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
+16
View File
@@ -5,3 +5,19 @@ target_link_libraries(effector_tests PRIVATE effector_core)
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME core COMMAND effector_tests)
# The interface drives the real window, so it needs a display. Under ctest it
# gets a private Xvfb one when xvfb-run is installed; without it, or without a
# display of its own, the test reports itself skipped rather than failed.
add_executable(effector_gui_tests gui_tests.cpp)
target_link_libraries(effector_gui_tests PRIVATE effector_gui)
target_compile_options(effector_gui_tests PRIVATE -Wall -Wextra -Wpedantic)
find_program(XVFB_RUN xvfb-run)
if(XVFB_RUN)
add_test(NAME gui COMMAND ${XVFB_RUN} -a -s "-screen 0 1400x900x24"
env -u WAYLAND_DISPLAY GDK_BACKEND=x11 $<TARGET_FILE:effector_gui_tests>)
else()
add_test(NAME gui COMMAND effector_gui_tests)
endif()
set_tests_properties(gui PROPERTIES SKIP_RETURN_CODE 77)
+275
View File
@@ -0,0 +1,275 @@
// Checks for the interface, driven through the real window: the widgets are
// found by what a user sees (a name, a tooltip, a label) and pressed the way a
// click would press them. Needs a display; exits 77 (skipped) without one.
#include <gtkmm.h>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include "cli/options.hpp"
#include "core/registry.hpp"
#include "gui/window.hpp"
namespace {
int failures = 0;
void check(bool condition, const std::string& what) {
if (condition) {
std::cout << " ok " << what << "\n";
} else {
std::cout << " FAIL " << what << "\n";
++failures;
}
}
// Lets the main loop run for a while: popovers map, renders come back, and
// the rows rebuilt by a click are laid out.
void pump(int milliseconds = 100) {
auto context = Glib::MainContext::get_default();
const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(milliseconds);
while (std::chrono::steady_clock::now() < until) {
while (context->pending()) context->iteration(false);
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
template <typename T, typename Pred>
void collect(Gtk::Widget& root, Pred pred, std::vector<T*>& out) {
for (auto* child = root.get_first_child(); child != nullptr; child = child->get_next_sibling()) {
if (auto* match = dynamic_cast<T*>(child); match != nullptr && pred(*match)) out.push_back(match);
collect<T>(*child, pred, out);
}
}
template <typename T>
std::vector<T*> all(Gtk::Widget& root) {
std::vector<T*> out;
collect<T>(root, [](T&) { return true; }, out);
return out;
}
template <typename T>
T* named(Gtk::Widget& root, const std::string& name) {
std::vector<T*> out;
collect<T>(root, [&](T& w) { return w.get_name() == name; }, out);
return out.empty() ? nullptr : out.front();
}
Gtk::Button* withTooltip(Gtk::Widget& root, const std::string& tip) {
std::vector<Gtk::Button*> out;
collect<Gtk::Button>(root, [&](Gtk::Button& b) { return b.get_tooltip_text() == tip; }, out);
return out.empty() ? nullptr : out.front();
}
bool isAncestor(const Gtk::Widget& ancestor, const Gtk::Widget& widget) {
for (auto* w = widget.get_parent(); w != nullptr; w = w->get_parent()) {
if (w == &ancestor) return true;
}
return false;
}
int rowCount(Gtk::ListBox& list) {
int count = 0;
while (list.get_row_at_index(count) != nullptr) ++count;
return count;
}
std::string rowTitle(Gtk::ListBox& list, int index) {
auto* row = list.get_row_at_index(index);
if (row == nullptr) return {};
const auto labels = all<Gtk::Label>(*row);
return labels.empty() ? std::string{} : std::string(labels.front()->get_text());
}
// The top of `widget` in the window's coordinates.
double topIn(Gtk::Widget& widget, Gtk::Widget& window) {
graphene_rect_t bounds;
if (!gtk_widget_compute_bounds(widget.gobj(), window.gobj(), &bounds)) return -1.0;
return bounds.origin.y;
}
// The menu of effects: the popover the "Add effect" button opens, and the
// entry in it for one effect.
Gtk::Button* menuEntry(Gtk::Popover& menu, const std::string& title) {
for (auto* button : all<Gtk::Button>(menu)) {
if (button->get_label() == title) return button;
}
return nullptr;
}
// Presses a button the way the keyboard does. GTK shows the press for a
// quarter of a second before the button reports the click, so wait it out.
void press(Gtk::Button& button) {
button.activate();
pump(400);
}
bool addFromMenu(Gtk::MenuButton& add, const std::string& title) {
auto* menu = add.get_popover();
if (menu == nullptr) return false;
add.popup();
pump();
auto* entry = menuEntry(*menu, title);
if (entry == nullptr || !menu->get_mapped()) return false;
press(*entry);
return true;
}
void runTests(Gtk::Window& window) {
const auto& effects = effector::effects();
std::cout << "sidebar\n";
auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front();
check(stack != nullptr && stack->get_child_by_name("chain") != nullptr &&
stack->get_child_by_name("setup") != nullptr,
"the sidebar has a Chain and a Setup tab");
check(stack != nullptr && stack->get_visible_child_name() == "chain",
"it opens on the Chain tab");
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
auto* add = named<Gtk::MenuButton>(window, "add-effect");
auto* sources = named<Gtk::Box>(window, "sources");
check(chainList != nullptr && add != nullptr && sources != nullptr,
"the chain list, the Add effect button and the sources are there");
if (chainList == nullptr || add == nullptr || sources == nullptr) return;
std::vector<Gtk::Paned*> panes;
collect<Gtk::Paned>(window, [&](Gtk::Paned& p) { return isAncestor(p, *chainList); }, panes);
auto* split = panes.empty() ? nullptr : panes.back();
check(split != nullptr && split->get_orientation() == Gtk::Orientation::VERTICAL,
"the Chain tab is a vertical split");
check(split != nullptr && split->get_start_child() != nullptr &&
isAncestor(*split->get_start_child(), *chainList),
"the chain list is above the split");
check(split != nullptr && split->get_end_child() != nullptr &&
!isAncestor(*split->get_end_child(), *chainList),
"the params are below it, apart from the list");
check(split != nullptr && !split->get_shrink_start_child() && !split->get_shrink_end_child(),
"neither side can be dragged below its minimum");
check(topIn(*add, window) >= 0.0 && topIn(*add, window) < topIn(*chainList, window),
"Add effect sits above the chain list, not under it");
check(isAncestor(*stack->get_child_by_name("setup"), *sources),
"the sources are on the Setup tab");
std::cout << "add effect\n";
auto* menu = add->get_popover();
check(menu != nullptr, "Add effect has a menu");
if (menu == nullptr) return;
bool listed = true;
for (const auto& effect : effects) listed = listed && menuEntry(*menu, effect->title()) != nullptr;
check(listed, "the menu has an entry for every registered effect");
check(all<Gtk::Button>(*menu).size() == effects.size(), "and nothing else");
add->popup();
pump();
check(menu->get_mapped(), "the menu opens");
// A popover taller than the room below its button is not shown at all --
// though GTK still calls it mapped -- which is what broke the button once
// there were enough effects: the entries have to scroll instead, in a
// menu short enough to open below a button near the top of a 768-pixel
// screen.
const auto scrollers = all<Gtk::ScrolledWindow>(*menu);
const int cap = scrollers.empty() ? -1 : scrollers.front()->get_max_content_height();
check(!scrollers.empty() && isAncestor(*scrollers.front(), *menuEntry(*menu, effects.front()->title())),
"the entries scroll");
check(cap > 0 && cap <= 480, "past a height that fits a small screen");
check(menu->get_height() > 0 && menu->get_height() <= cap + 40,
"and the menu stays within it however many effects there are");
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();
check(rowCount(*chainList) == 0, "the chain starts empty");
check(addFromMenu(*add, "Blur"), "picking Blur from the menu");
check(!menu->get_mapped(), "closes the menu");
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
"and appends a Blur stage");
check(chainList->get_selected_row() == chainList->get_row_at_index(0),
"the new stage is selected");
check(!sources->get_visible(), "a chain led by blur hides the sources");
check(addFromMenu(*add, "Scanlines"), "picking Scanlines");
check(rowCount(*chainList) == 2 && rowTitle(*chainList, 1) == "2. Scanlines",
"appends it after Blur");
check(chainList->get_selected_row() == chainList->get_row_at_index(1),
"and selects it");
std::cout << "stage rows\n";
if (rowCount(*chainList) != 2) return;
auto* second = chainList->get_row_at_index(1);
check(second->has_css_class("stage-row"), "a row is a stage row");
std::vector<Gtk::Button*> actions;
collect<Gtk::Button>(*second, [](Gtk::Button& b) { return b.has_css_class("stage-action"); }, actions);
check(actions.size() == 3, "with a move up, a move down and a remove action");
auto* first = chainList->get_row_at_index(0);
auto* firstUp = withTooltip(*first, "Move up");
auto* lastDown = withTooltip(*second, "Move down");
check(firstUp != nullptr && !firstUp->get_sensitive(), "the first stage cannot move up");
check(lastDown != nullptr && !lastDown->get_sensitive(), "the last stage cannot move down");
auto* up = withTooltip(*second, "Move up");
check(up != nullptr, "the second stage has Move up");
if (up != nullptr) press(*up);
check(rowTitle(*chainList, 0) == "1. Scanlines" && rowTitle(*chainList, 1) == "2. Blur",
"Move up swaps the stages");
check(chainList->get_selected_row() == chainList->get_row_at_index(0),
"and the moved stage stays selected");
check(sources->get_visible(), "a chain led by scanlines shows the sources");
auto* remove = withTooltip(*chainList->get_row_at_index(0), "Remove");
if (remove != nullptr) press(*remove);
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
"Remove drops the stage and renumbers the rest");
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());
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");
}
} // namespace
int main(int argc, char** argv) {
// A crash should still leave every check before it on the screen.
std::cout << std::unitbuf;
const char* x11 = std::getenv("DISPLAY");
const char* wayland = std::getenv("WAYLAND_DISPLAY");
if ((x11 == nullptr || *x11 == '\0') && (wayland == nullptr || *wayland == '\0')) {
std::cout << "no display, skipped\n";
return 77;
}
auto app = Gtk::Application::create("org.effector.GuiTests",
Gio::Application::Flags::NON_UNIQUE);
app->signal_activate().connect([&app] {
auto window = std::make_unique<effector::EffectorWindow>(effector::Options{});
app->add_window(*window);
window->present();
pump(300);
runTests(*window);
app->remove_window(*window);
});
const int status = app->run(argc > 0 ? 1 : 0, argv);
if (status != 0) return status;
std::cout << (failures == 0 ? "all passed\n" : std::to_string(failures) + " failed\n");
return failures;
}