diff --git a/CLAUDE.md b/CLAUDE.md index 3c5b70f..0dcc874 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ ctest --test-dir build --output-on-failure Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way. -The test suite is a single hand-rolled binary (no framework), so there is no +The core test suite is a single hand-rolled binary (no framework), so there is no per-test filter: run `./build/tests/effector_tests` directly to see the section headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`, `image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment @@ -20,6 +20,13 @@ out the other blocks in `tests/core_tests.cpp` or add a new `std::cout << "section\n"` block — checks are plain `check(condition, "description")` calls and `main()` returns the failure count. +The interface has its own binary, `./build/tests/effector_gui_tests` +(`tests/gui_tests.cpp`, same `check()` style), which opens the real window and +drives it by widget names and tooltips (`chain-list`, `add-effect`, +`sources`, "Move up", ...). It needs a display: ctest runs it under `xvfb-run` +when that is installed and otherwise reports it skipped (exit 77). To run it by +hand, use the Xvfb invocation below. + Headless render (no display needed, the fastest end-to-end smoke test): ```bash @@ -148,8 +155,9 @@ is discarded rather than mixed into the new project (`dropLoadedImages_`). `effector_core` (static) holds image IO, params, effects, rng and links only `gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need -no display) + Threads — **no gtkmm, no display**. The `effector` executable -adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of +no display) + Threads — **no gtkmm, no display**. `effector_gui` (static) adds +`src/cli/` and `src/gui/` and links gtkmm, and the `effector` executable is +`src/main.cpp` on top of it; the GUI tests link `effector_gui` too. Keep GTK out of `src/core/` and `src/effects/`; the tests link the core alone and rely on this. ### Determinism is a contract diff --git a/CMakeLists.txt b/CMakeLists.txt index 52b821a..d1c9210 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,14 +54,18 @@ target_include_directories(effector_core PUBLIC src) target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF PkgConfig::PANGOCAIRO Threads::Threads) target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic) -# --- application -------------------------------------------------------------- -add_executable(effector - src/main.cpp +# --- interface: the GTK window, split out so the GUI tests can link it -------- +add_library(effector_gui STATIC src/cli/options.cpp src/gui/app.cpp src/gui/window.cpp ) -target_link_libraries(effector PRIVATE effector_core PkgConfig::GTKMM) +target_link_libraries(effector_gui PUBLIC effector_core PkgConfig::GTKMM) +target_compile_options(effector_gui PRIVATE -Wall -Wextra -Wpedantic) + +# --- application -------------------------------------------------------------- +add_executable(effector src/main.cpp) +target_link_libraries(effector PRIVATE effector_gui) target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic) install(TARGETS effector RUNTIME DESTINATION bin) diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 9de00d9..b5ade6e 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -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(); + 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(); + switcher->set_stack(*stack); + switcher->set_halign(Gtk::Align::CENTER); + auto* sidebar = Gtk::make_managed(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::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(); 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::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::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::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::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::Orientation::HORIZONTAL, 8); auto* seedLabel = Gtk::make_managed("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& result // ---------------------------------------------------------------- chain --- -Gtk::Widget& EffectorWindow::buildChainSection() { - auto* box = Gtk::make_managed(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::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::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("No effects yet"); @@ -1151,7 +1222,10 @@ Gtk::Widget& EffectorWindow::buildChainSection() { auto* scroller = Gtk::make_managed(); 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(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(); + 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(i); auto* row = Gtk::make_managed(); - auto* box = Gtk::make_managed(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::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() { diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 5d16101..6fb4103 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -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 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_; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e98d7bd..863498c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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 $) +else() + add_test(NAME gui COMMAND effector_gui_tests) +endif() +set_tests_properties(gui PROPERTIES SKIP_RETURN_CODE 77) diff --git a/tests/gui_tests.cpp b/tests/gui_tests.cpp new file mode 100644 index 0000000..5204b18 --- /dev/null +++ b/tests/gui_tests.cpp @@ -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 + +#include +#include +#include +#include +#include +#include +#include + +#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 +void collect(Gtk::Widget& root, Pred pred, std::vector& out) { + for (auto* child = root.get_first_child(); child != nullptr; child = child->get_next_sibling()) { + if (auto* match = dynamic_cast(child); match != nullptr && pred(*match)) out.push_back(match); + collect(*child, pred, out); + } +} + +template +std::vector all(Gtk::Widget& root) { + std::vector out; + collect(root, [](T&) { return true; }, out); + return out; +} + +template +T* named(Gtk::Widget& root, const std::string& name) { + std::vector out; + collect(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 out; + collect(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(*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(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(window).empty() ? nullptr : all(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(window, "chain-list"); + auto* add = named(window, "add-effect"); + auto* sources = named(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 panes; + collect(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(*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(*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 actions; + collect(*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(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::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; +}