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.
|
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
|
per-test filter: run `./build/tests/effector_tests` directly to see the section
|
||||||
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
|
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
|
||||||
`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
|
`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
|
`std::cout << "section\n"` block — checks are plain
|
||||||
`check(condition, "description")` calls and `main()` returns the failure count.
|
`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):
|
Headless render (no display needed, the fastest end-to-end smoke test):
|
||||||
|
|
||||||
```bash
|
```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
|
`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
|
`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need
|
||||||
no display) + Threads — **no gtkmm, no display**. The `effector` executable
|
no display) + Threads — **no gtkmm, no display**. `effector_gui` (static) adds
|
||||||
adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of
|
`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.
|
`src/core/` and `src/effects/`; the tests link the core alone and rely on this.
|
||||||
|
|
||||||
### Determinism is a contract
|
### 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_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF PkgConfig::PANGOCAIRO Threads::Threads)
|
||||||
target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
|
||||||
# --- application --------------------------------------------------------------
|
# --- interface: the GTK window, split out so the GUI tests can link it --------
|
||||||
add_executable(effector
|
add_library(effector_gui STATIC
|
||||||
src/main.cpp
|
|
||||||
src/cli/options.cpp
|
src/cli/options.cpp
|
||||||
src/gui/app.cpp
|
src/gui/app.cpp
|
||||||
src/gui/window.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)
|
target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
|
||||||
install(TARGETS effector RUNTIME DESTINATION bin)
|
install(TARGETS effector RUNTIME DESTINATION bin)
|
||||||
|
|||||||
+122
-35
@@ -118,29 +118,63 @@ void EffectorWindow::buildUi() {
|
|||||||
root->append(*paned);
|
root->append(*paned);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The chain is what the interface is about, so it comes first and the params
|
// The sidebar is two tabs. "Chain" is what the interface is about: the stages
|
||||||
// of the selected stage sit right below it. The sources are a prerequisite,
|
// and the params of the selected one, with the whole height to themselves.
|
||||||
// not the subject: they live at the bottom in a collapsed expander.
|
// "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() {
|
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);
|
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
||||||
sidebar->set_margin(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_.set_xalign(0.0F);
|
||||||
paramHeading_.add_css_class("heading");
|
paramHeading_.add_css_class("heading");
|
||||||
sidebar->append(paramHeading_);
|
params->append(paramHeading_);
|
||||||
paramGrid_.set_column_spacing(8);
|
paramGrid_.set_column_spacing(8);
|
||||||
paramGrid_.set_row_spacing(6);
|
paramGrid_.set_row_spacing(6);
|
||||||
auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||||||
paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||||
paramScroller->set_child(paramGrid_);
|
paramScroller->set_child(paramGrid_);
|
||||||
paramScroller->set_vexpand(true);
|
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();
|
rebuildChainRows();
|
||||||
buildParamWidgets();
|
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_.set_selection_mode(Gtk::SelectionMode::NONE);
|
||||||
sourceList_.add_css_class("boxed-list");
|
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_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||||
scroller->set_child(sourceList_);
|
scroller->set_child(sourceList_);
|
||||||
scroller->set_size_request(-1, 140);
|
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);
|
sourcesHeading_.set_xalign(0.0F);
|
||||||
sourcesBox->append(*scroller);
|
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
|
// 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.
|
// 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_halign(Gtk::Align::START);
|
||||||
addButton->set_tooltip_text("Add source images");
|
addButton->set_tooltip_text("Add source images");
|
||||||
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
|
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
|
||||||
sourcesBox->append(*addButton);
|
sourcesSection_.append(*addButton);
|
||||||
addButton_ = addButton;
|
addButton_ = addButton;
|
||||||
|
|
||||||
sourcesExpander_.set_child(*sourcesBox);
|
|
||||||
sourcesExpander_.set_expanded(false);
|
|
||||||
updateSourcesLabel();
|
updateSourcesLabel();
|
||||||
sidebar->append(sourcesExpander_);
|
tab->append(sourcesSection_);
|
||||||
|
|
||||||
sidebar->append(*sectionLabel("Canvas"));
|
tab->append(*sectionLabel("Canvas"));
|
||||||
sidebar->append(buildCanvasSection());
|
tab->append(buildCanvasSection());
|
||||||
|
|
||||||
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
||||||
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
|
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
|
||||||
@@ -188,9 +226,9 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
|
|||||||
seedEntry_.set_text(std::to_string(seed_));
|
seedEntry_.set_text(std::to_string(seed_));
|
||||||
seedEntry_.signal_activate().connect(sigc::mem_fun(*this, &EffectorWindow::onSeedEntered));
|
seedEntry_.signal_activate().connect(sigc::mem_fun(*this, &EffectorWindow::onSeedEntered));
|
||||||
seedBox->append(seedEntry_);
|
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
|
// 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
|
// rather than left there to be filled in vain. This is the same declarative
|
||||||
// answer the render gate uses -- no effect is named here.
|
// answer the render gate uses -- no effect is named here.
|
||||||
void EffectorWindow::updateSourcesVisible() {
|
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
|
// The heading carries the count, which a long list scrolled away would hide.
|
||||||
// used to be plain to see in the list.
|
|
||||||
void EffectorWindow::updateSourcesLabel() {
|
void EffectorWindow::updateSourcesLabel() {
|
||||||
int count = 0;
|
int count = 0;
|
||||||
while (sourceList_.get_row_at_index(count) != nullptr) ++count;
|
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) + ")");
|
: "Source images (" + std::to_string(count) + ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +368,7 @@ Gtk::Widget& EffectorWindow::buildPreview() {
|
|||||||
picture_.set_can_shrink(true);
|
picture_.set_can_shrink(true);
|
||||||
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
|
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_.set_wrap(true);
|
||||||
placeholder_.add_css_class("dim-label");
|
placeholder_.add_css_class("dim-label");
|
||||||
|
|
||||||
@@ -1135,10 +1172,44 @@ void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- chain ---
|
// ---------------------------------------------------------------- chain ---
|
||||||
|
|
||||||
Gtk::Widget& EffectorWindow::buildChainSection() {
|
// The move and remove buttons are faded out until the pointer is over their
|
||||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
// row, or the row is selected or focused, so a long chain reads as a list of
|
||||||
box->append(*sectionLabel("Effect chain"));
|
// 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_.add_css_class("boxed-list");
|
||||||
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
|
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
|
||||||
auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet");
|
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>();
|
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||||||
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||||
scroller->set_child(chainList_);
|
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);
|
box->append(*scroller);
|
||||||
|
|
||||||
// set_label() and set_icon_name() replace one another, so the icon and the
|
// 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_always_show_arrow(true);
|
||||||
addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far");
|
addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far");
|
||||||
addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu()));
|
addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu()));
|
||||||
addEffectButton_.set_halign(Gtk::Align::START);
|
addEffectButton_.set_name("add-effect");
|
||||||
box->append(addEffectButton_);
|
|
||||||
|
|
||||||
return *box;
|
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
|
// One entry per registered effect, so a new effect shows up here without this
|
||||||
// file knowing anything about it.
|
// file knowing anything about it.
|
||||||
Gtk::Widget& EffectorWindow::buildAddMenu() {
|
Gtk::Widget& EffectorWindow::buildAddMenu() {
|
||||||
@@ -1188,7 +1264,15 @@ Gtk::Widget& EffectorWindow::buildAddMenu() {
|
|||||||
list->append(*item);
|
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;
|
return *popover;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1239,9 +1323,10 @@ void EffectorWindow::rebuildChainRows() {
|
|||||||
for (std::size_t i = 0; i < stages_.size(); ++i) {
|
for (std::size_t i = 0; i < stages_.size(); ++i) {
|
||||||
const int index = static_cast<int>(i);
|
const int index = static_cast<int>(i);
|
||||||
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
|
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
|
||||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4);
|
row->add_css_class("stage-row");
|
||||||
box->set_margin_top(4);
|
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 2);
|
||||||
box->set_margin_bottom(4);
|
box->set_margin_top(1);
|
||||||
|
box->set_margin_bottom(1);
|
||||||
box->set_margin_start(8);
|
box->set_margin_start(8);
|
||||||
box->set_margin_end(4);
|
box->set_margin_end(4);
|
||||||
|
|
||||||
@@ -1281,6 +1366,8 @@ void EffectorWindow::rebuildChainRows() {
|
|||||||
button->set_icon_name(action.icon);
|
button->set_icon_name(action.icon);
|
||||||
button->set_tooltip_text(action.tip);
|
button->set_tooltip_text(action.tip);
|
||||||
button->add_css_class("flat");
|
button->add_css_class("flat");
|
||||||
|
button->add_css_class("stage-action");
|
||||||
|
button->set_valign(Gtk::Align::CENTER);
|
||||||
const int delta = action.delta;
|
const int delta = action.delta;
|
||||||
button->signal_clicked().connect([this, index, delta] {
|
button->signal_clicked().connect([this, index, delta] {
|
||||||
if (delta == 0) {
|
if (delta == 0) {
|
||||||
@@ -1397,7 +1484,7 @@ bool EffectorWindow::canRender() const {
|
|||||||
std::string EffectorWindow::missingText() const {
|
std::string EffectorWindow::missingText() const {
|
||||||
if (stages_.empty()) return "Add an effect to start.";
|
if (stages_.empty()) return "Add an effect to start.";
|
||||||
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
|
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() {
|
void EffectorWindow::showPlaceholder() {
|
||||||
|
|||||||
+5
-2
@@ -29,6 +29,8 @@ public:
|
|||||||
private:
|
private:
|
||||||
void buildUi();
|
void buildUi();
|
||||||
Gtk::Widget& buildSidebar();
|
Gtk::Widget& buildSidebar();
|
||||||
|
Gtk::Widget& buildChainTab();
|
||||||
|
Gtk::Widget& buildSetupTab();
|
||||||
Gtk::Widget& buildPreview();
|
Gtk::Widget& buildPreview();
|
||||||
void buildParamWidgets();
|
void buildParamWidgets();
|
||||||
ParamValues paramValues() const;
|
ParamValues paramValues() const;
|
||||||
@@ -72,7 +74,7 @@ private:
|
|||||||
void closeLoadWindow();
|
void closeLoadWindow();
|
||||||
void cancelLoad();
|
void cancelLoad();
|
||||||
void appendSourceRow(const std::string& path);
|
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.
|
// The whole sources section only shows while the chain asks for sources.
|
||||||
void updateSourcesVisible();
|
void updateSourcesVisible();
|
||||||
|
|
||||||
@@ -183,7 +185,8 @@ private:
|
|||||||
std::unique_ptr<Gtk::Window> loadWindow_;
|
std::unique_ptr<Gtk::Window> loadWindow_;
|
||||||
Gtk::ProgressBar* loadBar_ = nullptr;
|
Gtk::ProgressBar* loadBar_ = nullptr;
|
||||||
Gtk::Label* loadFileLabel_ = 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::ListBox sourceList_;
|
||||||
Gtk::DropDown formatDrop_;
|
Gtk::DropDown formatDrop_;
|
||||||
Gtk::SpinButton widthSpin_;
|
Gtk::SpinButton widthSpin_;
|
||||||
|
|||||||
@@ -5,3 +5,19 @@ target_link_libraries(effector_tests PRIVATE effector_core)
|
|||||||
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
|
||||||
add_test(NAME core COMMAND effector_tests)
|
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