2 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
10 changed files with 222 additions and 34 deletions
+13
View File
@@ -4,6 +4,19 @@ 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 ## [0.8.1] - 2026-09-26
### Added ### Added
+6
View File
@@ -80,6 +80,12 @@ 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
+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(effector VERSION 0.8.1 LANGUAGES CXX) project(effector VERSION 0.8.2 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) 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 # 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.1 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')
+22
View File
@@ -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;
+19
View File
@@ -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,
+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."), "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);
+48 -13
View File
@@ -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>
@@ -74,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);
@@ -950,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);
@@ -1065,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] =
+33
View File
@@ -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;
+47
View File
@@ -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>
@@ -633,6 +634,51 @@ void testFont(Gtk::Window& window, const std::filesystem::path& dir) {
"a family chosen there is saved with the project"); "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) { bool actionEnabled(Gtk::Window& window, const std::string& name) {
auto action = dynamic_cast<Gtk::ApplicationWindow&>(window).lookup_action(name); auto action = dynamic_cast<Gtk::ApplicationWindow&>(window).lookup_action(name);
return action && action->get_enabled(); return action && action->get_enabled();
@@ -822,6 +868,7 @@ int main(int argc, char** argv) {
testRecentProjects(*window, dataDir / "projects"); testRecentProjects(*window, dataDir / "projects");
testVideo(*window, dataDir / "video"); testVideo(*window, dataDir / "video");
testFont(*window, dataDir / "font"); testFont(*window, dataDir / "font");
testSlider(*window, dataDir / "slider");
testHistory(*window, dataDir / "history"); testHistory(*window, dataDir / "history");
testMemoryGuard(*window, dataDir / "memory"); testMemoryGuard(*window, dataDir / "memory");
app->remove_window(*window); app->remove_window(*window);