Compare commits
4
Commits
v0.8.0
..
3c8c5bed8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8c5bed8c | ||
|
|
ef8f728126 | ||
|
|
5402a50742 | ||
|
|
f9fe6e0fa5 |
@@ -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
|
`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.
|
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
|
## [0.8.0] - 2026-09-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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`
|
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
|
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`),
|
`#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
|
travel as `ParamValues` (a
|
||||||
`map<string, variant<long,double,bool,string>>`), read back with
|
`map<string, variant<long,double,bool,string>>`), read back with
|
||||||
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
|
`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
|
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
|
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
|
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
|
`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_`).
|
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
|
### Video projects
|
||||||
|
|
||||||
A project is a still or a clip: `VideoSettings` (`src/core/video.hpp`: enabled,
|
A project is a still or a clip: `VideoSettings` (`src/core/video.hpp`: enabled,
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
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 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# so pkgver must follow the project() line of CMakeLists.txt (the version core
|
# so pkgver must follow the project() line of CMakeLists.txt (the version core
|
||||||
# test checks it). From this folder: makepkg -si
|
# test checks it). From this folder: makepkg -si
|
||||||
pkgname=effector
|
pkgname=effector
|
||||||
pkgver=0.8.0
|
pkgver=0.8.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
case ParamSpec::Type::Path:
|
case ParamSpec::Type::Path:
|
||||||
case ParamSpec::Type::Text:
|
case ParamSpec::Type::Text:
|
||||||
|
case ParamSpec::Type::Font:
|
||||||
options.params[spec->key] = requireValue(argc, argv, i, arg);
|
options.params[spec->key] = requireValue(argc, argv, i, arg);
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Color: {
|
case ParamSpec::Type::Color: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "core/params.hpp"
|
#include "core/params.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -16,6 +17,27 @@ ParamSpec ParamSpec::styled() const {
|
|||||||
return copy;
|
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,
|
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||||
long maximum, std::string help, long step) {
|
long maximum, std::string help, long step) {
|
||||||
ParamSpec spec;
|
ParamSpec spec;
|
||||||
@@ -100,6 +122,17 @@ ParamSpec ParamSpec::makeColor(std::string key, std::string label, std::string d
|
|||||||
return spec;
|
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 {
|
namespace {
|
||||||
|
|
||||||
const ParamValue* find(const ParamValues& values, const std::string& key) {
|
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::Path:
|
||||||
case ParamSpec::Type::Text:
|
case ParamSpec::Type::Text:
|
||||||
case ParamSpec::Type::Color:
|
case ParamSpec::Type::Color:
|
||||||
|
case ParamSpec::Type::Font:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return spec.defaultChoice;
|
return spec.defaultChoice;
|
||||||
|
|||||||
+25
-1
@@ -11,7 +11,7 @@ namespace effector {
|
|||||||
// Declarative description of one effect knob. The GUI turns these into widgets
|
// 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.
|
// and the CLI turns them into flags, so neither knows about any effect.
|
||||||
struct ParamSpec {
|
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;
|
Type type = Type::Int;
|
||||||
std::string key;
|
std::string key;
|
||||||
@@ -27,6 +27,13 @@ struct ParamSpec {
|
|||||||
// Whether a named style carries this param rather than the stage it sits
|
// Whether a named style carries this param rather than the stage it sits
|
||||||
// on: see core/style.hpp.
|
// on: see core/style.hpp.
|
||||||
bool inStyle = false;
|
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"
|
std::string flag() const; // "strip_height" -> "--strip-height"
|
||||||
|
|
||||||
@@ -35,6 +42,18 @@ struct ParamSpec {
|
|||||||
// signatures they have.
|
// signatures they have.
|
||||||
ParamSpec styled() const;
|
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,
|
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||||
long maximum, std::string help = {}, long step = 1);
|
long maximum, std::string help = {}, long step = 1);
|
||||||
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
|
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
|
||||||
@@ -56,6 +75,11 @@ struct ParamSpec {
|
|||||||
// spelling as a flag value.
|
// spelling as a flag value.
|
||||||
static ParamSpec makeColor(std::string key, std::string label, std::string defaultColor,
|
static ParamSpec makeColor(std::string key, std::string label, std::string defaultColor,
|
||||||
std::string help = {});
|
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>;
|
using ParamValue = std::variant<long, double, bool, std::string>;
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ void writeParams(std::ostream& out, const std::vector<ParamSpec>& specs, const P
|
|||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Choice:
|
case ParamSpec::Type::Choice:
|
||||||
case ParamSpec::Type::Text:
|
case ParamSpec::Type::Text:
|
||||||
|
case ParamSpec::Type::Font:
|
||||||
out << quote(paramText(values, spec.key, spec.defaultChoice));
|
out << quote(paramText(values, spec.key, spec.defaultChoice));
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Color: {
|
case ParamSpec::Type::Color: {
|
||||||
@@ -412,6 +413,7 @@ void readParams(const std::vector<ParamSpec>& specs, const Json& params,
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Text:
|
case ParamSpec::Type::Text:
|
||||||
|
case ParamSpec::Type::Font:
|
||||||
if (value->kind == Json::Kind::String) values[spec.key] = value->text;
|
if (value->kind == Json::Kind::String) values[spec.key] = value->text;
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Color: {
|
case ParamSpec::Type::Color: {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const std::vector<ParamSpec>& TextEffect::params() const {
|
|||||||
static const std::vector<ParamSpec> specs = {
|
static const std::vector<ParamSpec> specs = {
|
||||||
ParamSpec::makeText("text", "Text", "Effector",
|
ParamSpec::makeText("text", "Text", "Effector",
|
||||||
"What to write; a line break starts a new line."),
|
"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, "
|
"Font family, as it is named on this machine: Sans, Serif, Monospace, "
|
||||||
"\"DejaVu Serif\", ...")
|
"\"DejaVu Serif\", ...")
|
||||||
.styled(),
|
.styled(),
|
||||||
|
|||||||
+32
-19
@@ -25,10 +25,12 @@ const std::vector<ParamSpec>& TileSwapEffect::params() const {
|
|||||||
"How many tiles are dropped on the canvas; scattered layout only."),
|
"How many tiles are dropped on the canvas; scattered layout only."),
|
||||||
ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false,
|
ParamSpec::makeBool("tileswap_keep_order", "Cycle images in order", false,
|
||||||
"Take tiles from the images in order instead of picking randomly."),
|
"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 "
|
"Swaps per second of each tile, on average; 0 for none. Below two "
|
||||||
"without a jump; 0 for none.",
|
"swaps in the clip only some of the tiles swap, fewer the lower it "
|
||||||
0.1, 1),
|
"goes, so that the clip still loops without a jump.",
|
||||||
|
0.01, 2)
|
||||||
|
.slider(3.0),
|
||||||
};
|
};
|
||||||
return specs;
|
return specs;
|
||||||
}
|
}
|
||||||
@@ -56,8 +58,8 @@ struct Settings {
|
|||||||
|
|
||||||
double unit(Rng& rng) { return static_cast<double>(rng.next() >> 11) * 0x1.0p-53; }
|
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
|
// Where a tile sits for the whole clip, when in its swaps it turns over, the
|
||||||
// the seed its cuts come from.
|
// seed its cuts come from, and the draw that settles how often it swaps.
|
||||||
struct Cell {
|
struct Cell {
|
||||||
int x;
|
int x;
|
||||||
int y;
|
int y;
|
||||||
@@ -65,6 +67,7 @@ struct Cell {
|
|||||||
int h;
|
int h;
|
||||||
double phase;
|
double phase;
|
||||||
std::uint64_t seed;
|
std::uint64_t seed;
|
||||||
|
double share;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -80,7 +83,7 @@ Image TileSwapEffect::compose(const std::vector<Image>& sources, int width, int
|
|||||||
Rng steady = at.steady();
|
Rng steady = at.steady();
|
||||||
std::vector<Cell> cells;
|
std::vector<Cell> cells;
|
||||||
auto addCell = [&](int x, int y, int w, int h) {
|
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) {
|
if (scatter) {
|
||||||
const Image& src = sources[steady.below(sources.size() - 1)];
|
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) {
|
for (Cell& cell : cells) {
|
||||||
cell.phase = unit(steady);
|
cell.phase = unit(steady);
|
||||||
cell.seed = steady.next();
|
cell.seed = steady.next();
|
||||||
|
cell.share = unit(steady);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where this frame is in time: each tile has swapped a whole number of
|
// How often each tile swaps in one pass of the clip, `each` on average. A
|
||||||
// times, counted round the clip so that the frame after the last shows
|
// tile has to come back to the cut it started with, so one that swaps at
|
||||||
// what the first does.
|
// all swaps at least twice; below that a share of the tiles swap twice and
|
||||||
long swaps = 0;
|
// the rest hold still, so a low rate thins out the tiles that move instead
|
||||||
double perSecond = 0.0;
|
// of moving every one of them. Above it, each tile rounds `each` up or
|
||||||
if (settings.rate > 0.0) {
|
// down by its own draw.
|
||||||
perSecond = at.loopRate(settings.rate);
|
const double length = at.length();
|
||||||
swaps = std::max(1L, std::lround(perSecond * 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
|
// What a tile shows depends only on the tile and how many times it has
|
||||||
// still between swaps and any frame can be drawn on its own.
|
// 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;
|
std::vector<Tile> plan;
|
||||||
plan.reserve(cells.size());
|
plan.reserve(cells.size());
|
||||||
for (std::size_t i = 0; i < cells.size(); ++i) {
|
for (std::size_t i = 0; i < cells.size(); ++i) {
|
||||||
const Cell& cell = cells[i];
|
const Cell& cell = cells[i];
|
||||||
|
const long swaps = swapsOf(cell);
|
||||||
std::uint64_t turn = 0;
|
std::uint64_t turn = 0;
|
||||||
if (swaps > 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);
|
turn = static_cast<std::uint64_t>(passed % swaps);
|
||||||
}
|
}
|
||||||
Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL);
|
Rng cut(cell.seed + turn * 0x9e3779b97f4a7c15ULL);
|
||||||
|
|||||||
+235
-14
@@ -5,6 +5,7 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <iomanip>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
@@ -29,6 +30,11 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*
|
|||||||
const char* const kRecentApp = "effector";
|
const char* const kRecentApp = "effector";
|
||||||
const std::size_t kRecentCount = 10;
|
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() {
|
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
|
||||||
auto filter = Gtk::FileFilter::create();
|
auto filter = Gtk::FileFilter::create();
|
||||||
filter->set_name("Effector projects");
|
filter->set_name("Effector projects");
|
||||||
@@ -69,14 +75,14 @@ Gtk::Label* sectionLabel(const char* text) {
|
|||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A GtkSpinButton takes every wheel turn over it: it grabs the focus and
|
// A GtkSpinButton (or a GtkScale) takes every wheel turn over it: it grabs the
|
||||||
// spins its value, so scrolling down a column of params stops dead at the
|
// focus and spins its value, so scrolling down a column of params stops dead at
|
||||||
// first spin under the pointer and quietly changes it. This hands the wheel to
|
// 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
|
// 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
|
// 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
|
// spin's own controller, and so has to scroll the enclosing ScrolledWindow
|
||||||
// itself: stopping the event there stops it for the window as well.
|
// 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();
|
auto scroll = Gtk::EventControllerScroll::create();
|
||||||
scroll->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
|
scroll->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
|
||||||
scroll->set_flags(Gtk::EventControllerScroll::Flags::VERTICAL);
|
scroll->set_flags(Gtk::EventControllerScroll::Flags::VERTICAL);
|
||||||
@@ -116,13 +122,21 @@ EffectorWindow::EffectorWindow(const Options& initial) {
|
|||||||
|
|
||||||
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
||||||
loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress));
|
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));
|
clipProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onClipProgress));
|
||||||
exportProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportProgress));
|
exportProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportProgress));
|
||||||
exportDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportFinished));
|
exportDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportFinished));
|
||||||
|
|
||||||
buildUi();
|
buildUi();
|
||||||
applyInitial(initial);
|
applyInitial(initial);
|
||||||
|
historyAfterLoad_ = loading_;
|
||||||
|
resetHistory();
|
||||||
|
|
||||||
suspendRender_ = false;
|
suspendRender_ = false;
|
||||||
if (loading_) {
|
if (loading_) {
|
||||||
@@ -136,6 +150,7 @@ EffectorWindow::EffectorWindow(const Options& initial) {
|
|||||||
|
|
||||||
EffectorWindow::~EffectorWindow() {
|
EffectorWindow::~EffectorWindow() {
|
||||||
debounce_.disconnect();
|
debounce_.disconnect();
|
||||||
|
historyCommit_.disconnect();
|
||||||
chainSplitFit_.disconnect();
|
chainSplitFit_.disconnect();
|
||||||
recentChanged_.disconnect();
|
recentChanged_.disconnect();
|
||||||
loadWindowDelay_.disconnect();
|
loadWindowDelay_.disconnect();
|
||||||
@@ -594,6 +609,11 @@ Gtk::Widget& EffectorWindow::buildVideoSection() {
|
|||||||
videoQualityDrop_.set_selected(standard);
|
videoQualityDrop_.set_selected(standard);
|
||||||
videoQualityDrop_.set_hexpand(true);
|
videoQualityDrop_.set_hexpand(true);
|
||||||
videoQualityDrop_.set_tooltip_text(qualityTooltip);
|
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");
|
auto* qualityLabel = Gtk::make_managed<Gtk::Label>("Quality");
|
||||||
qualityLabel->set_xalign(0.0F);
|
qualityLabel->set_xalign(0.0F);
|
||||||
grid->attach(*qualityLabel, 0, 2);
|
grid->attach(*qualityLabel, 0, 2);
|
||||||
@@ -931,14 +951,42 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
}
|
}
|
||||||
case ParamSpec::Type::Int:
|
case ParamSpec::Type::Int:
|
||||||
case ParamSpec::Type::Double: {
|
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>();
|
auto spin = std::make_unique<Gtk::SpinButton>();
|
||||||
spin->set_range(spec.minimum, spec.maximum);
|
spin->set_range(spec.minimum, spec.maximum);
|
||||||
spin->set_increments(spec.step, spec.step * 10);
|
spin->set_increments(spec.step, spec.step * 10);
|
||||||
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
|
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
|
||||||
spin->set_value(spec.type == ParamSpec::Type::Int
|
spin->set_value(value);
|
||||||
? static_cast<double>(paramInt(values, spec.key,
|
|
||||||
static_cast<long>(spec.defaultValue)))
|
|
||||||
: paramDouble(values, spec.key, spec.defaultValue));
|
|
||||||
spin->signal_value_changed().connect(
|
spin->signal_value_changed().connect(
|
||||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||||
scrollPastUnlessFocused(*spin);
|
scrollPastUnlessFocused(*spin);
|
||||||
@@ -995,6 +1043,21 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
widget = std::move(button);
|
widget = std::move(button);
|
||||||
break;
|
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: {
|
case ParamSpec::Type::Choice: {
|
||||||
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
|
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
|
||||||
auto dropdown = std::make_unique<Gtk::DropDown>();
|
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();
|
values[entry.spec.key] = static_cast<Gtk::Switch*>(entry.widget)->get_active();
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Int:
|
case ParamSpec::Type::Int:
|
||||||
values[entry.spec.key] =
|
case ParamSpec::Type::Double: {
|
||||||
static_cast<long>(static_cast<Gtk::SpinButton*>(entry.widget)->get_value());
|
const double value =
|
||||||
break;
|
entry.spec.onSlider
|
||||||
case ParamSpec::Type::Double:
|
? entry.spec.sliderValue(static_cast<Gtk::Scale*>(entry.widget)->get_value())
|
||||||
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(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;
|
break;
|
||||||
|
}
|
||||||
case ParamSpec::Type::Path:
|
case ParamSpec::Type::Path:
|
||||||
case ParamSpec::Type::Text:
|
case ParamSpec::Type::Text:
|
||||||
values[entry.spec.key] =
|
values[entry.spec.key] =
|
||||||
@@ -1051,6 +1120,14 @@ ParamValues EffectorWindow::paramValues() const {
|
|||||||
values[entry.spec.key] = formatColor(rgb);
|
values[entry.spec.key] = formatColor(rgb);
|
||||||
break;
|
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: {
|
case ParamSpec::Type::Choice: {
|
||||||
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
|
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];
|
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;
|
if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name;
|
||||||
}
|
}
|
||||||
buildParamWidgets();
|
buildParamWidgets();
|
||||||
|
noteChange(); // the picture is the same, so no render asks for it
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2191,6 +2269,7 @@ void EffectorWindow::onRecreate() {
|
|||||||
seed_ = Rng::randomSeed();
|
seed_ = Rng::randomSeed();
|
||||||
seedEntry_.set_text(std::to_string(seed_));
|
seedEntry_.set_text(std::to_string(seed_));
|
||||||
if (!canRender()) {
|
if (!canRender()) {
|
||||||
|
noteChange();
|
||||||
showError(missingText());
|
showError(missingText());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2227,6 +2306,9 @@ void EffectorWindow::showPlaceholder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void EffectorWindow::requestRender() {
|
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 (suspendRender_) return;
|
||||||
if (!canRender()) {
|
if (!canRender()) {
|
||||||
showPlaceholder();
|
showPlaceholder();
|
||||||
@@ -2414,10 +2496,16 @@ Gtk::Widget& EffectorWindow::buildMainMenu() {
|
|||||||
});
|
});
|
||||||
// Never enabled: it only stands in for an empty list.
|
// Never enabled: it only stands in for an empty list.
|
||||||
add_action("no-recent")->set_enabled(false);
|
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("show-changelog", sigc::mem_fun(*this, &EffectorWindow::showChangelog));
|
||||||
add_action("about", sigc::mem_fun(*this, &EffectorWindow::showAbout));
|
add_action("about", sigc::mem_fun(*this, &EffectorWindow::showAbout));
|
||||||
|
|
||||||
auto menu = Gio::Menu::create();
|
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();
|
auto open = Gio::Menu::create();
|
||||||
open->append("New Project", "win.new-project");
|
open->append("New Project", "win.new-project");
|
||||||
open->append("Open Project…", "win.open-project");
|
open->append("Open Project…", "win.open-project");
|
||||||
@@ -2453,6 +2541,20 @@ Gtk::Widget& EffectorWindow::buildMainMenu() {
|
|||||||
}
|
}
|
||||||
add_controller(shortcuts);
|
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);
|
return *Gtk::make_managed<Gtk::PopoverMenu>(menu);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2502,6 +2604,8 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat
|
|||||||
} else {
|
} else {
|
||||||
addPaths(project.sources); // renders once the images are decoded
|
addPaths(project.sources); // renders once the images are decoded
|
||||||
}
|
}
|
||||||
|
historyAfterLoad_ = loading_;
|
||||||
|
resetHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
void EffectorWindow::clearSources() {
|
void EffectorWindow::clearSources() {
|
||||||
@@ -2810,6 +2914,123 @@ void EffectorWindow::updateTitle() {
|
|||||||
set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector");
|
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 ---
|
// --------------------------------------------------------------- saving ---
|
||||||
|
|
||||||
void EffectorWindow::onSave() {
|
void EffectorWindow::onSave() {
|
||||||
|
|||||||
@@ -161,6 +161,26 @@ private:
|
|||||||
void writeProject(const std::string& path);
|
void writeProject(const std::string& path);
|
||||||
void updateTitle();
|
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();
|
void onParamChanged();
|
||||||
// The chooser behind a Path param. The chosen file goes straight into the
|
// 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
|
// 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
|
sigc::connection recentChanged_; // the recent list outlives the window
|
||||||
bool suspendRender_ = true;
|
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
|
// Source loading. Decoding a batch of large photographs takes seconds, so
|
||||||
// it runs off the main thread and reports into a small progress window.
|
// it runs off the main thread and reports into a small progress window.
|
||||||
std::thread loader_;
|
std::thread loader_;
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
#include <array>
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <clocale>
|
#include <clocale>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
@@ -2079,6 +2081,37 @@ int main() {
|
|||||||
frozenSwap.values["tileswap_rate"] = 0.0;
|
frozenSwap.values["tileswap_rate"] = 0.0;
|
||||||
check(sameBytes(frameOf(frozenSwap, 0), frameOf(frozenSwap, 5)),
|
check(sameBytes(frameOf(frozenSwap, 0), frameOf(frozenSwap, 5)),
|
||||||
"at a rate of 0 it holds still");
|
"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;
|
ChainStage scattered = swapStage;
|
||||||
scattered.values["tileswap_layout"] = std::string("scatter");
|
scattered.values["tileswap_layout"] = std::string("scatter");
|
||||||
ChainStage bricks = swapStage;
|
ChainStage bricks = swapStage;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <iomanip>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -591,6 +592,214 @@ void testVideo(Gtk::Window& window, const std::filesystem::path& dir) {
|
|||||||
stack->set_visible_child("chain");
|
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
|
// 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.
|
// saving it then must not quietly drop them from the file.
|
||||||
void testMemoryGuard(Gtk::Window& window, const std::filesystem::path& dir) {
|
void testMemoryGuard(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||||
@@ -658,6 +867,9 @@ int main(int argc, char** argv) {
|
|||||||
testHelpWindows(*window);
|
testHelpWindows(*window);
|
||||||
testRecentProjects(*window, dataDir / "projects");
|
testRecentProjects(*window, dataDir / "projects");
|
||||||
testVideo(*window, dataDir / "video");
|
testVideo(*window, dataDir / "video");
|
||||||
|
testFont(*window, dataDir / "font");
|
||||||
|
testSlider(*window, dataDir / "slider");
|
||||||
|
testHistory(*window, dataDir / "history");
|
||||||
testMemoryGuard(*window, dataDir / "memory");
|
testMemoryGuard(*window, dataDir / "memory");
|
||||||
app->remove_window(*window);
|
app->remove_window(*window);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user