From ef8f728126bbe285b53c2356e3baccfed5fc4e3c Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 30 Sep 2026 23:44:27 +0200 Subject: [PATCH] Show Tile swap's Swap rate as a slider, and let it go slower A new ParamSpec::slider() marks an Int or Double param to be shown in the interface as a Gtk::Scale with its minimum and maximum marked under the ends, rather than a spin button; the command line and the project file are unchanged. Swap rate uses it, and now runs from 0 to 5 in steps of 0.01 (was 0 to 30 in steps of 0.1): with every tile swapping on its own, even 0.1 swapped the canvas many times a second. Version 0.8.2. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++ CLAUDE.md | 4 +++ CMakeLists.txt | 2 +- packaging/arch/PKGBUILD | 2 +- src/core/params.cpp | 6 +++++ src/core/params.hpp | 8 ++++++ src/effects/tileswap.cpp | 5 ++-- src/gui/window.cpp | 57 +++++++++++++++++++++++++++++++--------- tests/gui_tests.cpp | 46 ++++++++++++++++++++++++++++++++ 9 files changed, 120 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2adca1..c7b0776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Every release of Effector, newest first. The version lives in one place, the `project()` line of `CMakeLists.txt`, and each release is a `vX.Y.Z` tag on the commit that made it. The interface shows this file under Changelog in its menu. +## [0.8.2] - 2026-09-30 + +### Changed +- The Swap rate of Tile swap is a slider marked with its range, and goes + down in steps of 0.01 rather than 0.1, so a clip can swap its tiles much + more slowly. Its top end is now 5 swaps a second (it was 30). + ## [0.8.1] - 2026-09-26 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index a77ce9d..d7ca0c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,10 @@ travel as `ParamValues` (a `map>`), read back with `paramInt`/`paramDouble`/`paramBool`/`paramChoice`. +An Int or Double param marked `.slider()` is shown in +the interface as a slider with its minimum and maximum marked instead of a spin +button; nothing else changes. + A param can also be marked `.styled()`, which says a *named style* carries it rather than the stage it sits on: the font and the colour of a text stage, but never its words or its placement. The pool a style comes from is named by diff --git a/CMakeLists.txt b/CMakeLists.txt index 06589c6..5766dfc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(effector VERSION 0.8.1 LANGUAGES CXX) +project(effector VERSION 0.8.2 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index c7d5801..8a6fa04 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -2,7 +2,7 @@ # so pkgver must follow the project() line of CMakeLists.txt (the version core # test checks it). From this folder: makepkg -si pkgname=effector -pkgver=0.8.1 +pkgver=0.8.2 pkgrel=1 pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects' arch=('x86_64' 'aarch64') diff --git a/src/core/params.cpp b/src/core/params.cpp index 3a7d01d..428a87f 100644 --- a/src/core/params.cpp +++ b/src/core/params.cpp @@ -16,6 +16,12 @@ ParamSpec ParamSpec::styled() const { return copy; } +ParamSpec ParamSpec::slider() const { + ParamSpec copy = *this; + copy.onSlider = true; + return copy; +} + ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum, long maximum, std::string help, long step) { ParamSpec spec; diff --git a/src/core/params.hpp b/src/core/params.hpp index ece833e..a76d7c0 100644 --- a/src/core/params.hpp +++ b/src/core/params.hpp @@ -27,6 +27,9 @@ struct ParamSpec { // Whether a named style carries this param rather than the stage it sits // on: see core/style.hpp. bool inStyle = false; + // Whether the interface shows this number as a slider marked with its + // range rather than as a spin button: see slider(). + bool onSlider = false; std::string flag() const; // "strip_height" -> "--strip-height" @@ -35,6 +38,11 @@ struct ParamSpec { // signatures they have. ParamSpec styled() const; + // A copy of this Int or Double spec shown as a slider in the interface, for + // a number whose place between its minimum and maximum says more than its + // digits do. The command line and the project file are unchanged. + ParamSpec slider() const; + static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum, long maximum, std::string help = {}, long step = 1); static ParamSpec makeDouble(std::string key, std::string label, double defaultValue, diff --git a/src/effects/tileswap.cpp b/src/effects/tileswap.cpp index b391406..10c320b 100644 --- a/src/effects/tileswap.cpp +++ b/src/effects/tileswap.cpp @@ -25,10 +25,11 @@ const std::vector& TileSwapEffect::params() const { "How many tiles are dropped on the canvas; scattered layout only."), ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false, "Take tiles from the images in order instead of picking randomly."), - ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 30.0, + ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 5.0, "Swaps per second of each tile, rounded so that the clip loops " "without a jump; 0 for none.", - 0.1, 1), + 0.01, 2) + .slider(), }; return specs; } diff --git a/src/gui/window.cpp b/src/gui/window.cpp index d63c4e5..bd53925 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -74,14 +75,14 @@ Gtk::Label* sectionLabel(const char* text) { return label; } -// A GtkSpinButton takes every wheel turn over it: it grabs the focus and -// spins its value, so scrolling down a column of params stops dead at the -// first spin under the pointer and quietly changes it. This hands the wheel to +// A GtkSpinButton (or a GtkScale) takes every wheel turn over it: it grabs the +// focus and spins its value, so scrolling down a column of params stops dead at +// the first spin under the pointer and quietly changes it. This hands the wheel to // the view instead unless the spin already has the focus -- the user clicked // into it and means to adjust it. It runs in the capture phase, ahead of the // spin's own controller, and so has to scroll the enclosing ScrolledWindow // itself: stopping the event there stops it for the window as well. -void scrollPastUnlessFocused(Gtk::SpinButton& spin) { +void scrollPastUnlessFocused(Gtk::Widget& spin) { auto scroll = Gtk::EventControllerScroll::create(); scroll->set_propagation_phase(Gtk::PropagationPhase::CAPTURE); scroll->set_flags(Gtk::EventControllerScroll::Flags::VERTICAL); @@ -950,14 +951,39 @@ void EffectorWindow::buildParamWidgets() { } case ParamSpec::Type::Int: case ParamSpec::Type::Double: { + const double value = + spec.type == ParamSpec::Type::Int + ? static_cast( + paramInt(values, spec.key, static_cast(spec.defaultValue))) + : paramDouble(values, spec.key, spec.defaultValue); + if (spec.onSlider) { + // The ends are marked with their values, so where the + // number sits in its range can be read at a glance. + const int digits = spec.type == ParamSpec::Type::Int ? 0 : spec.digits; + auto scale = std::make_unique(Gtk::Orientation::HORIZONTAL); + scale->set_range(spec.minimum, spec.maximum); + scale->set_increments(spec.step, spec.step * 10); + scale->set_round_digits(digits); + scale->set_digits(digits); + scale->set_draw_value(true); + scale->set_value_pos(Gtk::PositionType::RIGHT); + const auto mark = [digits](double at) { + return Glib::ustring::format(std::fixed, std::setprecision(digits), at); + }; + scale->add_mark(spec.minimum, Gtk::PositionType::BOTTOM, mark(spec.minimum)); + scale->add_mark(spec.maximum, Gtk::PositionType::BOTTOM, mark(spec.maximum)); + scale->set_value(value); + scale->signal_value_changed().connect( + sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); + scrollPastUnlessFocused(*scale); + widget = std::move(scale); + break; + } auto spin = std::make_unique(); spin->set_range(spec.minimum, spec.maximum); spin->set_increments(spec.step, spec.step * 10); spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits); - spin->set_value(spec.type == ParamSpec::Type::Int - ? static_cast(paramInt(values, spec.key, - static_cast(spec.defaultValue))) - : paramDouble(values, spec.key, spec.defaultValue)); + spin->set_value(value); spin->signal_value_changed().connect( sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); scrollPastUnlessFocused(*spin); @@ -1065,12 +1091,17 @@ ParamValues EffectorWindow::paramValues() const { values[entry.spec.key] = static_cast(entry.widget)->get_active(); break; case ParamSpec::Type::Int: - values[entry.spec.key] = - static_cast(static_cast(entry.widget)->get_value()); - break; - case ParamSpec::Type::Double: - values[entry.spec.key] = static_cast(entry.widget)->get_value(); + case ParamSpec::Type::Double: { + const double value = + entry.spec.onSlider ? static_cast(entry.widget)->get_value() + : static_cast(entry.widget)->get_value(); + if (entry.spec.type == ParamSpec::Type::Int) { + values[entry.spec.key] = std::lround(value); + } else { + values[entry.spec.key] = value; + } break; + } case ParamSpec::Type::Path: case ParamSpec::Type::Text: values[entry.spec.key] = diff --git a/tests/gui_tests.cpp b/tests/gui_tests.cpp index 2ce9c48..adff4fc 100644 --- a/tests/gui_tests.cpp +++ b/tests/gui_tests.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -633,6 +634,50 @@ void testFont(Gtk::Window& window, const std::filesystem::path& dir) { "a family chosen there is saved with the project"); } +void testSlider(Gtk::Window& window, const std::filesystem::path& dir) { + std::cout << "slider\n"; + std::filesystem::create_directories(dir); + const auto path = dir / "swapping.json"; + effector::Project project; + project.video.enabled = true; + effector::ChainStage stage{effector::effectByName("tileswap"), {}}; + stage.values["tileswap_rate"] = 0.5; + project.stages.push_back(stage); + effector::saveProject(path.string(), project); + window.activate_action("win.open-recent", + Glib::Variant::create(Glib::filename_to_uri(path.string()))); + pump(300); + + auto* chainList = named(window, "chain-list"); + if (chainList != nullptr && chainList->get_row_at_index(0) != nullptr) { + chainList->select_row(*chainList->get_row_at_index(0)); + } + pump(100); + std::vector scales; + collect(window, [](Gtk::Scale& s) { return s.is_drawable(); }, scales); + check(scales.size() == 1, "a param marked as a slider is shown as one"); + if (scales.size() != 1) return; + auto* scale = scales.front(); + auto adjustment = scale->get_adjustment(); + // Written the way the locale writes numbers, as the interface does. + const auto mark = [](double at) { + return std::string(Glib::ustring::format(std::fixed, std::setprecision(2), at)); + }; + check(adjustment->get_lower() == 0.0 && adjustment->get_upper() == 5.0 && + showsText(*scale, mark(0.0)) && showsText(*scale, mark(5.0)), + "with its range marked at both ends"); + check(scale->get_value() == 0.5, "and shows the value saved"); + + scale->set_value(0.05); + pump(100); + window.activate_action("win.save-project"); + pump(100); + const auto saved = effector::loadProject(path.string()); + check(saved.stages.size() == 1 && + std::abs(effector::paramDouble(saved.stages[0].values, "tileswap_rate", 0.0) - 0.05) < 1e-9, + "a value slid to is saved with the project"); +} + bool actionEnabled(Gtk::Window& window, const std::string& name) { auto action = dynamic_cast(window).lookup_action(name); return action && action->get_enabled(); @@ -822,6 +867,7 @@ int main(int argc, char** argv) { testRecentProjects(*window, dataDir / "projects"); testVideo(*window, dataDir / "video"); testFont(*window, dataDir / "font"); + testSlider(*window, dataDir / "slider"); testHistory(*window, dataDir / "history"); testMemoryGuard(*window, dataDir / "memory"); app->remove_window(*window);