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:
@@ -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
@@ -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
@@ -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
@@ -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_;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user