Merge branch 'sidebar-tabs'
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