Merge branch 'sidebar-tabs'

This commit is contained in:
Si
2026-09-23 21:28:07 +02:00
6 changed files with 437 additions and 44 deletions
+11 -3
View File
@@ -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
+8 -4
View File
@@ -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)
+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_;
+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;
}