diff --git a/src/gui/window.cpp b/src/gui/window.cpp index b62f94b..278083e 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -107,6 +107,7 @@ EffectorWindow::EffectorWindow(const Options& initial) { EffectorWindow::~EffectorWindow() { debounce_.disconnect(); + chainSplitFit_.disconnect(); loadWindowDelay_.disconnect(); loadCancel_.store(true, std::memory_order_relaxed); if (loader_.joinable()) loader_.join(); @@ -175,8 +176,14 @@ Gtk::Widget& EffectorWindow::buildSidebar() { } // The chain and the params of the selected stage share the tab through a -// split the user drags: a long chain wants the list, a stage with many params -// wants the grid, and no fixed share suits both. +// split. Until the user drags it, the split rests under the chain's rows +// (fitChainSplit), so a short chain leaves the tab to the params and a long +// one grows up to chainListMaxHeight and then scrolls; once dragged, it stays +// where it was put. +// The tallest the chain list grows on its own before it scrolls, in pixels: +// room for eight stages. +constexpr int chainListMaxHeight = 270; + Gtk::Widget& EffectorWindow::buildChainTab() { auto* params = Gtk::make_managed(Gtk::Orientation::VERTICAL, 12); params->set_margin_top(12); @@ -197,9 +204,58 @@ Gtk::Widget& EffectorWindow::buildChainTab() { auto* split = Gtk::make_managed(Gtk::Orientation::VERTICAL); split->set_start_child(chain); split->set_end_child(*params); + split->set_resize_start_child(false); + split->set_resize_end_child(true); split->set_shrink_start_child(false); split->set_shrink_end_child(false); - split->set_position(300); + chainSplit_ = split; + split->property_position().signal_changed().connect([this] { onChainSplitMoved(); }); + // The room the split has changes with the window, and a fit it had to + // clamp may fit whole now. + split->property_max_position().signal_changed().connect([this] { scheduleChainSplitFit(); }); + // A double-click on the handle undoes the drag: the split fits the rows + // again, and follows them from then on. The handle is the one child of the + // split that is neither pane, but the click is heard on the split itself, + // grouped with the split's own drag: that drag claims every press on the + // handle, which would cancel a click gesture anywhere else. + Gtk::Widget* handle = nullptr; + for (auto* child = split->get_first_child(); child != nullptr; child = child->get_next_sibling()) { + if (child != &chain && child != params) handle = child; + } + if (handle != nullptr) { + handle->set_tooltip_text("Drag to share the room, double-click to fit the chain"); + auto click = Gtk::GestureClick::create(); + click->set_propagation_phase(Gtk::PropagationPhase::CAPTURE); + // The split's drag ends every press on the handle, which starts the + // click's count over, so the second press is counted here. + auto lastPress = std::make_shared(0); + auto* gesture = click.get(); // not the RefPtr: the gesture owns this slot + click->signal_pressed().connect([this, split, handle, gesture, lastPress](int, double x, double y) { + // The handle is thin, and the drag takes presses a little past it. + const auto bounds = handle->compute_bounds(*split); + constexpr double slack = 6.0; + if (!bounds || y < bounds->get_y() - slack || + y > bounds->get_y() + bounds->get_height() + slack || + x < bounds->get_x() || x > bounds->get_x() + bounds->get_width()) { + *lastPress = 0; + return; + } + const guint32 now = gesture->get_current_event_time(); + const int doubleClick = Gtk::Settings::get_default()->property_gtk_double_click_time(); + if (*lastPress != 0 && now - *lastPress <= static_cast(doubleClick)) { + *lastPress = 0; + refitChainSplit(); + } else { + *lastPress = now; + } + }); + const auto controllers = split->observe_controllers(); + for (guint i = 0; i < controllers->get_n_items(); ++i) { + auto drag = std::dynamic_pointer_cast(controllers->get_object(i)); + if (drag) click->group(drag); + } + split->add_controller(click); + } rebuildChainRows(); buildParamWidgets(); return *split; @@ -1258,8 +1314,11 @@ Gtk::Widget& EffectorWindow::buildChainSection() { auto* scroller = Gtk::make_managed(); scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); scroller->set_child(chainList_); - // The split above the params decides the height; this is only the least - // it may be dragged down to. + // The list asks for the height of its rows, up to a cap, which is where + // the split above the params rests until it is dragged; the size request + // is only the least it may be dragged down to. + scroller->set_propagate_natural_height(true); + scroller->set_max_content_height(chainListMaxHeight); scroller->set_size_request(-1, 100); scroller->set_vexpand(true); box->append(*scroller); @@ -1425,6 +1484,56 @@ void EffectorWindow::rebuildChainRows() { selectStage(selectedStage_); suspendStageSelect_ = false; updateSourcesVisible(); + scheduleChainSplitFit(); +} + +// Once the rows are laid out, not in the middle of a rebuild or of an +// allocation, where moving the split would ask for another one. +void EffectorWindow::scheduleChainSplitFit() { + if (chainSplitDragged_ || chainSplitFit_.connected()) return; + chainSplitFit_ = Glib::signal_idle().connect([this] { + fitChainSplit(); + return false; + }); +} + +// GtkPaned would size the fixed pane from its minimum height, which is only +// the floor it may be dragged down to, so the fit to the rows' natural height +// is done here. +void EffectorWindow::fitChainSplit() { + if (chainSplit_ == nullptr || chainSplitDragged_) return; + auto* chain = chainSplit_->get_start_child(); + if (chain == nullptr) return; + int minimum = 0; + int natural = 0; + int minimumBaseline = 0; + int naturalBaseline = 0; + const int width = chainSplit_->get_width(); + chain->measure(Gtk::Orientation::VERTICAL, width > 0 ? width : -1, minimum, natural, + minimumBaseline, naturalBaseline); + fittedChainSplit_ = natural; + suspendSplitTrack_ = true; + chainSplit_->set_position(natural); + suspendSplitTrack_ = false; +} + +void EffectorWindow::refitChainSplit() { + chainSplitDragged_ = false; + fitChainSplit(); +} + +// Any move but a fit, or GTK clamping a fit into a window too short for it, +// is the user's drag. +void EffectorWindow::onChainSplitMoved() { + if (suspendSplitTrack_ || fittedChainSplit_ < 0) return; + const int position = chainSplit_->get_position(); + if (position == fittedChainSplit_) return; + const int lowest = chainSplit_->property_min_position(); + const int highest = chainSplit_->property_max_position(); + if (fittedChainSplit_ > highest && position == highest) return; + if (fittedChainSplit_ < lowest && position == lowest) return; + chainSplitDragged_ = true; + chainSplitFit_.disconnect(); } // Switching a stage off leaves everything about it alone -- its settings, its diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 37b3042..c1f6f30 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -95,6 +95,11 @@ private: // The check on a chain row: the stage stays where it is, and is skipped. void setStageEnabled(int index, bool enabled); void rebuildChainRows(); + // Rests the split under the chain's rows, until the user drags it. + void scheduleChainSplitFit(); + void fitChainSplit(); + void refitChainSplit(); // the double-click on the handle + void onChainSplitMoved(); std::string stageLabel(std::size_t index) const; void selectStage(int index); void onStageSelected(); @@ -146,6 +151,13 @@ private: StyleSet styles_; int selectedStage_ = 0; bool suspendStageSelect_ = false; // guards the rebuild of the chain rows + // The split between the chain and the params, and where it was last put + // to fit the rows: any other position the user reached by dragging. + Gtk::Paned* chainSplit_ = nullptr; + int fittedChainSplit_ = -1; + bool chainSplitDragged_ = false; + bool suspendSplitTrack_ = false; + sigc::connection chainSplitFit_; // The name label of each chain row, renamed in place as params change. std::vector stageLabels_; Image result_; diff --git a/tests/gui_tests.cpp b/tests/gui_tests.cpp index 5204b18..5070157 100644 --- a/tests/gui_tests.cpp +++ b/tests/gui_tests.cpp @@ -185,16 +185,16 @@ void runTests(Gtk::Window& window) { add->popdown(); pump(); - // With the split dragged all the way down, the button must still work. - if (split != nullptr) split->set_position(10000); - pump(); - add->popup(); - pump(); - check(menu->get_mapped(), "the menu opens with the split dragged to the bottom"); - add->popdown(); - pump(); - if (split != nullptr) split->set_position(300); - pump(); + + std::vector listScrollers; + collect(window, [&](Gtk::ScrolledWindow& s) { + return s.get_child() != nullptr && isAncestor(*s.get_child(), *chainList); + }, listScrollers); + auto* listScroller = listScrollers.empty() ? nullptr : listScrollers.back(); + check(listScroller != nullptr && listScroller->get_propagate_natural_height() && + listScroller->get_max_content_height() > 0, + "the chain list asks for the height of its rows, up to a cap"); + const int emptySplit = split != nullptr ? split->get_position() : -1; check(rowCount(*chainList) == 0, "the chain starts empty"); check(addFromMenu(*add, "Blur"), "picking Blur from the menu"); @@ -240,10 +240,35 @@ void runTests(Gtk::Window& window) { check(!sources->get_visible(), "and the sources hide again"); std::cout << "many stages\n"; - for (int i = 0; i < 12; ++i) addFromMenu(*add, effects[static_cast(i) % effects.size()]->title()); + for (int i = 0; i < 4; ++i) addFromMenu(*add, effects[static_cast(i) % effects.size()]->title()); + pump(); + const int fewSplit = split != nullptr ? split->get_position() : -1; + check(split != nullptr && fewSplit > emptySplit, + "the split moves down as the chain grows"); + for (int i = 4; i < 12; ++i) addFromMenu(*add, effects[static_cast(i) % effects.size()]->title()); check(rowCount(*chainList) == 13, "the menu still works with a long chain"); check(addFromMenu(*add, effects.back()->title()) && rowCount(*chainList) == 14, "including for the last effect in the menu"); + pump(); + const int longSplit = split != nullptr ? split->get_position() : -1; + check(longSplit > fewSplit && listScroller != nullptr && + listScroller->get_height() <= listScroller->get_max_content_height() + 2, + "up to the cap, past which the list scrolls"); + if (split != nullptr) split->set_position(emptySplit + 20); + pump(); + addFromMenu(*add, effects.front()->title()); + pump(); + check(split != nullptr && split->get_position() == emptySplit + 20, + "once dragged, the split stays put as the chain grows"); + + // With the split dragged all the way down, the button must still work. + if (split != nullptr) split->set_position(10000); + pump(); + add->popup(); + pump(); + check(menu->get_mapped(), "the menu opens with the split dragged to the bottom"); + add->popdown(); + pump(); } } // namespace