Fit the chain/params split to the chain's rows until it is dragged

The split between the chain list and the params sat at a fixed 300 px,
wrong for a short chain and for a long one alike. It now rests under the
chain's rows, growing with the chain up to eight stages before the list
scrolls. Once the user drags it, it stays put; a double-click on the
handle fits it to the rows again.

GtkPaned sizes the fixed pane from its minimum height, which is only the
drag floor here, so the fit is done by hand from the natural height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-23 22:21:51 +02:00
co-authored by Claude Opus 5.5
parent d0b5669f46
commit a01ada5372
3 changed files with 162 additions and 16 deletions
+114 -5
View File
@@ -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::Box>(Gtk::Orientation::VERTICAL, 12);
params->set_margin_top(12);
@@ -197,9 +204,58 @@ Gtk::Widget& EffectorWindow::buildChainTab() {
auto* split = Gtk::make_managed<Gtk::Paned>(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<guint32>(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<guint32>(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<Gtk::Gesture>(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<Gtk::ScrolledWindow>();
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
+12
View File
@@ -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<Gtk::Label*> stageLabels_;
Image result_;
+36 -11
View File
@@ -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<Gtk::ScrolledWindow*> listScrollers;
collect<Gtk::ScrolledWindow>(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<std::size_t>(i) % effects.size()]->title());
for (int i = 0; i < 4; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(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<std::size_t>(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