Split the sidebar into Chain and Setup tabs, with a draggable chain list

The sidebar grew crowded as the chain got longer. The chain and the params
of the selected stage now share a Chain tab through a vertical split the
user drags, and the sources, canvas and seed move to a Setup tab. Stage
rows are compact, with their move and remove buttons shown only on the
hovered, selected or focused row.

Add effect had stopped opening: its menu of fifteen effects no longer fit
below the button, and GTK does not show a popover that does not fit. The
menu now scrolls past 480 pixels, and the button sits beside the chain
heading, where the split cannot push it down the screen.

The window code becomes an effector_gui library so a new GUI test binary
can drive the real window under Xvfb; ctest skips it when xvfb-run is
missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-23 21:26:42 +02:00
co-authored by Claude Opus 5.5
parent 1edf854cd1
commit 5a5cea69db
6 changed files with 437 additions and 44 deletions
+122 -35
View File
@@ -118,29 +118,63 @@ void EffectorWindow::buildUi() {
root->append(*paned);
}
// The chain is what the interface is about, so it comes first and the params
// of the selected stage sit right below it. The sources are a prerequisite,
// not the subject: they live at the bottom in a collapsed expander.
// The sidebar is two tabs. "Chain" is what the interface is about: the stages
// and the params of the selected one, with the whole height to themselves.
// "Setup" holds what is set once per project -- the sources, the canvas and
// the seed -- so it never takes room from the chain however long that grows.
Gtk::Widget& EffectorWindow::buildSidebar() {
auto* stack = Gtk::make_managed<Gtk::Stack>();
stack->set_vexpand(true);
stack->set_transition_type(Gtk::StackTransitionType::CROSSFADE);
stack->add(buildChainTab(), "chain", "Chain");
stack->add(buildSetupTab(), "setup", "Setup");
auto* switcher = Gtk::make_managed<Gtk::StackSwitcher>();
switcher->set_stack(*stack);
switcher->set_halign(Gtk::Align::CENTER);
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sidebar->set_margin(12);
sidebar->append(*switcher);
sidebar->append(*stack);
return *sidebar;
}
sidebar->append(buildChainSection());
// 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.
Gtk::Widget& EffectorWindow::buildChainTab() {
auto* params = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
params->set_margin_top(12);
paramHeading_.set_xalign(0.0F);
paramHeading_.add_css_class("heading");
sidebar->append(paramHeading_);
params->append(paramHeading_);
paramGrid_.set_column_spacing(8);
paramGrid_.set_row_spacing(6);
auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>();
paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
paramScroller->set_child(paramGrid_);
paramScroller->set_vexpand(true);
sidebar->append(*paramScroller);
params->append(*paramScroller);
auto& chain = buildChainSection();
chain.set_margin_bottom(6);
auto* split = Gtk::make_managed<Gtk::Paned>(Gtk::Orientation::VERTICAL);
split->set_start_child(chain);
split->set_end_child(*params);
split->set_shrink_start_child(false);
split->set_shrink_end_child(false);
split->set_position(300);
rebuildChainRows();
buildParamWidgets();
return *split;
}
sidebar->append(*Gtk::make_managed<Gtk::Separator>(Gtk::Orientation::HORIZONTAL));
// The sources come first because they are the first thing a project needs;
// their list takes whatever height the canvas and seed leave over.
Gtk::Widget& EffectorWindow::buildSetupTab() {
auto* tab = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sourceList_.set_selection_mode(Gtk::SelectionMode::NONE);
sourceList_.add_css_class("boxed-list");
@@ -153,10 +187,16 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(sourceList_);
scroller->set_size_request(-1, 140);
scroller->set_margin_top(6);
scroller->set_vexpand(true);
auto* sourcesBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
sourcesBox->append(*scroller);
sourcesHeading_.set_xalign(0.0F);
sourcesHeading_.add_css_class("heading");
sourcesSection_.set_name("sources");
sourcesSection_.set_orientation(Gtk::Orientation::VERTICAL);
sourcesSection_.set_spacing(6);
sourcesSection_.set_vexpand(true);
sourcesSection_.append(sourcesHeading_);
sourcesSection_.append(*scroller);
// Same shape as "Add effect": the icon and the text are put together by
// hand because set_label() and set_icon_name() replace one another.
@@ -169,16 +209,14 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
addButton->set_halign(Gtk::Align::START);
addButton->set_tooltip_text("Add source images");
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
sourcesBox->append(*addButton);
sourcesSection_.append(*addButton);
addButton_ = addButton;
sourcesExpander_.set_child(*sourcesBox);
sourcesExpander_.set_expanded(false);
updateSourcesLabel();
sidebar->append(sourcesExpander_);
tab->append(sourcesSection_);
sidebar->append(*sectionLabel("Canvas"));
sidebar->append(buildCanvasSection());
tab->append(*sectionLabel("Canvas"));
tab->append(buildCanvasSection());
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
@@ -188,9 +226,9 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
seedEntry_.set_text(std::to_string(seed_));
seedEntry_.signal_activate().connect(sigc::mem_fun(*this, &EffectorWindow::onSeedEntered));
seedBox->append(seedEntry_);
sidebar->append(*seedBox);
tab->append(*seedBox);
return *sidebar;
return *tab;
}
// Only the first stage is ever handed the sources, so whether the chain wants
@@ -199,15 +237,14 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
// rather than left there to be filled in vain. This is the same declarative
// answer the render gate uses -- no effect is named here.
void EffectorWindow::updateSourcesVisible() {
sourcesExpander_.set_visible(chainNeedsSources(stages_));
sourcesSection_.set_visible(chainNeedsSources(stages_));
}
// The expander is collapsed by default, so its label carries the count that
// used to be plain to see in the list.
// The heading carries the count, which a long list scrolled away would hide.
void EffectorWindow::updateSourcesLabel() {
int count = 0;
while (sourceList_.get_row_at_index(count) != nullptr) ++count;
sourcesExpander_.set_label(count == 0 ? "Source images"
sourcesHeading_.set_text(count == 0 ? "Source images"
: "Source images (" + std::to_string(count) + ")");
}
@@ -331,7 +368,7 @@ Gtk::Widget& EffectorWindow::buildPreview() {
picture_.set_can_shrink(true);
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
placeholder_.set_text("Add source images to start.");
placeholder_.set_text("Add source images under Setup to start.");
placeholder_.set_wrap(true);
placeholder_.add_css_class("dim-label");
@@ -1135,10 +1172,44 @@ void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result
// ---------------------------------------------------------------- chain ---
Gtk::Widget& EffectorWindow::buildChainSection() {
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
box->append(*sectionLabel("Effect chain"));
// The move and remove buttons are faded out until the pointer is over their
// row, or the row is selected or focused, so a long chain reads as a list of
// names rather than a column of icons. They keep their place while hidden,
// so the names do not jump as the pointer moves.
const char* const stageRowCss = R"css(
row.stage-row .stage-action {
min-width: 24px;
min-height: 24px;
padding: 0;
opacity: 0;
transition: opacity 120ms;
}
row.stage-row:hover .stage-action,
row.stage-row:selected .stage-action,
row.stage-row:focus-within .stage-action {
opacity: 1;
}
)css";
Gtk::Widget& EffectorWindow::buildChainSection() {
auto css = Gtk::CssProvider::create();
css->load_from_string(stageRowCss);
Gtk::StyleProvider::add_provider_for_display(Gdk::Display::get_default(), css,
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
// "Add effect" sits beside the heading rather than under the list: under
// it, the split could drag it so far down the screen that the menu of
// effects had no room left to open.
auto* heading = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
auto* title = sectionLabel("Effect chain");
title->set_hexpand(true);
heading->append(*title);
heading->append(addEffectButton_);
box->append(*heading);
chainList_.set_name("chain-list");
chainList_.add_css_class("boxed-list");
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet");
@@ -1151,7 +1222,10 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(chainList_);
scroller->set_size_request(-1, 150);
// The split above the params decides the height; this is only the least
// it may be dragged down to.
scroller->set_size_request(-1, 100);
scroller->set_vexpand(true);
box->append(*scroller);
// set_label() and set_icon_name() replace one another, so the icon and the
@@ -1164,12 +1238,14 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
addEffectButton_.set_always_show_arrow(true);
addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far");
addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu()));
addEffectButton_.set_halign(Gtk::Align::START);
box->append(addEffectButton_);
addEffectButton_.set_name("add-effect");
return *box;
}
// The tallest the menu of effects grows before it scrolls, in pixels.
constexpr int addMenuMaxHeight = 480;
// One entry per registered effect, so a new effect shows up here without this
// file knowing anything about it.
Gtk::Widget& EffectorWindow::buildAddMenu() {
@@ -1188,7 +1264,15 @@ Gtk::Widget& EffectorWindow::buildAddMenu() {
list->append(*item);
}
popover->set_child(*list);
// A popover that does not fit on the screen is not shown at all, and the
// list grows with every effect registered, so past a point it scrolls.
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_propagate_natural_height(true);
scroller->set_max_content_height(addMenuMaxHeight);
scroller->set_child(*list);
popover->set_child(*scroller);
return *popover;
}
@@ -1239,9 +1323,10 @@ void EffectorWindow::rebuildChainRows() {
for (std::size_t i = 0; i < stages_.size(); ++i) {
const int index = static_cast<int>(i);
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4);
box->set_margin_top(4);
box->set_margin_bottom(4);
row->add_css_class("stage-row");
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 2);
box->set_margin_top(1);
box->set_margin_bottom(1);
box->set_margin_start(8);
box->set_margin_end(4);
@@ -1281,6 +1366,8 @@ void EffectorWindow::rebuildChainRows() {
button->set_icon_name(action.icon);
button->set_tooltip_text(action.tip);
button->add_css_class("flat");
button->add_css_class("stage-action");
button->set_valign(Gtk::Align::CENTER);
const int delta = action.delta;
button->signal_clicked().connect([this, index, delta] {
if (delta == 0) {
@@ -1397,7 +1484,7 @@ bool EffectorWindow::canRender() const {
std::string EffectorWindow::missingText() const {
if (stages_.empty()) return "Add an effect to start.";
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
return "Add source images to start.";
return "Add source images under Setup to start.";
}
void EffectorWindow::showPlaceholder() {
+5 -2
View File
@@ -29,6 +29,8 @@ public:
private:
void buildUi();
Gtk::Widget& buildSidebar();
Gtk::Widget& buildChainTab();
Gtk::Widget& buildSetupTab();
Gtk::Widget& buildPreview();
void buildParamWidgets();
ParamValues paramValues() const;
@@ -72,7 +74,7 @@ private:
void closeLoadWindow();
void cancelLoad();
void appendSourceRow(const std::string& path);
void updateSourcesLabel(); // the collapsed expander shows the count
void updateSourcesLabel(); // the heading shows the count
// The whole sources section only shows while the chain asks for sources.
void updateSourcesVisible();
@@ -183,7 +185,8 @@ private:
std::unique_ptr<Gtk::Window> loadWindow_;
Gtk::ProgressBar* loadBar_ = nullptr;
Gtk::Label* loadFileLabel_ = nullptr;
Gtk::Expander sourcesExpander_;
Gtk::Box sourcesSection_; // hidden while the chain needs no sources
Gtk::Label sourcesHeading_;
Gtk::ListBox sourceList_;
Gtk::DropDown formatDrop_;
Gtk::SpinButton widthSpin_;