4 Commits
Author SHA1 Message Date
simonandClaude Opus 5.5 3c8c5bed8c Make Tile swap slow at a low Swap rate, on a curved slider
A tile has to come back to the cut it started with for the clip to
loop, so every tile swapped at least twice a clip, and below that
rounding left every tile still: the bottom of the slider was either
frozen or busy. Swap rate is now the average per tile: below two swaps
a clip only a share of the tiles swap (twice each), fewer the lower it
goes, and above it each tile rounds up or down by its own draw.

ParamSpec::slider() takes an optional curve: the scale runs over the
slider's travel and sliderValue/sliderPosition map it onto the number
as position^curve. Swap rate uses a curve of 3, so the low end gets
most of the slider; the command line and project file are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:11:28 +02:00
simonandClaude Opus 5.5 ef8f728126 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 <noreply@anthropic.com>
2026-09-30 23:44:27 +02:00
SiandClaude Opus 5.5 5402a50742 Add undo and redo to the interface
Ctrl+Z undoes, Ctrl+Y and Ctrl+Shift+Z redo, caught in the capture
phase so a focused entry or spin never undoes only its own text; both
are in the header-bar menu too. The history keeps whole snapshots of
the project (currentProject() plus the immutable source list itself,
so stepping back never decodes again), compared by their JSON.
requestRender() notes every edit, and a step is committed once the
controls have been still for half a second. New and Open start the
history over once the project's images are in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:15:03 +02:00
SiandClaude Opus 5.5 f9fe6e0fa5 Pick the text stage's font with a font chooser
A new Font param type (ParamSpec::makeFont) is a font family as the
machine names it. The interface shows it as a family-only
FontDialogButton; the command line and the project file treat it as a
plain string, as they did the Text param it replaces, so existing
projects and --font-family keep working. A family the machine lacks is
kept as written. Bumps the version to 0.8.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:58:44 +02:00
14 changed files with 651 additions and 38 deletions
+24
View File
@@ -4,6 +4,30 @@ 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).
- At a low Swap rate, Tile swap now moves only some of its tiles, fewer the
lower the rate, instead of swapping every tile at least twice a clip (or,
below that, none at all). Each step down the bottom of the slider now
looks slower than the one above it.
- The Swap rate slider is curved: its low end takes up most of its length
(1 swap a second sits just past the middle), so slow rates are easy to set.
## [0.8.1] - 2026-09-26
### Added
- The Font of a text stage (and of a text style) is picked from the fonts
installed on this machine with a font chooser, rather than typed by name.
The command line still takes any family, as `--font-family "DejaVu Serif"`.
- Undo and redo: Ctrl+Z steps back through the edits to the project (the
chain, params, styles, canvas, video settings, seed and images), and Ctrl+Y
or Ctrl+Shift+Z steps forward again; both are in the menu as well. A burst
of edits, like a spin held down or a word typed, is one step.
## [0.8.0] - 2026-09-26
### Added
+19 -1
View File
@@ -73,11 +73,19 @@ GUI or the parser — add a `ParamSpec` instead. A `Path` param names a file on
disk; like a source path it is written relative to the project folder. A `Text`
param is free-form text the user types, and a `Color` param is a colour written
`#rrggbb` (`parseColor`/`formatColor`/`paramColor` in `src/core/params.hpp`),
shown as a colour button in the interface. Values
shown as a colour button in the interface. A `Font` param is a font family
named as the machine names it ("DejaVu Serif"), a family-only font chooser in
the interface and a plain string everywhere else. Values
travel as `ParamValues` (a
`map<string, variant<long,double,bool,string>>`), 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. `.slider(curve)` bends it: the scale runs over the
slider's travel and `sliderValue`/`sliderPosition` map that onto the number as
`position^curve`, so a curve above 1 gives the low end more room.
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
@@ -173,6 +181,16 @@ the `effector` application name on each entry; the GUI tests point
`XDG_DATA_HOME` at a temporary folder so they never touch the user's list. Opening one replaces the sources, so a decode still in flight
is discarded rather than mixed into the new project (`dropLoadedImages_`).
Undo and redo (Ctrl+Z, Ctrl+Y / Ctrl+Shift+Z, caught in the capture phase so
an entry never undoes its own text instead) keep whole snapshots of the
project: `captureState` is `currentProject()` plus the `sources_` list itself,
so stepping back never decodes again, compared by `projectToJson`. There is
nothing to call per edit: `requestRender()` calls `noteChange()`, which commits
a step once the controls have been still for `kHistoryPause`; an edit that
renders nothing (renaming a style) calls `noteChange()` itself. New and Open
start the history over, after the project's images are in
(`historyAfterLoad_`).
### Video projects
A project is a still or a clip: `VideoSettings` (`src/core/video.hpp`: enabled,
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20)
project(effector VERSION 0.8.0 LANGUAGES CXX)
project(effector VERSION 0.8.2 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
+1 -1
View File
@@ -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.0
pkgver=0.8.2
pkgrel=1
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
arch=('x86_64' 'aarch64')
+1
View File
@@ -182,6 +182,7 @@ Options parseOptions(int argc, char** argv) {
}
case ParamSpec::Type::Path:
case ParamSpec::Type::Text:
case ParamSpec::Type::Font:
options.params[spec->key] = requireValue(argc, argv, i, arg);
break;
case ParamSpec::Type::Color: {
+34
View File
@@ -1,6 +1,7 @@
#include "core/params.hpp"
#include <algorithm>
#include <cmath>
namespace effector {
@@ -16,6 +17,27 @@ ParamSpec ParamSpec::styled() const {
return copy;
}
ParamSpec ParamSpec::slider(double curve) const {
ParamSpec copy = *this;
copy.onSlider = true;
copy.curve = std::max(1e-3, curve);
return copy;
}
double ParamSpec::sliderValue(double position) const {
const double span = maximum - minimum;
const double raw = minimum + span * std::pow(std::clamp(position, 0.0, 1.0), curve);
const double unit = type == Type::Int ? std::max(1.0, std::round(step)) : step;
const double stepped = unit > 0.0 ? minimum + std::round((raw - minimum) / unit) * unit : raw;
return std::clamp(stepped, minimum, maximum);
}
double ParamSpec::sliderPosition(double value) const {
const double span = maximum - minimum;
if (span <= 0.0) return 0.0;
return std::pow(std::clamp((value - minimum) / span, 0.0, 1.0), 1.0 / curve);
}
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help, long step) {
ParamSpec spec;
@@ -100,6 +122,17 @@ ParamSpec ParamSpec::makeColor(std::string key, std::string label, std::string d
return spec;
}
ParamSpec ParamSpec::makeFont(std::string key, std::string label, std::string defaultFamily,
std::string help) {
ParamSpec spec;
spec.type = Type::Font;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultChoice = std::move(defaultFamily);
return spec;
}
namespace {
const ParamValue* find(const ParamValues& values, const std::string& key) {
@@ -121,6 +154,7 @@ ParamValue paramDefault(const ParamSpec& spec) {
case ParamSpec::Type::Path:
case ParamSpec::Type::Text:
case ParamSpec::Type::Color:
case ParamSpec::Type::Font:
break;
}
return spec.defaultChoice;
+25 -1
View File
@@ -11,7 +11,7 @@ namespace effector {
// Declarative description of one effect knob. The GUI turns these into widgets
// and the CLI turns them into flags, so neither knows about any effect.
struct ParamSpec {
enum class Type { Int, Double, Bool, Choice, Path, Text, Color };
enum class Type { Int, Double, Bool, Choice, Path, Text, Color, Font };
Type type = Type::Int;
std::string key;
@@ -27,6 +27,13 @@ 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;
// How a slider's travel is spread over its range: the value is
// minimum + (maximum - minimum) * position^curve, so 1 is even and a
// larger curve gives the low end more of the slider. See slider().
double curve = 1.0;
std::string flag() const; // "strip_height" -> "--strip-height"
@@ -35,6 +42,18 @@ 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. A `curve` above 1 spreads the low end of the range over more
// of the slider, for a number whose small values differ the most. The
// command line and the project file are unchanged.
ParamSpec slider(double curve = 1.0) const;
// Where a slider at `position` (0 to 1) puts this number, rounded to its
// step and kept in range, and where on the slider `value` sits.
double sliderValue(double position) const;
double sliderPosition(double value) 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,
@@ -56,6 +75,11 @@ struct ParamSpec {
// spelling as a flag value.
static ParamSpec makeColor(std::string key, std::string label, std::string defaultColor,
std::string help = {});
// A font family as it is named on this machine ("Sans", "DejaVu Serif"):
// a font chooser in the interface, a flag taking any string on the
// command line. A family the machine lacks is kept as written.
static ParamSpec makeFont(std::string key, std::string label, std::string defaultFamily,
std::string help = {});
};
using ParamValue = std::variant<long, double, bool, std::string>;
+2
View File
@@ -366,6 +366,7 @@ void writeParams(std::ostream& out, const std::vector<ParamSpec>& specs, const P
break;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Text:
case ParamSpec::Type::Font:
out << quote(paramText(values, spec.key, spec.defaultChoice));
break;
case ParamSpec::Type::Color: {
@@ -412,6 +413,7 @@ void readParams(const std::vector<ParamSpec>& specs, const Json& params,
}
break;
case ParamSpec::Type::Text:
case ParamSpec::Type::Font:
if (value->kind == Json::Kind::String) values[spec.key] = value->text;
break;
case ParamSpec::Type::Color: {
+1 -1
View File
@@ -16,7 +16,7 @@ const std::vector<ParamSpec>& TextEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeText("text", "Text", "Effector",
"What to write; a line break starts a new line."),
ParamSpec::makeText("font_family", "Font", "Sans",
ParamSpec::makeFont("font_family", "Font", "Sans",
"Font family, as it is named on this machine: Sans, Serif, Monospace, "
"\"DejaVu Serif\", ...")
.styled(),
+32 -19
View File
@@ -25,10 +25,12 @@ const std::vector<ParamSpec>& 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,
"Swaps per second of each tile, rounded so that the clip loops "
"without a jump; 0 for none.",
0.1, 1),
ParamSpec::makeDouble("tileswap_rate", "Swap rate", 1.0, 0.0, 5.0,
"Swaps per second of each tile, on average; 0 for none. Below two "
"swaps in the clip only some of the tiles swap, fewer the lower it "
"goes, so that the clip still loops without a jump.",
0.01, 2)
.slider(3.0),
};
return specs;
}
@@ -56,8 +58,8 @@ struct Settings {
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
// Where a tile sits for the whole clip, when in its swaps it turns over, and
// the seed its cuts come from.
// Where a tile sits for the whole clip, when in its swaps it turns over, the
// seed its cuts come from, and the draw that settles how often it swaps.
struct Cell {
int x;
int y;
@@ -65,6 +67,7 @@ struct Cell {
int h;
double phase;
std::uint64_t seed;
double share;
};
} // namespace
@@ -80,7 +83,7 @@ Image TileSwapEffect::compose(const std::vector<Image>& sources, int width, int
Rng steady = at.steady();
std::vector<Cell> cells;
auto addCell = [&](int x, int y, int w, int h) {
cells.push_back(Cell{x, y, w, h, 0.0, 0});
cells.push_back(Cell{x, y, w, h, 0.0, 0, 0.0});
};
if (scatter) {
const Image& src = sources[steady.below(sources.size() - 1)];
@@ -119,27 +122,37 @@ Image TileSwapEffect::compose(const std::vector<Image>& sources, int width, int
for (Cell& cell : cells) {
cell.phase = unit(steady);
cell.seed = steady.next();
cell.share = unit(steady);
}
// Where this frame is in time: each tile has swapped a whole number of
// times, counted round the clip so that the frame after the last shows
// what the first does.
long swaps = 0;
double perSecond = 0.0;
if (settings.rate > 0.0) {
perSecond = at.loopRate(settings.rate);
swaps = std::max(1L, std::lround(perSecond * at.length()));
}
// How often each tile swaps in one pass of the clip, `each` on average. A
// tile has to come back to the cut it started with, so one that swaps at
// all swaps at least twice; below that a share of the tiles swap twice and
// the rest hold still, so a low rate thins out the tiles that move instead
// of moving every one of them. Above it, each tile rounds `each` up or
// down by its own draw.
const double length = at.length();
const double each = settings.rate * length;
auto swapsOf = [&](const Cell& cell) -> long {
if (each <= 0.0) return 0;
if (each < 2.0) return cell.share < each / 2.0 ? 2 : 0;
const double whole = std::floor(each);
return static_cast<long>(whole) + (cell.share < each - whole ? 1 : 0);
};
// What a tile shows depends only on the tile and its swap, so it holds
// still between swaps and any frame can be drawn on its own.
// What a tile shows depends only on the tile and how many times it has
// swapped, counted round the clip so that the frame after the last shows
// what the first does; so it holds still between swaps and any frame can
// be drawn on its own.
std::vector<Tile> plan;
plan.reserve(cells.size());
for (std::size_t i = 0; i < cells.size(); ++i) {
const Cell& cell = cells[i];
const long swaps = swapsOf(cell);
std::uint64_t turn = 0;
if (swaps > 0) {
const auto passed = static_cast<long>(std::floor(at.seconds() * perSecond + cell.phase));
const auto passed =
static_cast<long>(std::floor(at.seconds() * swaps / length + cell.phase));
turn = static_cast<std::uint64_t>(passed % swaps);
}
Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL);
+235 -14
View File
@@ -5,6 +5,7 @@
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <iomanip>
#include <sstream>
#include <utility>
@@ -29,6 +30,11 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*
const char* const kRecentApp = "effector";
const std::size_t kRecentCount = 10;
// Undo keeps this many steps, and an edit becomes one once the controls have
// been still this long (ms): a spin held down or a word typed is one step.
const std::size_t kHistoryDepth = 100;
constexpr unsigned kHistoryPause = 500;
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
auto filter = Gtk::FileFilter::create();
filter->set_name("Effector projects");
@@ -69,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);
@@ -116,13 +122,21 @@ EffectorWindow::EffectorWindow(const Options& initial) {
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress));
loadFinished_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadFinished));
loadFinished_.connect([this] {
onLoadFinished();
if (historyAfterLoad_ && !loading_) {
historyAfterLoad_ = false;
resetHistory();
}
});
clipProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onClipProgress));
exportProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportProgress));
exportDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportFinished));
buildUi();
applyInitial(initial);
historyAfterLoad_ = loading_;
resetHistory();
suspendRender_ = false;
if (loading_) {
@@ -136,6 +150,7 @@ EffectorWindow::EffectorWindow(const Options& initial) {
EffectorWindow::~EffectorWindow() {
debounce_.disconnect();
historyCommit_.disconnect();
chainSplitFit_.disconnect();
recentChanged_.disconnect();
loadWindowDelay_.disconnect();
@@ -594,6 +609,11 @@ Gtk::Widget& EffectorWindow::buildVideoSection() {
videoQualityDrop_.set_selected(standard);
videoQualityDrop_.set_hexpand(true);
videoQualityDrop_.set_tooltip_text(qualityTooltip);
// Only the export hears of the quality, so it asks for no render; it is
// still part of the project, and of its history.
videoQualityDrop_.property_selected().signal_changed().connect([this] {
if (!suspendVideoSync_) noteChange();
});
auto* qualityLabel = Gtk::make_managed<Gtk::Label>("Quality");
qualityLabel->set_xalign(0.0F);
grid->attach(*qualityLabel, 0, 2);
@@ -931,14 +951,42 @@ void EffectorWindow::buildParamWidgets() {
}
case ParamSpec::Type::Int:
case ParamSpec::Type::Double: {
const double value =
spec.type == ParamSpec::Type::Int
? static_cast<double>(
paramInt(values, spec.key, static_cast<long>(spec.defaultValue)))
: paramDouble(values, spec.key, spec.defaultValue);
if (spec.onSlider) {
// The scale runs over the slider's travel, 0 to 1, and
// the spec maps that onto the number, so a curved slider
// gives its low end more room. 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::Scale>(Gtk::Orientation::HORIZONTAL);
scale->set_range(0.0, 1.0);
scale->set_increments(0.01, 0.1);
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->set_format_value_func(
[spec, mark](double position) { return mark(spec.sliderValue(position)); });
scale->add_mark(0.0, Gtk::PositionType::BOTTOM, mark(spec.minimum));
scale->add_mark(1.0, Gtk::PositionType::BOTTOM, mark(spec.maximum));
scale->set_value(spec.sliderPosition(value));
scale->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
scrollPastUnlessFocused(*scale);
widget = std::move(scale);
break;
}
auto spin = std::make_unique<Gtk::SpinButton>();
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<double>(paramInt(values, spec.key,
static_cast<long>(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);
@@ -995,6 +1043,21 @@ void EffectorWindow::buildParamWidgets() {
widget = std::move(button);
break;
}
case ParamSpec::Type::Font: {
// Only the family is chosen here: weight, slant and size are
// params of their own.
auto button = std::make_unique<Gtk::FontDialogButton>(Gtk::FontDialog::create());
button->set_halign(Gtk::Align::START);
button->set_level(Gtk::FontLevel::FAMILY);
button->set_use_font(true);
Pango::FontDescription font;
font.set_family(paramText(values, spec.key, spec.defaultChoice));
button->set_font_desc(font);
button->property_font_desc().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(button);
break;
}
case ParamSpec::Type::Choice: {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>();
@@ -1031,12 +1094,18 @@ ParamValues EffectorWindow::paramValues() const {
values[entry.spec.key] = static_cast<Gtk::Switch*>(entry.widget)->get_active();
break;
case ParamSpec::Type::Int:
values[entry.spec.key] =
static_cast<long>(static_cast<Gtk::SpinButton*>(entry.widget)->get_value());
break;
case ParamSpec::Type::Double:
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
case ParamSpec::Type::Double: {
const double value =
entry.spec.onSlider
? entry.spec.sliderValue(static_cast<Gtk::Scale*>(entry.widget)->get_value())
: static_cast<Gtk::SpinButton*>(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] =
@@ -1051,6 +1120,14 @@ ParamValues EffectorWindow::paramValues() const {
values[entry.spec.key] = formatColor(rgb);
break;
}
case ParamSpec::Type::Font: {
// A family the machine lacks keeps its name, so a project
// from elsewhere still says which font it wanted.
const auto family =
static_cast<Gtk::FontDialogButton*>(entry.widget)->get_font_desc().get_family();
if (!family.empty()) values[entry.spec.key] = std::string(family);
break;
}
case ParamSpec::Type::Choice: {
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
@@ -1222,6 +1299,7 @@ void EffectorWindow::onRenameStyle() {
if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name;
}
buildParamWidgets();
noteChange(); // the picture is the same, so no render asks for it
});
}
@@ -2191,6 +2269,7 @@ void EffectorWindow::onRecreate() {
seed_ = Rng::randomSeed();
seedEntry_.set_text(std::to_string(seed_));
if (!canRender()) {
noteChange();
showError(missingText());
return;
}
@@ -2227,6 +2306,9 @@ void EffectorWindow::showPlaceholder() {
}
void EffectorWindow::requestRender() {
// Every edit of the project asks for a render, so this is where the
// history hears of them.
noteChange();
if (suspendRender_) return;
if (!canRender()) {
showPlaceholder();
@@ -2414,10 +2496,16 @@ Gtk::Widget& EffectorWindow::buildMainMenu() {
});
// Never enabled: it only stands in for an empty list.
add_action("no-recent")->set_enabled(false);
undoAction_ = add_action("undo", sigc::mem_fun(*this, &EffectorWindow::undo));
redoAction_ = add_action("redo", sigc::mem_fun(*this, &EffectorWindow::redo));
add_action("show-changelog", sigc::mem_fun(*this, &EffectorWindow::showChangelog));
add_action("about", sigc::mem_fun(*this, &EffectorWindow::showAbout));
auto menu = Gio::Menu::create();
auto edit = Gio::Menu::create();
edit->append("Undo", "win.undo");
edit->append("Redo", "win.redo");
menu->append_section(edit);
auto open = Gio::Menu::create();
open->append("New Project", "win.new-project");
open->append("Open Project…", "win.open-project");
@@ -2453,6 +2541,20 @@ Gtk::Widget& EffectorWindow::buildMainMenu() {
}
add_controller(shortcuts);
// Undo and redo are caught before the focused widget sees them: an entry
// would otherwise undo its own text, and a spin button only the digits
// typed into it, rather than the step the project took.
auto history = Gtk::ShortcutController::create();
history->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
const Accel historyAccels[] = {{"<Control>z", "win.undo"},
{"<Control>y", "win.redo"},
{"<Control><Shift>z", "win.redo"}};
for (const Accel& accel : historyAccels) {
history->add_shortcut(Gtk::Shortcut::create(
Gtk::ShortcutTrigger::parse_string(accel.keys), Gtk::NamedAction::create(accel.action)));
}
add_controller(history);
return *Gtk::make_managed<Gtk::PopoverMenu>(menu);
}
@@ -2502,6 +2604,8 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat
} else {
addPaths(project.sources); // renders once the images are decoded
}
historyAfterLoad_ = loading_;
resetHistory();
}
void EffectorWindow::clearSources() {
@@ -2810,6 +2914,123 @@ void EffectorWindow::updateTitle() {
set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector");
}
// -------------------------------------------------------------- history ---
EffectorWindow::HistoryState EffectorWindow::captureState() const {
HistoryState state;
state.project = currentProject();
state.json = projectToJson(state.project);
state.sources = sources_;
state.refusedSources = refusedSources_;
state.selectedStage = selectedStage_;
return state;
}
// Puts a step back the way a project is opened, but with the images already
// in hand: the source list is the very one the step held.
void EffectorWindow::restoreState(const HistoryState& state) {
suspendHistory_ = true;
suspendRender_ = true;
closeStyleWindow(); // it may name a style the step does not hold
const bool wasVideo = isVideo();
const Project& project = state.project;
stages_ = project.stages;
styles_ = project.styles;
selectedStage_ =
stages_.empty() ? 0 : std::clamp(state.selectedStage, 0, static_cast<int>(stages_.size()) - 1);
widthSpin_.set_value(project.width);
heightSpin_.set_value(project.height);
setCanvasFormat(project.format, project.dpi);
setVideoSettings(project.video); // rebuilds the chain rows and the params
seed_ = project.seed.value_or(seed_);
seedEntry_.set_text(std::to_string(seed_));
if (state.sources != sources_) {
while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row);
sources_ = state.sources;
for (const auto& image : *sources_) appendSourceRow(image.path());
updateSourcesLabel();
}
refusedSources_ = state.refusedSources;
if (isVideo() != wasVideo) {
// What is on screen is the other kind of result, as in onVideoToggled.
result_ = Image();
saveButton_.set_sensitive(false);
}
clearError();
suspendRender_ = false;
requestRender();
suspendHistory_ = false;
}
void EffectorWindow::noteChange() {
if (suspendHistory_) return;
// Something is waiting to become a step, so there is something to undo
// even before the timer runs.
if (undoAction_) undoAction_->set_enabled(true);
historyCommit_.disconnect();
historyCommit_ = Glib::signal_timeout().connect(
[this] {
commitHistory();
return false;
},
kHistoryPause);
}
void EffectorWindow::commitHistory() {
historyCommit_.disconnect();
HistoryState now = captureState();
if (now.json != committed_.json) {
// The step remembers the stage being worked on as it was left, so
// undoing it lands on that stage again.
committed_.selectedStage = selectedStage_;
undo_.push_back(std::move(committed_));
if (undo_.size() > kHistoryDepth) undo_.erase(undo_.begin());
redo_.clear();
}
committed_ = std::move(now);
updateHistoryActions();
}
void EffectorWindow::resetHistory() {
historyCommit_.disconnect();
undo_.clear();
redo_.clear();
committed_ = captureState();
updateHistoryActions();
}
void EffectorWindow::undo() {
commitHistory(); // an edit still waiting on the timer is the step undone
if (undo_.empty()) return;
committed_.selectedStage = selectedStage_;
redo_.push_back(std::move(committed_));
const HistoryState previous = std::move(undo_.back());
undo_.pop_back();
restoreState(previous);
committed_ = captureState();
updateHistoryActions();
}
void EffectorWindow::redo() {
commitHistory(); // a fresh edit clears the redo list, as it should
if (redo_.empty()) return;
committed_.selectedStage = selectedStage_;
undo_.push_back(std::move(committed_));
const HistoryState next = std::move(redo_.back());
redo_.pop_back();
restoreState(next);
committed_ = captureState();
updateHistoryActions();
}
void EffectorWindow::updateHistoryActions() {
if (undoAction_) undoAction_->set_enabled(!undo_.empty());
if (redoAction_) redoAction_->set_enabled(!redo_.empty());
}
// --------------------------------------------------------------- saving ---
void EffectorWindow::onSave() {
+31
View File
@@ -161,6 +161,26 @@ private:
void writeProject(const std::string& path);
void updateTitle();
// Undo and redo. The history is a list of whole snapshots of the project,
// taken a moment after the edits stop, so a spin held down or a word typed
// is one step rather than one per tick. The sources ride along as the
// immutable list they already are, so stepping back never decodes again.
struct HistoryState {
std::string json; // what the step is compared by
Project project;
std::shared_ptr<const std::vector<Image>> sources;
std::vector<std::string> refusedSources;
int selectedStage = 0;
};
HistoryState captureState() const;
void restoreState(const HistoryState& state);
void noteChange(); // coalesced: restarts the short history timer
void commitHistory(); // the current project becomes a step, if it changed
void resetHistory(); // a new or opened project starts with none
void undo();
void redo();
void updateHistoryActions();
void onParamChanged();
// The chooser behind a Path param. The chosen file goes straight into the
// stage's values rather than into the widget, which may well have been
@@ -213,6 +233,17 @@ private:
sigc::connection recentChanged_; // the recent list outlives the window
bool suspendRender_ = true;
std::vector<HistoryState> undo_;
std::vector<HistoryState> redo_;
HistoryState committed_; // the project as the last step left it
sigc::connection historyCommit_;
bool suspendHistory_ = false; // set while a step is being put back
// The sources of a project being opened are not a step of their own: the
// history starts over once they are in.
bool historyAfterLoad_ = false;
Glib::RefPtr<Gio::SimpleAction> undoAction_;
Glib::RefPtr<Gio::SimpleAction> redoAction_;
// Source loading. Decoding a batch of large photographs takes seconds, so
// it runs off the main thread and reports into a small progress window.
std::thread loader_;
+33
View File
@@ -4,8 +4,10 @@
#include <array>
#include <atomic>
#include <clocale>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
@@ -2079,6 +2081,37 @@ int main() {
frozenSwap.values["tileswap_rate"] = 0.0;
check(sameBytes(frameOf(frozenSwap, 0), frameOf(frozenSwap, 5)),
"at a rate of 0 it holds still");
// How many pixels ever show something other than frame 0.
auto moving = [&](const ChainStage& at) {
const auto first = frameOf(at, 0);
std::vector<bool> moved(static_cast<std::size_t>(80 * 48), false);
for (int index = 1; index < 8; ++index) {
const auto frame = frameOf(at, index);
for (int y = 0; y < 48; ++y) {
for (int x = 0; x < 80; ++x) {
if (std::memcmp(frame.row(y) + x * 3, first.row(y) + x * 3, 3) != 0) {
moved[static_cast<std::size_t>(y * 80 + x)] = true;
}
}
}
}
return std::count(moved.begin(), moved.end(), true);
};
ChainStage slowSwap = swapStage;
slowSwap.values["tileswap_rate"] = 0.3;
const auto slowMoving = moving(slowSwap);
check(slowMoving > 0 && slowMoving < moving(swapStage),
"a rate below two swaps a clip moves only some of the tiles");
const auto& rate = swap->params().back();
check(rate.onSlider && rate.curve > 1.0, "its swap rate is a curved slider");
check(rate.sliderValue(0.0) == 0.0 && rate.sliderValue(1.0) == 5.0 &&
rate.sliderValue(0.5) < 2.5,
"which spans the whole range and gives the low end more travel");
check(std::abs(rate.sliderValue(rate.sliderPosition(0.37)) - 0.37) < 1e-9 &&
rate.sliderValue(0.123456) * 100.0 ==
std::round(rate.sliderValue(0.123456) * 100.0),
"a value finds its place on the slider, and a place rounds to the step");
ChainStage scattered = swapStage;
scattered.values["tileswap_layout"] = std::string("scatter");
ChainStage bricks = swapStage;
+212
View File
@@ -7,6 +7,7 @@
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <iomanip>
#include <iostream>
#include <memory>
#include <string>
@@ -591,6 +592,214 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
stack->set_visible_child("chain");
}
// A font param is a font chooser showing the family, and the family picked
// there is what the stage renders with and what the project saves.
void testFont(Gtk::Window& window, const std::filesystem::path& dir) {
std::cout << "font\n";
std::filesystem::create_directories(dir);
const auto path = dir / "lettering.json";
effector::Project project;
effector::ChainStage stage{effector::effectByName("text"), {}};
stage.values["font_family"] = std::string("Serif");
project.stages.push_back(stage);
effector::saveProject(path.string(), project);
window.activate_action("win.open-recent",
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(300);
auto* chainList = named<Gtk::ListBox>(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<Gtk::FontDialogButton*> buttons;
collect<Gtk::FontDialogButton>(window, [](Gtk::FontDialogButton& b) { return b.is_drawable(); },
buttons);
check(buttons.size() == 1, "the text stage picks its font with a font chooser");
if (buttons.size() != 1) return;
auto* button = buttons.front();
check(button->get_level() == Gtk::FontLevel::FAMILY,
"which chooses only the family, the weight and size being params of their own");
check(button->get_font_desc().get_family() == "Serif", "and shows the family saved");
Pango::FontDescription mono;
mono.set_family("Monospace");
button->set_font_desc(mono);
pump(100);
window.activate_action("win.save-project");
pump(100);
const auto saved = effector::loadProject(path.string());
check(saved.stages.size() == 1 &&
effector::paramText(saved.stages[0].values, "font_family", "") == "Monospace",
"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<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(300);
auto* chainList = named<Gtk::ListBox>(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<Gtk::Scale*> scales;
collect<Gtk::Scale>(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();
const auto& spec = effector::effectByName("tileswap")->params().back();
// 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(showsText(*scale, mark(0.0)) && showsText(*scale, mark(5.0)),
"with its range marked at both ends");
check(spec.sliderValue(scale->get_value()) == 0.5, "and shows the value saved");
check(scale->get_value() > 0.5 / 5.0 + 0.3,
"a curved slider gives the low end of its range more of its travel");
scale->set_value(spec.sliderPosition(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<Gtk::ApplicationWindow&>(window).lookup_action(name);
return action && action->get_enabled();
}
// Whether the window catches `keys` for `action` before the focused widget.
bool capturesShortcut(Gtk::Window& window, const std::string& keys, const std::string& action) {
auto controllers = window.observe_controllers();
for (guint i = 0; i < controllers->get_n_items(); ++i) {
auto* controller = GTK_EVENT_CONTROLLER(g_list_model_get_item(controllers->gobj(), i));
const bool capture = gtk_event_controller_get_propagation_phase(controller) == GTK_PHASE_CAPTURE;
if (GTK_IS_SHORTCUT_CONTROLLER(controller) && capture) {
auto* list = G_LIST_MODEL(controller);
for (guint j = 0; j < g_list_model_get_n_items(list); ++j) {
auto* shortcut = GTK_SHORTCUT(g_list_model_get_item(list, j));
char* trigger = gtk_shortcut_trigger_to_string(gtk_shortcut_get_trigger(shortcut));
auto* named = gtk_shortcut_get_action(shortcut);
const bool match = trigger == keys && GTK_IS_NAMED_ACTION(named) &&
action == gtk_named_action_get_action_name(GTK_NAMED_ACTION(named));
g_free(trigger);
g_object_unref(shortcut);
if (match) {
g_object_unref(controller);
return true;
}
}
}
g_object_unref(controller);
}
return false;
}
void testHistory(Gtk::Window& window, const std::filesystem::path& dir) {
std::cout << "undo and redo\n";
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
auto* add = named<Gtk::MenuButton>(window, "add-effect");
if (chainList == nullptr || add == nullptr) return;
check(capturesShortcut(window, "<Control>z", "win.undo") &&
capturesShortcut(window, "<Control>y", "win.redo") &&
capturesShortcut(window, "<Shift><Control>z", "win.redo"),
"Ctrl+Z undoes, Ctrl+Y and Ctrl+Shift+Z redo, ahead of the focused widget");
window.activate_action("win.new-project");
pump(700);
check(!actionEnabled(window, "undo") && !actionEnabled(window, "redo"),
"a new project has nothing to undo or redo");
addFromMenu(*add, "Blur");
pump(700);
addFromMenu(*add, "Scanlines");
pump(700);
check(rowCount(*chainList) == 2 && actionEnabled(window, "undo"), "two stages added");
window.activate_action("win.undo");
pump();
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
"undo takes the last stage off");
check(actionEnabled(window, "redo"), "and it can be redone");
window.activate_action("win.undo");
pump();
check(rowCount(*chainList) == 0 && !actionEnabled(window, "undo"), "undo again empties the chain");
window.activate_action("win.redo");
pump();
window.activate_action("win.redo");
pump();
check(rowCount(*chainList) == 2 && rowTitle(*chainList, 1) == "2. Scanlines" &&
!actionEnabled(window, "redo"),
"redo puts both back");
// Undone straight away, before the edit has settled into a step.
addFromMenu(*add, "Blur");
check(actionEnabled(window, "undo"), "an edit can be undone at once");
window.activate_action("win.undo");
pump();
check(rowCount(*chainList) == 2, "and undoing it takes that edit back");
window.activate_action("win.undo");
pump();
addFromMenu(*add, "Blur");
pump(700);
check(!actionEnabled(window, "redo"), "a new edit after an undo drops what could be redone");
// The images of an opened project arrive after it, from the decoder, and
// are part of it rather than a step to undo.
std::filesystem::create_directories(dir);
const auto source = dir / "source.png";
effector::Image(64, 64).save(source.string());
effector::Project project;
project.sources.push_back(source.string());
project.stages.push_back({effector::effectByName("scanlines"), {}});
const auto path = dir / "history.json";
effector::saveProject(path.string(), project);
window.activate_action("win.open-recent",
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(1000);
auto* sources = named<Gtk::Box>(window, "sources");
auto lists = sources != nullptr ? all<Gtk::ListBox>(*sources) : std::vector<Gtk::ListBox*>{};
auto* sourceList = lists.empty() ? nullptr : lists.front();
check(sourceList != nullptr && rowCount(*sourceList) == 1 && !actionEnabled(window, "undo"),
"opening a project with images leaves nothing to undo");
if (sourceList == nullptr || rowCount(*sourceList) != 1) return;
// Removing an image is undone with the image still in hand.
auto* stack = all<Gtk::Stack>(window).front();
stack->set_visible_child("setup");
pump();
const auto removes = all<Gtk::Button>(*sourceList->get_row_at_index(0));
if (!removes.empty()) press(*removes.front());
pump(700);
check(rowCount(*sourceList) == 0, "an image removed");
window.activate_action("win.undo");
pump();
check(rowCount(*sourceList) == 1, "comes back with undo");
stack->set_visible_child("chain");
window.activate_action("win.new-project");
pump(700);
}
// A project whose images will not fit is refused before any is decoded, and
// saving it then must not quietly drop them from the file.
void testMemoryGuard(Gtk::Window& window, const std::filesystem::path& dir) {
@@ -658,6 +867,9 @@ int main(int argc, char** argv) {
testHelpWindows(*window);
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);
});