12 Commits
Author SHA1 Message Date
SiandClaude Opus 5.5 81ca17463e Keep the status line short, with the full chain and setup in its tooltip
The line now reads only the number of effects that ran and the render
time; the chain, canvas size, sources and seed move to the tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:34:07 +02:00
SiandClaude Opus 5.5 d198428dc4 Fit the split on a double-click only once the handle's drag ends
The split's own drag is still going during the second press of a
double-click, so the least movement before the release put the handle
back under the pointer right after it was fitted. The fit now waits for
that drag to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:26:59 +02:00
SiandClaude Opus 5.5 a01ada5372 Fit the chain/params split to the chain's rows until it is dragged
The split between the chain list and the params sat at a fixed 300 px,
wrong for a short chain and for a long one alike. It now rests under the
chain's rows, growing with the chain up to eight stages before the list
scrolls. Once the user drags it, it stays put; a double-click on the
handle fits it to the rows again.

GtkPaned sizes the fixed pane from its minimum height, which is only the
drag floor here, so the fit is done by hand from the natural height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:21:51 +02:00
SiandClaude Opus 5.5 d0b5669f46 Let the mouse wheel scroll past spin buttons that do not have the focus
A GtkSpinButton grabs the focus and spins its value on any wheel turn over
it, so scrolling through the params stopped at the first spin under the
pointer and changed it. A capture-phase scroll controller now scrolls the
enclosing view instead, unless the spin already has the focus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:47:10 +02:00
SiandClaude Opus 5.5 5292c101db Label text stages in the chain list with their words
Effect::label(values) names a stage in a list, defaulting to the title;
the text effect answers "Text (words)", and the chain rows are renamed
in place as params change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:37:26 +02:00
SiandClaude Opus 5.5 134c575727 Add a rotation to the text effect's placement
text_rotation turns the text and its box about the box's centre, so the
centring and the offset still decide where it sits. Like the other
placement params it stays on the stage and is not carried by a style.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:33:37 +02:00
Si 68a1e40d3f Merge branch 'sidebar-tabs' 2026-09-23 21:28:07 +02:00
SiandClaude Opus 5.5 5a5cea69db Split the sidebar into Chain and Setup tabs, with a draggable chain list
The sidebar grew crowded as the chain got longer. The chain and the params
of the selected stage now share a Chain tab through a vertical split the
user drags, and the sources, canvas and seed move to a Setup tab. Stage
rows are compact, with their move and remove buttons shown only on the
hovered, selected or focused row.

Add effect had stopped opening: its menu of fifteen effects no longer fit
below the button, and GTK does not show a popover that does not fit. The
menu now scrolls past 480 pixels, and the button sits beside the chain
heading, where the split cannot push it down the screen.

The window code becomes an effector_gui library so a new GUI test binary
can drive the real window under Xvfb; ctest skips it when xvfb-run is
missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:26:42 +02:00
Si 1edf854cd1 Merge branch 'centered-preview' 2026-09-23 20:44:55 +02:00
SiandClaude Opus 5.5 4fd6ccc9b0 Keep the preview centred however long the status line gets
The status line under the preview names every stage of the chain, and a
label asks for its whole text as its minimum width. With a long chain
that was wider than the pane, so the preview column was laid out wider
than it is shown and the picture, centred in it, slid off to the right
until a shorter status (such as "Saved ...") let it back. The line is
now ellipsized and never asks for more room than it is given; the full
text stays readable as its tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:44:47 +02:00
Si 352a928149 Merge branch 'chaos-effects' 2026-09-23 20:36:57 +02:00
SiandClaude Opus 5.5 ce3a422f6a Add eight effects that throw something chaotic into the chain
Slit shift, channel split, pixel sort, corruption, shatter, warp, bit
crush and feedback: tearing bands sideways, knocking the colour planes
out of register, sorting runs of pixels, damaging blocks the way a broken
stream does, breaking the picture into shards, pushing it along a noise
field, mangling its bytes and laying it over itself again and again.

All eight are pure transforms. None needs sources, so any of them can
lead a chain, and like blur they start from a crop of a random source
when they do and photographs are loaded. canvasFromSources holds that
start once for them.

Each draws its whole plan from the rng before touching a pixel, and
pixel sort hands every line a seed of its own, so the parallel work
never decides anything. The rotations in shatter and feedback take their
sine and cosine from a short series of plain multiplications rather than
libm, so a seed lands on the same pixels whatever the machine's maths
library makes of std::sin.

The tests hold every one of them to the same things -- a seed
reproduces it, it runs without sources and as a later stage, a sliver
of a canvas does not trip it, turned right down it leaves its input
alone -- plus a check of what each actually does, and the registry now
checks that no two effects declare the same param key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:36:09 +02:00
29 changed files with 2282 additions and 49 deletions
+11 -3
View File
@@ -12,7 +12,7 @@ ctest --test-dir build --output-on-failure
Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way. Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way.
The test suite is a single hand-rolled binary (no framework), so there is no The core test suite is a single hand-rolled binary (no framework), so there is no
per-test filter: run `./build/tests/effector_tests` directly to see the section per-test filter: run `./build/tests/effector_tests` directly to see the section
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`, headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment `image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
@@ -20,6 +20,13 @@ out the other blocks in `tests/core_tests.cpp` or add a new
`std::cout << "section\n"` block — checks are plain `std::cout << "section\n"` block — checks are plain
`check(condition, "description")` calls and `main()` returns the failure count. `check(condition, "description")` calls and `main()` returns the failure count.
The interface has its own binary, `./build/tests/effector_gui_tests`
(`tests/gui_tests.cpp`, same `check()` style), which opens the real window and
drives it by widget names and tooltips (`chain-list`, `add-effect`,
`sources`, "Move up", ...). It needs a display: ctest runs it under `xvfb-run`
when that is installed and otherwise reports it skipped (exit 77). To run it by
hand, use the Xvfb invocation below.
Headless render (no display needed, the fastest end-to-end smoke test): Headless render (no display needed, the fastest end-to-end smoke test):
```bash ```bash
@@ -148,8 +155,9 @@ is discarded rather than mixed into the new project (`dropLoadedImages_`).
`effector_core` (static) holds image IO, params, effects, rng and links only `effector_core` (static) holds image IO, params, effects, rng and links only
`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need `gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need
no display) + Threads — **no gtkmm, no display**. The `effector` executable no display) + Threads — **no gtkmm, no display**. `effector_gui` (static) adds
adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of `src/cli/` and `src/gui/` and links gtkmm, and the `effector` executable is
`src/main.cpp` on top of it; the GUI tests link `effector_gui` too. Keep GTK out of
`src/core/` and `src/effects/`; the tests link the core alone and rely on this. `src/core/` and `src/effects/`; the tests link the core alone and rely on this.
### Determinism is a contract ### Determinism is a contract
+16 -4
View File
@@ -32,13 +32,21 @@ add_library(effector_core STATIC
src/core/registry.cpp src/core/registry.cpp
src/core/rng.cpp src/core/rng.cpp
src/core/style.cpp src/core/style.cpp
src/effects/bitcrush.cpp
src/effects/blur.cpp src/effects/blur.cpp
src/effects/channelsplit.cpp
src/effects/corrupt.cpp
src/effects/feedback.cpp
src/effects/grain.cpp src/effects/grain.cpp
src/effects/monochrome.cpp src/effects/monochrome.cpp
src/effects/overlay.cpp src/effects/overlay.cpp
src/effects/pixelsort.cpp
src/effects/scanlines.cpp src/effects/scanlines.cpp
src/effects/shatter.cpp
src/effects/slitshift.cpp
src/effects/text.cpp src/effects/text.cpp
src/effects/tiles.cpp src/effects/tiles.cpp
src/effects/warp.cpp
) )
target_include_directories(effector_core PUBLIC src) target_include_directories(effector_core PUBLIC src)
# The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding, # The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding,
@@ -46,14 +54,18 @@ target_include_directories(effector_core PUBLIC src)
target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF PkgConfig::PANGOCAIRO Threads::Threads) target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF PkgConfig::PANGOCAIRO Threads::Threads)
target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic)
# --- application -------------------------------------------------------------- # --- interface: the GTK window, split out so the GUI tests can link it --------
add_executable(effector add_library(effector_gui STATIC
src/main.cpp
src/cli/options.cpp src/cli/options.cpp
src/gui/app.cpp src/gui/app.cpp
src/gui/window.cpp src/gui/window.cpp
) )
target_link_libraries(effector PRIVATE effector_core PkgConfig::GTKMM) target_link_libraries(effector_gui PUBLIC effector_core PkgConfig::GTKMM)
target_compile_options(effector_gui PRIVATE -Wall -Wextra -Wpedantic)
# --- application --------------------------------------------------------------
add_executable(effector src/main.cpp)
target_link_libraries(effector PRIVATE effector_gui)
target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic)
install(TARGETS effector RUNTIME DESTINATION bin) install(TARGETS effector RUNTIME DESTINATION bin)
+7
View File
@@ -38,6 +38,13 @@ public:
// use. An effect with no kind must declare no styled params. // use. An effect with no kind must declare no styled params.
virtual std::string styleKind() const { return {}; } virtual std::string styleKind() const { return {}; }
// How a stage of this effect is named in a list of stages, given its
// values: the title unless the values say something more telling.
virtual std::string label(const ParamValues& values) const {
(void)values;
return title();
}
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng, virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0; const ParamValues& values) const = 0;
+16
View File
@@ -1,12 +1,20 @@
#include "core/registry.hpp" #include "core/registry.hpp"
#include "effects/bitcrush.hpp"
#include "effects/blur.hpp" #include "effects/blur.hpp"
#include "effects/channelsplit.hpp"
#include "effects/corrupt.hpp"
#include "effects/feedback.hpp"
#include "effects/grain.hpp" #include "effects/grain.hpp"
#include "effects/monochrome.hpp" #include "effects/monochrome.hpp"
#include "effects/overlay.hpp" #include "effects/overlay.hpp"
#include "effects/pixelsort.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
#include "effects/shatter.hpp"
#include "effects/slitshift.hpp"
#include "effects/text.hpp" #include "effects/text.hpp"
#include "effects/tiles.hpp" #include "effects/tiles.hpp"
#include "effects/warp.hpp"
namespace effector { namespace effector {
@@ -20,6 +28,14 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<OverlayEffect>(), std::make_shared<OverlayEffect>(),
std::make_shared<TextEffect>(), std::make_shared<TextEffect>(),
std::make_shared<TilesEffect>(), std::make_shared<TilesEffect>(),
std::make_shared<SlitShiftEffect>(),
std::make_shared<ChannelSplitEffect>(),
std::make_shared<PixelSortEffect>(),
std::make_shared<CorruptEffect>(),
std::make_shared<ShatterEffect>(),
std::make_shared<WarpEffect>(),
std::make_shared<BitCrushEffect>(),
std::make_shared<FeedbackEffect>(),
}; };
return registry; return registry;
} }
+129
View File
@@ -0,0 +1,129 @@
#include "effects/bitcrush.hpp"
#include <algorithm>
#include <array>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& BitCrushEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeChoice("crush_op", "Operation", "mixed",
{"mixed", "posterize", "xor", "bitrotate", "channel_swap"},
"posterize drops the low bits, xor flips random low bits, "
"bitrotate turns each byte's bits round, channel_swap shuffles "
"red, green and blue; mixed picks one for every band."),
ParamSpec::makeInt("crush_bits", "Bits", 3, 1, 7,
"How many bits the operation reaches: dropped by posterize, flipped "
"at most by xor, turned at most by bitrotate."),
ParamSpec::makeDouble("crush_coverage", "Coverage", 0.5, 0.0, 1.0,
"Chance of each band being crushed."),
ParamSpec::makeInt("crush_band_height", "Band height", 32, 1, 4096,
"Thickness in pixels of each band."),
};
return specs;
}
namespace {
enum class Op : std::uint8_t { None, Posterize, Xor, Rotate, Swap };
struct Band {
Op op = Op::None;
int rotate = 0;
std::array<std::uint8_t, 3> mask{};
std::array<int, 3> order{0, 1, 2};
};
Op opNamed(const std::string& name) {
if (name == "posterize") return Op::Posterize;
if (name == "xor") return Op::Xor;
if (name == "bitrotate") return Op::Rotate;
if (name == "channel_swap") return Op::Swap;
return Op::None; // mixed: drawn per band
}
} // namespace
Image BitCrushEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const Op fixed = opNamed(paramChoice(values, "crush_op", "mixed"));
const int bits = static_cast<int>(std::clamp(paramInt(values, "crush_bits", 3), 1L, 7L));
const double coverage = std::clamp(paramDouble(values, "crush_coverage", 0.5), 0.0, 1.0);
const int bandHeight = static_cast<int>(std::max(1L, paramInt(values, "crush_band_height", 32)));
Image canvas = canvasFromSources(sources, width, height, rng);
const int bandCount = (height + bandHeight - 1) / bandHeight;
std::vector<Band> plan(static_cast<std::size_t>(bandCount));
for (auto& band : plan) {
if (!chance(rng.next(), coverage)) continue;
band.op = fixed != Op::None ? fixed : static_cast<Op>(1 + rng.below(3));
switch (band.op) {
case Op::Xor:
for (auto& mask : band.mask) {
mask = static_cast<std::uint8_t>(rng.inRange(1, (1L << bits) - 1));
}
break;
case Op::Rotate:
band.rotate = static_cast<int>(rng.inRange(1, bits));
break;
case Op::Swap:
// Fisher-Yates, never leaving the order as it was.
do {
band.order = {0, 1, 2};
for (int i = 2; i > 0; --i) {
std::swap(band.order[static_cast<std::size_t>(i)],
band.order[rng.below(static_cast<std::uint64_t>(i))]);
}
} while (band.order[0] == 0 && band.order[1] == 1);
break;
default:
break;
}
}
const std::uint8_t keep = static_cast<std::uint8_t>(0xFF << bits);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
const Band& band = plan[y / static_cast<std::size_t>(bandHeight)];
if (band.op == Op::None) continue;
std::uint8_t* row = canvas.row(static_cast<int>(y));
for (int x = 0; x < width; ++x) {
std::uint8_t* px = row + static_cast<std::size_t>(x) * 3;
switch (band.op) {
case Op::Posterize:
for (int c = 0; c < 3; ++c) px[c] &= keep;
break;
case Op::Xor:
for (int c = 0; c < 3; ++c) px[c] ^= band.mask[static_cast<std::size_t>(c)];
break;
case Op::Rotate:
for (int c = 0; c < 3; ++c) {
px[c] = static_cast<std::uint8_t>((px[c] << band.rotate) |
(px[c] >> (8 - band.rotate)));
}
break;
case Op::Swap: {
const std::uint8_t in[3] = {px[0], px[1], px[2]};
for (int c = 0; c < 3; ++c) px[c] = in[band.order[static_cast<std::size_t>(c)]];
break;
}
case Op::None:
break;
}
}
}
});
return canvas;
}
} // namespace effector
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Mangles the bytes of its input band by band: posterising, xoring with a
// random mask, rotating the bits or shuffling the channels.
// A pure transform: it needs no source images, and leading a chain with
// sources loaded it starts from a crop of a randomly chosen one.
class BitCrushEffect : public Effect {
public:
std::string name() const override { return "bitcrush"; }
std::string title() const override { return "Bit crush"; }
std::string description() const override {
return "Mangles the bytes of random bands: posterises them, xors them with a random "
"mask, rotates their bits or shuffles their channels.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+97
View File
@@ -0,0 +1,97 @@
#include "effects/channelsplit.hpp"
#include <algorithm>
#include <array>
#include <vector>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& ChannelSplitEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("split_amount", "Amount", 12, 0, 1024,
"Furthest in pixels a channel may be moved, along either axis."),
ParamSpec::makeChoice("split_mode", "Mode", "global", {"global", "per_band"},
"global moves each channel once over the whole image; per_band "
"draws new offsets for every band."),
ParamSpec::makeInt("split_band_height", "Band height", 24, 1, 2048,
"Thickness in pixels of each band; per_band mode only."),
ParamSpec::makeChoice("split_channels", "Channels", "rgb", {"rgb", "red_cyan", "random"},
"rgb moves all three channels apart; red_cyan moves red one way and "
"green and blue the other, as 3D glasses would; random moves one "
"channel picked at random."),
};
return specs;
}
namespace {
struct Offset {
int dx = 0;
int dy = 0;
};
using ChannelOffsets = std::array<Offset, 3>;
ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels) {
ChannelOffsets offsets{};
auto draw = [&] {
return Offset{static_cast<int>(rng.inRange(-amount, amount)),
static_cast<int>(rng.inRange(-amount, amount))};
};
if (channels == "red_cyan") {
const Offset red = draw();
offsets[0] = red;
offsets[1] = offsets[2] = Offset{-red.dx, -red.dy};
} else if (channels == "random") {
offsets[rng.below(2)] = draw();
} else {
for (auto& offset : offsets) offset = draw();
}
return offsets;
}
} // namespace
Image ChannelSplitEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const int amount = static_cast<int>(std::max(0L, paramInt(values, "split_amount", 12)));
const bool perBand = paramChoice(values, "split_mode", "global") == "per_band";
const int bandHeight = static_cast<int>(std::max(1L, paramInt(values, "split_band_height", 24)));
const std::string channels = paramChoice(values, "split_channels", "rgb");
const Image input = canvasFromSources(sources, width, height, rng);
// One set of offsets per band, a single band covering everything in global
// mode; all of them drawn before the pixels are touched.
const int band = perBand ? bandHeight : height;
const int bandCount = (height + band - 1) / band;
std::vector<ChannelOffsets> plan;
plan.reserve(static_cast<std::size_t>(bandCount));
for (int i = 0; i < bandCount; ++i) plan.push_back(drawOffsets(rng, amount, channels));
Image canvas(width, height);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
const ChannelOffsets& offsets = plan[y / static_cast<std::size_t>(band)];
std::uint8_t* out = canvas.row(static_cast<int>(y));
for (int c = 0; c < 3; ++c) {
const Offset& offset = offsets[static_cast<std::size_t>(c)];
const int sy = std::clamp(static_cast<int>(y) - offset.dy, 0, height - 1);
const std::uint8_t* in = input.row(sy);
for (int x = 0; x < width; ++x) {
const int sx = std::clamp(x - offset.dx, 0, width - 1);
out[static_cast<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c];
}
}
}
});
return canvas;
}
} // namespace effector
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Knocks the red, green and blue planes out of register, either the whole image
// at once or band by band, each band with offsets of its own.
// A pure transform: it needs no source images, and leading a chain with
// sources loaded it starts from a crop of a randomly chosen one.
class ChannelSplitEffect : public Effect {
public:
std::string name() const override { return "channelsplit"; }
std::string title() const override { return "Channel split"; }
std::string description() const override {
return "Knocks the red, green and blue planes out of register, over the whole image or "
"band by band.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+143
View File
@@ -0,0 +1,143 @@
#include "effects/corrupt.hpp"
#include <algorithm>
#include <cstring>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& CorruptEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("corrupt_block", "Block size", 16, 2, 256,
"Width and height in pixels of one block, as a codec would cut them."),
ParamSpec::makeDouble("corrupt_rate", "Rate", 0.08, 0.0, 1.0,
"Chance of each block being damaged."),
ParamSpec::makeChoice("corrupt_kinds", "Damage", "mixed",
{"mixed", "repeat", "displace", "colour"},
"repeat freezes a block into a copy of the one to its left, "
"displace pulls one in from elsewhere in the image, colour knocks "
"it off colour; mixed picks one of them for every block."),
ParamSpec::makeBool("corrupt_bleed", "Bleed", false,
"Carry each damaged block's fault on to the end of its row, the way "
"one broken bit spoils the rest of a stream."),
};
return specs;
}
namespace {
enum class Damage : std::uint8_t { None, Repeat, Displace, Colour };
struct Block {
Damage damage = Damage::None;
int sx = 0; // displace: where the block is read from
int sy = 0;
int shift[3] = {0, 0, 0}; // colour: added to each channel
};
} // namespace
Image CorruptEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const int size = static_cast<int>(std::max(2L, paramInt(values, "corrupt_block", 16)));
const double rate = std::clamp(paramDouble(values, "corrupt_rate", 0.08), 0.0, 1.0);
const std::string kinds = paramChoice(values, "corrupt_kinds", "mixed");
const bool bleed = paramBool(values, "corrupt_bleed", false);
const Image input = canvasFromSources(sources, width, height, rng);
const int cols = (width + size - 1) / size;
const int rows = (height + size - 1) / size;
auto drawBlock = [&](int bx) {
Block block;
if (kinds == "repeat") block.damage = Damage::Repeat;
else if (kinds == "displace") block.damage = Damage::Displace;
else if (kinds == "colour") block.damage = Damage::Colour;
else block.damage = static_cast<Damage>(1 + rng.below(2));
// The first block of a row has nothing to its left to repeat.
if (block.damage == Damage::Repeat && bx == 0) block.damage = Damage::Displace;
if (block.damage == Damage::Displace) {
block.sx = static_cast<int>(rng.inRange(0, std::max(0, width - size)));
block.sy = static_cast<int>(rng.inRange(0, std::max(0, height - size)));
} else if (block.damage == Damage::Colour) {
for (int& shift : block.shift) shift = static_cast<int>(rng.inRange(-128, 128));
}
return block;
};
// The whole damage map is drawn up front, block by block in reading order.
std::vector<Block> plan(static_cast<std::size_t>(cols) * rows);
for (int by = 0; by < rows; ++by) {
const Block* carried = nullptr;
for (int bx = 0; bx < cols; ++bx) {
Block& block = plan[static_cast<std::size_t>(by) * cols + bx];
if (carried != nullptr) {
block = *carried;
// A displaced run keeps moving along with the row, so the
// stretch reads as one shifted strip rather than one block
// over and over.
if (block.damage == Damage::Displace) block.sx += size;
carried = &block;
continue;
}
if (!chance(rng.next(), rate)) continue;
block = drawBlock(bx);
if (bleed) carried = &block;
}
}
Image canvas = input;
// Block rows are independent (a repeat only ever reads to its left), so
// they run in parallel and each walks its row in order.
parallelFor(static_cast<std::size_t>(rows), [&](std::size_t begin, std::size_t end) {
for (std::size_t by = begin; by < end; ++by) {
const int y0 = static_cast<int>(by) * size;
const int h = std::min(size, height - y0);
for (int bx = 0; bx < cols; ++bx) {
const Block& block = plan[by * static_cast<std::size_t>(cols) + bx];
if (block.damage == Damage::None) continue;
const int x0 = bx * size;
const int w = std::min(size, width - x0);
const std::size_t bytes = static_cast<std::size_t>(w) * 3;
for (int line = 0; line < h; ++line) {
std::uint8_t* out = canvas.row(y0 + line) + static_cast<std::size_t>(x0) * 3;
switch (block.damage) {
case Damage::Repeat:
std::memmove(out, out - static_cast<std::size_t>(size) * 3, bytes);
break;
case Damage::Displace: {
const int sy = std::min(block.sy + line, height - 1);
const std::uint8_t* in = input.row(sy);
for (int x = 0; x < w; ++x) {
const int sx = (block.sx + x) % width;
std::memcpy(out + static_cast<std::size_t>(x) * 3,
in + static_cast<std::size_t>(sx) * 3, 3);
}
break;
}
case Damage::Colour:
for (std::size_t i = 0; i < bytes; ++i) {
out[i] = static_cast<std::uint8_t>(
std::clamp(out[i] + block.shift[i % 3], 0, 255));
}
break;
case Damage::None:
break;
}
}
}
}
});
return canvas;
}
} // namespace effector
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Imitates a damaged video stream: blocks of the image are frozen into a copy of
// their neighbour, pulled in from elsewhere or knocked off colour, and with
// bleeding switched on the damage runs on to the end of the row.
// A pure transform: it needs no source images, and leading a chain with
// sources loaded it starts from a crop of a randomly chosen one.
class CorruptEffect : public Effect {
public:
std::string name() const override { return "corrupt"; }
std::string title() const override { return "Corruption"; }
std::string description() const override {
return "Imitates a damaged video stream: random blocks are smeared, displaced or knocked "
"off colour, optionally bleeding on to the end of their row.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+103
View File
@@ -0,0 +1,103 @@
#include "effects/feedback.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& FeedbackEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("echo_count", "Echoes", 6, 1, 64,
"How many times the image is laid back over itself."),
ParamSpec::makeDouble("echo_scale", "Scale", 0.85, 0.1, 2.0,
"Size of each echo against the one before; below 1 the echoes "
"tunnel inwards, above 1 they burst outwards.",
0.01),
ParamSpec::makeDouble("echo_rotate", "Rotation", 4.0, -180.0, 180.0,
"Turn in degrees of each echo against the one before.", 0.5, 1),
ParamSpec::makeInt("echo_drift", "Drift", 20, 0, 4096,
"Furthest in pixels each echo may wander off centre, either way."),
ParamSpec::makeDouble("echo_fade", "Opacity", 0.8, 0.0, 1.0,
"How strongly each echo covers what lies beneath it."),
};
return specs;
}
namespace {
struct Echo {
double dx = 0.0;
double dy = 0.0;
};
} // namespace
Image FeedbackEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const long count = std::clamp(paramInt(values, "echo_count", 6), 1L, 64L);
const double scale = std::clamp(paramDouble(values, "echo_scale", 0.85), 0.1, 2.0);
const double rotate = std::clamp(paramDouble(values, "echo_rotate", 4.0), -180.0, 180.0);
const int drift = static_cast<int>(std::max(0L, paramInt(values, "echo_drift", 20)));
const double fade = std::clamp(paramDouble(values, "echo_fade", 0.8), 0.0, 1.0);
const Image input = canvasFromSources(sources, width, height, rng);
std::vector<Echo> echoes(static_cast<std::size_t>(count));
for (auto& echo : echoes) {
echo.dx = static_cast<double>(rng.inRange(-drift, drift));
echo.dy = static_cast<double>(rng.inRange(-drift, drift));
}
double sine = 0.0;
double cosine = 1.0;
sinCosDegrees(rotate, sine, cosine);
const double cx = (width - 1) * 0.5;
const double cy = (height - 1) * 0.5;
const int mix = static_cast<int>(fade * 256.0 + 0.5);
// A camera filming its own monitor: every pass lays the whole picture so
// far, shrunk (or grown), turned and drifted, over the untouched input, so
// the older echoes nest inside the newer ones and fade as they go.
Image frame = input;
Image next(width, height);
for (const Echo& echo : echoes) {
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t row = begin; row < end; ++row) {
const std::uint8_t* base = input.row(static_cast<int>(row));
std::uint8_t* out = next.row(static_cast<int>(row));
const double ry = static_cast<double>(row) - cy - echo.dy;
for (int col = 0; col < width; ++col) {
const std::size_t at = static_cast<std::size_t>(col) * 3;
const double rx = col - cx - echo.dx;
const double sx = cx + (rx * cosine + ry * sine) / scale;
const double sy = cy + (-rx * sine + ry * cosine) / scale;
if (sx < 0.0 || sy < 0.0 || sx > width - 1 || sy > height - 1) {
out[at] = base[at];
out[at + 1] = base[at + 1];
out[at + 2] = base[at + 2];
continue;
}
std::uint8_t echoed[3];
sampleBilinear(frame, sx, sy, echoed);
for (int c = 0; c < 3; ++c) {
const int under = base[at + static_cast<std::size_t>(c)];
out[at + static_cast<std::size_t>(c)] =
static_cast<std::uint8_t>(under + (echoed[c] - under) * mix / 256);
}
}
}
});
std::swap(frame, next);
}
return frame;
}
} // namespace effector
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Lays the image over itself again and again, each copy zoomed, turned and
// drifted a little further and fading as it goes, like a camera filming its
// own monitor.
// A pure transform: it needs no source images, and leading a chain with
// sources loaded it starts from a crop of a randomly chosen one.
class FeedbackEffect : public Effect {
public:
std::string name() const override { return "feedback"; }
std::string title() const override { return "Feedback"; }
std::string description() const override {
return "Lays the image over itself again and again, each copy zoomed, turned and drifted "
"further and fading, like a camera filming its own monitor.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+151
View File
@@ -0,0 +1,151 @@
#include "effects/pixelsort.hpp"
#include <algorithm>
#include <array>
#include <cstring>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& PixelSortEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeDouble("sort_low", "Lower threshold", 0.25, 0.0, 1.0,
"Pixels darker than this break a run and stay where they are."),
ParamSpec::makeDouble("sort_high", "Upper threshold", 0.8, 0.0, 1.0,
"Pixels brighter than this break a run and stay where they are."),
ParamSpec::makeChoice("sort_key", "Sort by", "luma", {"luma", "hue", "saturation"},
"What orders the pixels inside a run."),
ParamSpec::makeChoice("sort_direction", "Direction", "horizontal",
{"horizontal", "vertical"}, "Whether rows or columns are sorted."),
ParamSpec::makeBool("sort_reverse", "Reverse", false,
"Sort from high to low instead of from low to high."),
ParamSpec::makeDouble("sort_line_chance", "Line chance", 1.0, 0.0, 1.0,
"Chance of each row (or column) being sorted at all."),
ParamSpec::makeInt("sort_max_run", "Longest run", 0, 0, 8192,
"Each run is cut after a random length up to this many pixels, which "
"frays the edges of the sorted streaks (0 = no limit)."),
};
return specs;
}
namespace {
int luma(const std::uint8_t* px) { return (px[0] * 54 + px[1] * 183 + px[2] * 19) >> 8; }
// Hue on a 0..1535 scale (six sectors of 256), in integers.
int hue(const std::uint8_t* px) {
const int r = px[0], g = px[1], b = px[2];
const int hi = std::max({r, g, b});
const int lo = std::min({r, g, b});
const int range = hi - lo;
if (range == 0) return 0;
if (hi == r) return g >= b ? (g - b) * 255 / range : 1535 - (b - g) * 255 / range;
if (hi == g) return 512 + (b - r) * 255 / range;
return 1024 + (r - g) * 255 / range;
}
int saturation(const std::uint8_t* px) {
return std::max({px[0], px[1], px[2]}) - std::min({px[0], px[1], px[2]});
}
// The image with rows and columns swapped, so vertical sorting is horizontal
// sorting of the transpose.
Image transposed(const Image& src) {
Image out(src.height(), src.width());
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
for (std::size_t x = begin; x < end; ++x) {
std::uint8_t* row = out.row(static_cast<int>(x));
for (int y = 0; y < src.height(); ++y) {
std::memcpy(row + static_cast<std::size_t>(y) * 3, src.row(y) + x * 3, 3);
}
}
});
return out;
}
struct LinePlan {
bool sorted = false;
std::uint64_t seed = 0; // for the run lengths, when they are limited
};
} // namespace
Image PixelSortEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const int low = static_cast<int>(std::clamp(paramDouble(values, "sort_low", 0.25), 0.0, 1.0) * 255.0 + 0.5);
const int high = static_cast<int>(std::clamp(paramDouble(values, "sort_high", 0.8), 0.0, 1.0) * 255.0 + 0.5);
const std::string keyName = paramChoice(values, "sort_key", "luma");
const bool vertical = paramChoice(values, "sort_direction", "horizontal") == "vertical";
const bool reverse = paramBool(values, "sort_reverse", false);
const double lineChance = std::clamp(paramDouble(values, "sort_line_chance", 1.0), 0.0, 1.0);
const int maxRun = static_cast<int>(std::max(0L, paramInt(values, "sort_max_run", 0)));
Image canvas = canvasFromSources(sources, width, height, rng);
if (vertical) canvas = transposed(canvas);
const int lines = canvas.height();
const int span = canvas.width();
// Which lines are sorted, and a seed for each line's run lengths, are drawn
// serially; the lines then sort in parallel, each from its own generator.
std::vector<LinePlan> plan(static_cast<std::size_t>(lines));
for (auto& line : plan) {
line.sorted = chance(rng.next(), lineChance);
if (maxRun > 0) line.seed = rng.next();
}
auto keyOf = [&](const std::uint8_t* px) {
if (keyName == "hue") return hue(px);
if (keyName == "saturation") return saturation(px);
return luma(px);
};
parallelFor(static_cast<std::size_t>(lines), [&](std::size_t begin, std::size_t end) {
std::vector<std::pair<int, std::array<std::uint8_t, 3>>> run;
for (std::size_t line = begin; line < end; ++line) {
if (!plan[line].sorted) continue;
Rng runRng(plan[line].seed);
std::uint8_t* row = canvas.row(static_cast<int>(line));
auto flush = [&](int start) {
// A stable sort, so equal keys keep their order on every
// standard library.
std::stable_sort(run.begin(), run.end(), [&](const auto& a, const auto& b) {
return reverse ? a.first > b.first : a.first < b.first;
});
for (std::size_t i = 0; i < run.size(); ++i) {
std::memcpy(row + (static_cast<std::size_t>(start) + i) * 3, run[i].second.data(), 3);
}
run.clear();
};
int start = 0;
int limit = maxRun > 0 ? static_cast<int>(runRng.inRange(1, maxRun)) : span;
for (int x = 0; x < span; ++x) {
const std::uint8_t* px = row + static_cast<std::size_t>(x) * 3;
const int brightness = luma(px);
if (brightness < low || brightness > high) {
flush(start);
start = x + 1;
continue;
}
if (static_cast<int>(run.size()) >= limit) {
flush(start);
start = x;
if (maxRun > 0) limit = static_cast<int>(runRng.inRange(1, maxRun));
}
run.push_back({keyOf(px), {px[0], px[1], px[2]}});
}
flush(start);
}
});
return vertical ? transposed(canvas) : canvas;
}
} // namespace effector
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Finds runs of pixels whose brightness lies between two thresholds and sorts
// each run by brightness, hue or saturation, which makes the image drip.
// A pure transform: it needs no source images, and leading a chain with
// sources loaded it starts from a crop of a randomly chosen one.
class PixelSortEffect : public Effect {
public:
std::string name() const override { return "pixelsort"; }
std::string title() const override { return "Pixel sort"; }
std::string description() const override {
return "Sorts the runs of pixels whose brightness lies between two thresholds, along "
"rows or columns, so the image melts and drips.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include "core/image.hpp"
namespace effector {
// Reads the colour at a fractional position, mixing the four neighbouring
// pixels; positions off the image take the nearest edge pixel. The weights are
// cut to 1/256 steps so the mix itself is integer arithmetic.
inline void sampleBilinear(const Image& image, double x, double y, std::uint8_t out[3]) {
const int maxX = image.width() - 1;
const int maxY = image.height() - 1;
x = std::clamp(x, 0.0, static_cast<double>(maxX));
y = std::clamp(y, 0.0, static_cast<double>(maxY));
const int x0 = static_cast<int>(x);
const int y0 = static_cast<int>(y);
const int x1 = std::min(x0 + 1, maxX);
const int y1 = std::min(y0 + 1, maxY);
const int fx = static_cast<int>((x - x0) * 256.0);
const int fy = static_cast<int>((y - y0) * 256.0);
const std::uint8_t* a = image.row(y0) + static_cast<std::size_t>(x0) * 3;
const std::uint8_t* b = image.row(y0) + static_cast<std::size_t>(x1) * 3;
const std::uint8_t* c = image.row(y1) + static_cast<std::size_t>(x0) * 3;
const std::uint8_t* d = image.row(y1) + static_cast<std::size_t>(x1) * 3;
for (int ch = 0; ch < 3; ++ch) {
const int top = a[ch] * (256 - fx) + b[ch] * fx;
const int bottom = c[ch] * (256 - fx) + d[ch] * fx;
out[ch] = static_cast<std::uint8_t>((top * (256 - fy) + bottom * fy + 32768) >> 16);
}
}
// Sine and cosine of an angle in degrees from nothing but additions and
// multiplications, so a rotation lands on the same pixels whatever the
// machine's libm makes of std::sin. The angle is folded into one octant and a
// short Taylor series does the rest, far below a pixel's worth of error.
inline void sinCosDegrees(double degrees, double& sine, double& cosine) {
const double turns = degrees / 90.0;
const double quadrant = std::floor(turns + 0.5);
const double x = (turns - quadrant) * 1.5707963267948966; // [-pi/4, pi/4]
const double x2 = x * x;
const double s =
x * (1.0 - x2 / 6.0 * (1.0 - x2 / 20.0 * (1.0 - x2 / 42.0 * (1.0 - x2 / 72.0))));
const double c =
1.0 - x2 / 2.0 * (1.0 - x2 / 12.0 * (1.0 - x2 / 30.0 * (1.0 - x2 / 56.0 * (1.0 - x2 / 90.0))));
switch (static_cast<int>(quadrant - 4.0 * std::floor(quadrant / 4.0))) {
case 0: sine = s; cosine = c; break;
case 1: sine = c; cosine = -s; break;
case 2: sine = -s; cosine = -c; break;
default: sine = -c; cosine = s; break;
}
}
// A uniform double in [0, 1) from the top 53 bits of one rng draw.
inline double unitDouble(std::uint64_t bits) {
return static_cast<double>(bits >> 11) * (1.0 / 9007199254740992.0);
}
// Whether an event with the given probability happens, from one rng draw.
inline bool chance(std::uint64_t bits, double probability) {
return unitDouble(bits) < probability;
}
} // namespace effector
+178
View File
@@ -0,0 +1,178 @@
#include "effects/shatter.hpp"
#include <algorithm>
#include <cmath>
#include <limits>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& ShatterEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("shatter_cells", "Shards", 40, 2, 5000,
"How many shards the image breaks into."),
ParamSpec::makeInt("shatter_displace", "Displacement", 40, 0, 4096,
"Furthest in pixels a shard's picture may slide, along either axis."),
ParamSpec::makeDouble("shatter_rotate", "Rotation", 8.0, 0.0, 180.0,
"Largest turn in degrees of a shard's picture, either way.", 0.5, 1),
ParamSpec::makeDouble("shatter_zoom", "Zoom", 0.05, 0.0, 0.9,
"Largest zoom in or out of a shard's picture, as a fraction.", 0.01),
ParamSpec::makeInt("shatter_crack_width", "Crack width", 2, 0, 64,
"Width in pixels of the cracks between the shards (0 = none)."),
ParamSpec::makeColor("shatter_crack_colour", "Crack colour", "#101010",
"Colour the cracks are drawn in."),
};
return specs;
}
namespace {
struct Shard {
double x = 0.0; // the point the shard grows around
double y = 0.0;
double dx = 0.0;
double dy = 0.0;
double sine = 0.0;
double cosine = 1.0;
double zoom = 1.0;
};
// The shard points bucketed on a coarse grid, so finding the two nearest to a
// pixel looks at a few buckets instead of every point.
class ShardGrid {
public:
ShardGrid(const std::vector<Shard>& shards, int width, int height) : shards_(shards) {
cell_ = std::max(1.0, std::sqrt(static_cast<double>(width) * height /
static_cast<double>(shards.size())));
cols_ = static_cast<int>(width / cell_) + 1;
rows_ = static_cast<int>(height / cell_) + 1;
buckets_.resize(static_cast<std::size_t>(cols_) * rows_);
for (std::size_t i = 0; i < shards.size(); ++i) {
buckets_[bucketOf(shards[i].x, shards[i].y)].push_back(static_cast<int>(i));
}
}
// The nearest and second nearest shard to (x, y).
void nearest(double x, double y, int& first, int& second) const {
const int bx = std::clamp(static_cast<int>(x / cell_), 0, cols_ - 1);
const int by = std::clamp(static_cast<int>(y / cell_), 0, rows_ - 1);
double best = std::numeric_limits<double>::max();
double next = best;
first = second = -1;
const int maxRing = std::max(cols_, rows_);
for (int ring = 0; ring <= maxRing; ++ring) {
// Anything in this ring is at least (ring - 1) cells away, so once
// both answers are closer than that the search is over.
const double reach = std::max(0, ring - 1) * cell_;
if (second >= 0 && next < reach * reach) break;
for (int gy = by - ring; gy <= by + ring; ++gy) {
if (gy < 0 || gy >= rows_) continue;
const bool edgeRow = gy == by - ring || gy == by + ring;
for (int gx = bx - ring; gx <= bx + ring; gx += edgeRow ? 1 : 2 * std::max(ring, 1)) {
if (gx < 0 || gx >= cols_) continue;
for (int index : buckets_[static_cast<std::size_t>(gy) * cols_ + gx]) {
const Shard& shard = shards_[static_cast<std::size_t>(index)];
const double d = (shard.x - x) * (shard.x - x) + (shard.y - y) * (shard.y - y);
if (d < best || (d == best && index < first)) {
next = best;
second = first;
best = d;
first = index;
} else if (d < next || (d == next && index < second)) {
next = d;
second = index;
}
}
if (ring == 0) break;
}
}
}
}
private:
std::size_t bucketOf(double x, double y) const {
const int bx = std::clamp(static_cast<int>(x / cell_), 0, cols_ - 1);
const int by = std::clamp(static_cast<int>(y / cell_), 0, rows_ - 1);
return static_cast<std::size_t>(by) * cols_ + bx;
}
const std::vector<Shard>& shards_;
double cell_ = 1.0;
int cols_ = 1;
int rows_ = 1;
std::vector<std::vector<int>> buckets_;
};
} // namespace
Image ShatterEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const long count = std::max(2L, paramInt(values, "shatter_cells", 40));
const int displace = static_cast<int>(std::max(0L, paramInt(values, "shatter_displace", 40)));
const double rotate = std::clamp(paramDouble(values, "shatter_rotate", 8.0), 0.0, 180.0);
const double zoom = std::clamp(paramDouble(values, "shatter_zoom", 0.05), 0.0, 0.9);
const double crackWidth = static_cast<double>(std::max(0L, paramInt(values, "shatter_crack_width", 2)));
std::uint8_t crack[3];
paramColor(values, "shatter_crack_colour", "#101010", crack);
const Image input = canvasFromSources(sources, width, height, rng);
std::vector<Shard> shards(static_cast<std::size_t>(count));
for (auto& shard : shards) {
shard.x = unitDouble(rng.next()) * width;
shard.y = unitDouble(rng.next()) * height;
shard.dx = static_cast<double>(rng.inRange(-displace, displace));
shard.dy = static_cast<double>(rng.inRange(-displace, displace));
sinCosDegrees((unitDouble(rng.next()) * 2.0 - 1.0) * rotate, shard.sine, shard.cosine);
shard.zoom = 1.0 + (unitDouble(rng.next()) * 2.0 - 1.0) * zoom;
}
const ShardGrid grid(shards, width, height);
Image canvas(width, height);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t row = begin; row < end; ++row) {
std::uint8_t* out = canvas.row(static_cast<int>(row));
const double y = static_cast<double>(row) + 0.5;
for (int col = 0; col < width; ++col) {
std::uint8_t* px = out + static_cast<std::size_t>(col) * 3;
const double x = col + 0.5;
int first = 0;
int second = 0;
grid.nearest(x, y, first, second);
const Shard& a = shards[static_cast<std::size_t>(first)];
if (crackWidth > 0.0 && second >= 0) {
// Distance to the edge between the two nearest shards,
// which is the perpendicular bisector of their points.
const Shard& b = shards[static_cast<std::size_t>(second)];
const double da = (x - a.x) * (x - a.x) + (y - a.y) * (y - a.y);
const double db = (x - b.x) * (x - b.x) + (y - b.y) * (y - b.y);
const double gap = std::sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));
if (gap > 0.0 && (db - da) / (2.0 * gap) < crackWidth * 0.5) {
px[0] = crack[0];
px[1] = crack[1];
px[2] = crack[2];
continue;
}
}
// Undo the shard's slide, then its turn and zoom about its point.
const double rx = x - a.dx - a.x;
const double ry = y - a.dy - a.y;
const double sx = a.x + (rx * a.cosine + ry * a.sine) / a.zoom;
const double sy = a.y + (-rx * a.sine + ry * a.cosine) / a.zoom;
sampleBilinear(input, sx - 0.5, sy - 0.5, px);
}
}
});
return canvas;
}
} // namespace effector
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Breaks its input into random glass shards (the cells around scattered points),
// each showing the image shifted, turned and zoomed a little on its own, with
// optional cracks drawn along the edges.
// A pure transform: it needs no source images, and leading a chain with
// sources loaded it starts from a crop of a randomly chosen one.
class ShatterEffect : public Effect {
public:
std::string name() const override { return "shatter"; }
std::string title() const override { return "Shatter"; }
std::string description() const override {
return "Breaks the image into random shards, each shifted, turned and zoomed on its own, "
"with optional cracks between them.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+99
View File
@@ -0,0 +1,99 @@
#include "effects/slitshift.hpp"
#include <algorithm>
#include <cstring>
#include <vector>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& SlitShiftEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("shift_bands", "Bands", 24, 1, 4096,
"How many bands are torn out of place."),
ParamSpec::makeInt("shift_max", "Largest shift", 120, 0, 8192,
"Furthest in pixels a band may be pushed, either way."),
ParamSpec::makeInt("shift_band_height", "Band height", 16, 1, 2048,
"Thickness in pixels of each band."),
ParamSpec::makeInt("shift_height_jitter", "Height jitter", 12, 0, 2048,
"Random +/- variation added to the band thickness (0 = uniform)."),
ParamSpec::makeChoice("shift_edge", "Edges", "wrap", {"wrap", "smear", "black"},
"What fills the gap a band leaves: wrap brings in what was pushed "
"off the other side, smear stretches the last pixel, black leaves "
"it empty."),
ParamSpec::makeChoice("shift_axis", "Axis", "horizontal", {"horizontal", "vertical"},
"horizontal tears bands of rows sideways, vertical tears bands of "
"columns up and down."),
};
return specs;
}
namespace {
// Where the pixel at `index` of a line of `count` is read from once the line is
// pushed by `offset`, or -1 for an empty (black) pixel.
int shiftedIndex(int index, int offset, int count, char edge) {
const int from = index - offset;
if (from >= 0 && from < count) return from;
if (edge == 'w') return ((from % count) + count) % count;
if (edge == 's') return std::clamp(from, 0, count - 1);
return -1;
}
} // namespace
Image SlitShiftEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const long bands = std::max(1L, paramInt(values, "shift_bands", 24));
const int maxShift = static_cast<int>(std::max(0L, paramInt(values, "shift_max", 120)));
const int bandHeight = static_cast<int>(std::max(1L, paramInt(values, "shift_band_height", 16)));
const int jitter = static_cast<int>(std::max(0L, paramInt(values, "shift_height_jitter", 12)));
const char edge = paramChoice(values, "shift_edge", "wrap").front();
const bool vertical = paramChoice(values, "shift_axis", "horizontal") == "vertical";
const Image input = canvasFromSources(sources, width, height, rng);
// Every band is drawn up front, and overlapping bands simply add their
// pushes, so what the pixels need is one offset per line.
const int lines = vertical ? width : height;
const int span = vertical ? height : width;
std::vector<int> offsets(static_cast<std::size_t>(lines), 0);
for (long band = 0; band < bands; ++band) {
int thickness = bandHeight;
if (jitter > 0) thickness += static_cast<int>(rng.inRange(-jitter, jitter));
thickness = std::clamp(thickness, 1, lines);
const int start = static_cast<int>(rng.inRange(0, lines - thickness));
const int offset = static_cast<int>(rng.inRange(-maxShift, maxShift));
for (int line = start; line < start + thickness; ++line) {
offsets[static_cast<std::size_t>(line)] += offset;
}
}
Image canvas(width, height);
parallelFor(static_cast<std::size_t>(lines), [&](std::size_t begin, std::size_t end) {
for (std::size_t line = begin; line < end; ++line) {
const int offset = offsets[line];
for (int i = 0; i < span; ++i) {
const int from = shiftedIndex(i, offset, span, edge);
const int x = vertical ? static_cast<int>(line) : i;
const int y = vertical ? i : static_cast<int>(line);
std::uint8_t* out = canvas.row(y) + static_cast<std::size_t>(x) * 3;
if (from < 0) {
out[0] = out[1] = out[2] = 0;
continue;
}
const int fx = vertical ? x : from;
const int fy = vertical ? from : y;
std::memcpy(out, input.row(fy) + static_cast<std::size_t>(fx) * 3, 3);
}
}
});
return canvas;
}
} // namespace effector
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Tears its input apart: random bands of rows (or columns) are pushed sideways by a
// random distance each, the way a video signal loses its sync.
// A pure transform: it needs no source images, and leading a chain with
// sources loaded it starts from a crop of a randomly chosen one.
class SlitShiftEffect : public Effect {
public:
std::string name() const override { return "slitshift"; }
std::string title() const override { return "Slit shift"; }
std::string description() const override {
return "Tears random bands of its input sideways, like a video signal losing sync; "
"overlapping bands add their shifts up.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+18
View File
@@ -2,9 +2,11 @@
#include <algorithm> #include <algorithm>
#include <cstring> #include <cstring>
#include <vector>
#include "core/image.hpp" #include "core/image.hpp"
#include "core/parallel.hpp" #include "core/parallel.hpp"
#include "core/rng.hpp"
namespace effector { namespace effector {
@@ -35,4 +37,20 @@ inline void fillFromSource(Image& canvas, const Image& src, int sx, int sy) {
}); });
} }
// The canvas a pure transform starts from: a crop of a randomly chosen source,
// or black when there is none. Inside a chain the only source is the stage
// before, which has the canvas size, so no random value is drawn at all; only a
// transform leading a chain with photographs loaded spends any of the rng here.
inline Image canvasFromSources(const std::vector<Image>& sources, int width, int height, Rng& rng) {
Image canvas(width, height);
if (!sources.empty()) {
const std::size_t index = rng.below(sources.size() - 1);
const Image& src = sources[index];
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
fillFromSource(canvas, src, sx, sy);
}
return canvas;
}
} // namespace effector } // namespace effector
+33
View File
@@ -5,6 +5,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <numbers>
#include "core/parallel.hpp" #include "core/parallel.hpp"
#include "effects/source_fill.hpp" #include "effects/source_fill.hpp"
@@ -57,6 +58,10 @@ const std::vector<ParamSpec>& TextEffect::params() const {
"Moves the text right by this many pixels (left when negative)."), "Moves the text right by this many pixels (left when negative)."),
ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000, ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000,
"Moves the text down by this many pixels (up when negative)."), "Moves the text down by this many pixels (up when negative)."),
ParamSpec::makeDouble("text_rotation", "Rotation", 0.0, -360.0, 360.0,
"Turns the text and its box about their centre by this many "
"degrees, clockwise (anticlockwise when negative).",
1.0, 1),
ParamSpec::makeDouble("text_opacity", "Text opacity", 1.0, 0.0, 1.0, ParamSpec::makeDouble("text_opacity", "Text opacity", 1.0, 0.0, 1.0,
"How strongly the glyphs are mixed over what is behind them.") "How strongly the glyphs are mixed over what is behind them.")
.styled(), .styled(),
@@ -198,6 +203,22 @@ void TextEffect::loadBoxImage(const std::string& path, Image& image,
alpha = cachedAlpha_; alpha = cachedAlpha_;
} }
std::string TextEffect::label(const ParamValues& values) const {
// Several lines read as one: each run of whitespace becomes a single space.
std::string words;
bool gap = false;
for (const char c : paramText(values, "text", "")) {
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
gap = !words.empty();
continue;
}
if (gap) words += ' ';
gap = false;
words += c;
}
return words.empty() ? title() : title() + " (" + words + ")";
}
Image TextEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng, Image TextEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const { const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
@@ -264,6 +285,18 @@ Image TextEffect::render(const std::vector<Image>& sources, int width, int heigh
const double boxY = (centre ? (height - boxHeight) / 2.0 : 0.0) + const double boxY = (centre ? (height - boxHeight) / 2.0 : 0.0) +
static_cast<double>(paramInt(values, "text_offset_y", 0)); static_cast<double>(paramInt(values, "text_offset_y", 0));
// The turn is about the middle of the box, so it never moves the text away
// from where the centring and the offset put it. Cairo's y points down,
// which makes a positive angle clockwise on screen.
const double rotation = paramDouble(values, "text_rotation", 0.0);
if (rotation != 0.0) {
const double cx = boxX + boxWidth / 2.0;
const double cy = boxY + boxHeight / 2.0;
cairo_translate(cr.get, cx, cy);
cairo_rotate(cr.get, rotation * std::numbers::pi / 180.0);
cairo_translate(cr.get, -cx, -cy);
}
if (drawsBox) { if (drawsBox) {
cairo_save(cr.get); cairo_save(cr.get);
cairo_rectangle(cr.get, boxX, boxY, boxWidth, boxHeight); cairo_rectangle(cr.get, boxX, boxY, boxWidth, boxHeight);
+2
View File
@@ -30,6 +30,8 @@ public:
const std::vector<ParamSpec>& params() const override; const std::vector<ParamSpec>& params() const override;
std::string styleKind() const override { return "text"; } std::string styleKind() const override { return "text"; }
bool needsSources() const override { return false; } bool needsSources() const override { return false; }
// "Text (Hello world)", so stages writing different words tell apart.
std::string label(const ParamValues& values) const override;
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng, Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override; const ParamValues& values) const override;
+159
View File
@@ -0,0 +1,159 @@
#include "effects/warp.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
#include "core/parallel.hpp"
#include "effects/sampling.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& WarpEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("warp_strength", "Strength", 40, 0, 4096,
"How far in pixels the field pushes the image at its strongest."),
ParamSpec::makeInt("warp_scale", "Scale", 160, 4, 8192,
"Size in pixels of the largest features in the field."),
ParamSpec::makeInt("warp_octaves", "Octaves", 3, 1, 6,
"Finer layers of noise laid over the first; more give a turbulent "
"edge to the flow."),
ParamSpec::makeChoice("warp_mode", "Mode", "displace", {"displace", "swirl"},
"displace pushes along the field; swirl flows along its contour "
"lines, which curls the image into eddies."),
};
return specs;
}
namespace {
// One layer of value noise: random heights on a lattice `spacing` pixels
// apart, blended smoothly between the lattice points.
class NoiseLayer {
public:
NoiseLayer(Rng& rng, int width, int height, double spacing)
: spacing_(spacing),
cols_(static_cast<int>(width / spacing) + 2),
rows_(static_cast<int>(height / spacing) + 2),
lattice_(static_cast<std::size_t>(cols_) * rows_) {
for (auto& value : lattice_) value = unitDouble(rng.next()) * 2.0 - 1.0;
}
// The height at (x, y) in [-1, 1], and its slope per pixel along x and y.
void sample(double x, double y, double& value, double& slopeX, double& slopeY) const {
const double gx = x / spacing_;
const double gy = y / spacing_;
const int ix = std::min(static_cast<int>(gx), cols_ - 2);
const int iy = std::min(static_cast<int>(gy), rows_ - 2);
const double fx = gx - ix;
const double fy = gy - iy;
const double sx = fx * fx * (3.0 - 2.0 * fx);
const double sy = fy * fy * (3.0 - 2.0 * fy);
const double a = at(ix, iy);
const double b = at(ix + 1, iy);
const double c = at(ix, iy + 1);
const double d = at(ix + 1, iy + 1);
const double cross = a - b - c + d;
value = a + (b - a) * sx + (c - a) * sy + cross * sx * sy;
slopeX = 6.0 * fx * (1.0 - fx) * ((b - a) + cross * sy) / spacing_;
slopeY = 6.0 * fy * (1.0 - fy) * ((c - a) + cross * sx) / spacing_;
}
private:
double at(int x, int y) const { return lattice_[static_cast<std::size_t>(y) * cols_ + x]; }
double spacing_;
int cols_;
int rows_;
std::vector<double> lattice_;
};
// Octaves of noise summed, each twice as fine and half as strong as the one
// before, and scaled back so the total stays in [-1, 1].
class NoiseField {
public:
NoiseField(Rng& rng, int width, int height, double scale, int octaves) {
double spacing = scale;
double weight = 1.0;
for (int i = 0; i < octaves; ++i) {
layers_.emplace_back(rng, width, height, std::max(1.0, spacing));
weights_.push_back(weight);
total_ += weight;
spacing *= 0.5;
weight *= 0.5;
}
}
void sample(double x, double y, double& value, double& slopeX, double& slopeY) const {
value = slopeX = slopeY = 0.0;
for (std::size_t i = 0; i < layers_.size(); ++i) {
double v = 0.0, sx = 0.0, sy = 0.0;
layers_[i].sample(x, y, v, sx, sy);
value += v * weights_[i];
slopeX += sx * weights_[i];
slopeY += sy * weights_[i];
}
value /= total_;
slopeX /= total_;
slopeY /= total_;
}
private:
std::vector<NoiseLayer> layers_;
std::vector<double> weights_;
double total_ = 0.0;
};
} // namespace
Image WarpEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const double strength = static_cast<double>(std::max(0L, paramInt(values, "warp_strength", 40)));
const double scale = static_cast<double>(std::max(4L, paramInt(values, "warp_scale", 160)));
const int octaves = static_cast<int>(std::clamp(paramInt(values, "warp_octaves", 3), 1L, 6L));
const bool swirl = paramChoice(values, "warp_mode", "displace") == "swirl";
const Image input = canvasFromSources(sources, width, height, rng);
// Lattices are drawn in full before any pixel moves: one field for the
// swirl, which flows along its contours, two for a push along x and y.
const NoiseField first(rng, width, height, scale, octaves);
std::vector<NoiseField> extra;
if (!swirl) extra.emplace_back(rng, width, height, scale, octaves);
Image canvas(width, height);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t row = begin; row < end; ++row) {
std::uint8_t* out = canvas.row(static_cast<int>(row));
const double y = static_cast<double>(row);
for (int col = 0; col < width; ++col) {
const double x = col;
double value = 0.0, slopeX = 0.0, slopeY = 0.0;
first.sample(x, y, value, slopeX, slopeY);
double dx = 0.0;
double dy = 0.0;
if (swirl) {
// Along the contour: the slope turned a quarter, which
// never piles pixels up or tears them apart. The slope of
// a field `scale` wide is about 1/scale, hence the factor.
dx = slopeY * scale * strength * 0.5;
dy = -slopeX * scale * strength * 0.5;
} else {
double other = 0.0;
extra.front().sample(x, y, other, slopeX, slopeY);
dx = value * strength;
dy = other * strength;
}
sampleBilinear(input, x + dx, y + dy, out + static_cast<std::size_t>(col) * 3);
}
}
});
return canvas;
}
} // namespace effector
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Pushes every pixel along a smooth random noise field: a haze at low strength,
// melted paint at high. The swirl mode follows the field's contour lines,
// which turns the image into eddies instead of ripples.
// A pure transform: it needs no source images, and leading a chain with
// sources loaded it starts from a crop of a randomly chosen one.
class WarpEffect : public Effect {
public:
std::string name() const override { return "warp"; }
std::string title() const override { return "Warp"; }
std::string description() const override {
return "Pushes every pixel along a smooth random noise field, from a gentle haze to "
"molten eddies.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+314 -39
View File
@@ -43,6 +43,40 @@ Gtk::Label* sectionLabel(const char* text) {
return label; 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
// 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) {
auto scroll = Gtk::EventControllerScroll::create();
scroll->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
scroll->set_flags(Gtk::EventControllerScroll::Flags::VERTICAL);
auto* controller = scroll.get();
scroll->signal_scroll().connect(
[&spin, controller](double, double dy) {
if ((spin.get_state_flags() & Gtk::StateFlags::FOCUS_WITHIN) == Gtk::StateFlags::FOCUS_WITHIN) {
return false;
}
auto* scroller = spin.get_ancestor(GTK_TYPE_SCROLLED_WINDOW);
if (scroller == nullptr) return true;
auto adjustment = static_cast<Gtk::ScrolledWindow*>(scroller)->get_vadjustment();
// The step a ScrolledWindow itself takes for one notch of a wheel;
// a touchpad already reports its deltas in pixels.
double step = dy;
if (controller->get_unit() == Gdk::ScrollUnit::WHEEL) {
step *= std::pow(adjustment->get_page_size(), 2.0 / 3.0);
}
adjustment->set_value(std::clamp(adjustment->get_value() + step, adjustment->get_lower(),
adjustment->get_upper() - adjustment->get_page_size()));
return true;
},
false);
spin.add_controller(scroll);
}
} // namespace } // namespace
EffectorWindow::EffectorWindow(const Options& initial) { EffectorWindow::EffectorWindow(const Options& initial) {
@@ -73,6 +107,7 @@ EffectorWindow::EffectorWindow(const Options& initial) {
EffectorWindow::~EffectorWindow() { EffectorWindow::~EffectorWindow() {
debounce_.disconnect(); debounce_.disconnect();
chainSplitFit_.disconnect();
loadWindowDelay_.disconnect(); loadWindowDelay_.disconnect();
loadCancel_.store(true, std::memory_order_relaxed); loadCancel_.store(true, std::memory_order_relaxed);
if (loader_.joinable()) loader_.join(); if (loader_.joinable()) loader_.join();
@@ -118,29 +153,137 @@ void EffectorWindow::buildUi() {
root->append(*paned); root->append(*paned);
} }
// The chain is what the interface is about, so it comes first and the params // The sidebar is two tabs. "Chain" is what the interface is about: the stages
// of the selected stage sit right below it. The sources are a prerequisite, // and the params of the selected one, with the whole height to themselves.
// not the subject: they live at the bottom in a collapsed expander. // "Setup" holds what is set once per project -- the sources, the canvas and
// the seed -- so it never takes room from the chain however long that grows.
Gtk::Widget& EffectorWindow::buildSidebar() { Gtk::Widget& EffectorWindow::buildSidebar() {
auto* stack = Gtk::make_managed<Gtk::Stack>();
stack->set_vexpand(true);
stack->set_transition_type(Gtk::StackTransitionType::CROSSFADE);
stack->add(buildChainTab(), "chain", "Chain");
stack->add(buildSetupTab(), "setup", "Setup");
auto* switcher = Gtk::make_managed<Gtk::StackSwitcher>();
switcher->set_stack(*stack);
switcher->set_halign(Gtk::Align::CENTER);
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12); auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sidebar->set_margin(12); sidebar->set_margin(12);
sidebar->append(*switcher);
sidebar->append(*stack);
return *sidebar;
}
sidebar->append(buildChainSection()); // The chain and the params of the selected stage share the tab through a
// split. Until the user drags it, the split rests under the chain's rows
// (fitChainSplit), so a short chain leaves the tab to the params and a long
// one grows up to chainListMaxHeight and then scrolls; once dragged, it stays
// where it was put.
// The tallest the chain list grows on its own before it scrolls, in pixels:
// room for eight stages.
constexpr int chainListMaxHeight = 270;
Gtk::Widget& EffectorWindow::buildChainTab() {
auto* params = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
params->set_margin_top(12);
paramHeading_.set_xalign(0.0F); paramHeading_.set_xalign(0.0F);
paramHeading_.add_css_class("heading"); paramHeading_.add_css_class("heading");
sidebar->append(paramHeading_); params->append(paramHeading_);
paramGrid_.set_column_spacing(8); paramGrid_.set_column_spacing(8);
paramGrid_.set_row_spacing(6); paramGrid_.set_row_spacing(6);
auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>(); auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>();
paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
paramScroller->set_child(paramGrid_); paramScroller->set_child(paramGrid_);
paramScroller->set_vexpand(true); paramScroller->set_vexpand(true);
sidebar->append(*paramScroller); params->append(*paramScroller);
auto& chain = buildChainSection();
chain.set_margin_bottom(6);
auto* split = Gtk::make_managed<Gtk::Paned>(Gtk::Orientation::VERTICAL);
split->set_start_child(chain);
split->set_end_child(*params);
split->set_resize_start_child(false);
split->set_resize_end_child(true);
split->set_shrink_start_child(false);
split->set_shrink_end_child(false);
chainSplit_ = split;
split->property_position().signal_changed().connect([this] { onChainSplitMoved(); });
// The room the split has changes with the window, and a fit it had to
// clamp may fit whole now.
split->property_max_position().signal_changed().connect([this] { scheduleChainSplitFit(); });
// A double-click on the handle undoes the drag: the split fits the rows
// again, and follows them from then on. The handle is the one child of the
// split that is neither pane, but the click is heard on the split itself,
// grouped with the split's own drag: that drag claims every press on the
// handle, which would cancel a click gesture anywhere else.
Gtk::Widget* handle = nullptr;
for (auto* child = split->get_first_child(); child != nullptr; child = child->get_next_sibling()) {
if (child != &chain && child != params) handle = child;
}
if (handle != nullptr) {
handle->set_tooltip_text("Drag to share the room, double-click to fit the chain");
auto click = Gtk::GestureClick::create();
click->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
// The split's drag ends every press on the handle, which starts the
// click's count over, so the second press is counted here. And that
// drag is still going on during the second press -- the least jitter
// puts the handle back under the pointer -- so the fit waits for it
// to end.
std::vector<Glib::RefPtr<Gtk::GestureDrag>> drags;
const auto controllers = split->observe_controllers();
for (guint i = 0; i < controllers->get_n_items(); ++i) {
auto other = std::dynamic_pointer_cast<Gtk::Gesture>(controllers->get_object(i));
if (other) click->group(other);
if (auto drag = std::dynamic_pointer_cast<Gtk::GestureDrag>(other)) drags.push_back(drag);
}
auto lastPress = std::make_shared<guint32>(0);
auto refitPending = std::make_shared<bool>(false);
for (const auto& drag : drags) {
drag->signal_drag_end().connect([this, refitPending](double, double) {
if (!*refitPending) return;
*refitPending = false;
refitChainSplit();
});
}
auto* gesture = click.get(); // not the RefPtr: the gesture owns this slot
const bool waitForDrag = !drags.empty();
click->signal_pressed().connect(
[this, split, handle, gesture, lastPress, refitPending, waitForDrag](int, double x, double y) {
// The handle is thin, and the drag takes presses a little past it.
const auto bounds = handle->compute_bounds(*split);
constexpr double slack = 6.0;
if (!bounds || y < bounds->get_y() - slack ||
y > bounds->get_y() + bounds->get_height() + slack ||
x < bounds->get_x() || x > bounds->get_x() + bounds->get_width()) {
*lastPress = 0;
return;
}
const guint32 now = gesture->get_current_event_time();
const int doubleClick = Gtk::Settings::get_default()->property_gtk_double_click_time();
if (*lastPress != 0 && now - *lastPress <= static_cast<guint32>(doubleClick)) {
*lastPress = 0;
if (waitForDrag) {
*refitPending = true;
} else {
refitChainSplit();
}
} else {
*lastPress = now;
}
});
split->add_controller(click);
}
rebuildChainRows(); rebuildChainRows();
buildParamWidgets(); buildParamWidgets();
return *split;
}
sidebar->append(*Gtk::make_managed<Gtk::Separator>(Gtk::Orientation::HORIZONTAL)); // The sources come first because they are the first thing a project needs;
// their list takes whatever height the canvas and seed leave over.
Gtk::Widget& EffectorWindow::buildSetupTab() {
auto* tab = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sourceList_.set_selection_mode(Gtk::SelectionMode::NONE); sourceList_.set_selection_mode(Gtk::SelectionMode::NONE);
sourceList_.add_css_class("boxed-list"); sourceList_.add_css_class("boxed-list");
@@ -153,10 +296,16 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(sourceList_); scroller->set_child(sourceList_);
scroller->set_size_request(-1, 140); scroller->set_size_request(-1, 140);
scroller->set_margin_top(6); scroller->set_vexpand(true);
auto* sourcesBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6); sourcesHeading_.set_xalign(0.0F);
sourcesBox->append(*scroller); sourcesHeading_.add_css_class("heading");
sourcesSection_.set_name("sources");
sourcesSection_.set_orientation(Gtk::Orientation::VERTICAL);
sourcesSection_.set_spacing(6);
sourcesSection_.set_vexpand(true);
sourcesSection_.append(sourcesHeading_);
sourcesSection_.append(*scroller);
// Same shape as "Add effect": the icon and the text are put together by // Same shape as "Add effect": the icon and the text are put together by
// hand because set_label() and set_icon_name() replace one another. // hand because set_label() and set_icon_name() replace one another.
@@ -169,16 +318,14 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
addButton->set_halign(Gtk::Align::START); addButton->set_halign(Gtk::Align::START);
addButton->set_tooltip_text("Add source images"); addButton->set_tooltip_text("Add source images");
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages)); addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
sourcesBox->append(*addButton); sourcesSection_.append(*addButton);
addButton_ = addButton; addButton_ = addButton;
sourcesExpander_.set_child(*sourcesBox);
sourcesExpander_.set_expanded(false);
updateSourcesLabel(); updateSourcesLabel();
sidebar->append(sourcesExpander_); tab->append(sourcesSection_);
sidebar->append(*sectionLabel("Canvas")); tab->append(*sectionLabel("Canvas"));
sidebar->append(buildCanvasSection()); tab->append(buildCanvasSection());
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8); auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed"); auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
@@ -188,9 +335,9 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
seedEntry_.set_text(std::to_string(seed_)); seedEntry_.set_text(std::to_string(seed_));
seedEntry_.signal_activate().connect(sigc::mem_fun(*this, &EffectorWindow::onSeedEntered)); seedEntry_.signal_activate().connect(sigc::mem_fun(*this, &EffectorWindow::onSeedEntered));
seedBox->append(seedEntry_); seedBox->append(seedEntry_);
sidebar->append(*seedBox); tab->append(*seedBox);
return *sidebar; return *tab;
} }
// Only the first stage is ever handed the sources, so whether the chain wants // Only the first stage is ever handed the sources, so whether the chain wants
@@ -199,15 +346,14 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
// rather than left there to be filled in vain. This is the same declarative // rather than left there to be filled in vain. This is the same declarative
// answer the render gate uses -- no effect is named here. // answer the render gate uses -- no effect is named here.
void EffectorWindow::updateSourcesVisible() { void EffectorWindow::updateSourcesVisible() {
sourcesExpander_.set_visible(chainNeedsSources(stages_)); sourcesSection_.set_visible(chainNeedsSources(stages_));
} }
// The expander is collapsed by default, so its label carries the count that // The heading carries the count, which a long list scrolled away would hide.
// used to be plain to see in the list.
void EffectorWindow::updateSourcesLabel() { void EffectorWindow::updateSourcesLabel() {
int count = 0; int count = 0;
while (sourceList_.get_row_at_index(count) != nullptr) ++count; while (sourceList_.get_row_at_index(count) != nullptr) ++count;
sourcesExpander_.set_label(count == 0 ? "Source images" sourcesHeading_.set_text(count == 0 ? "Source images"
: "Source images (" + std::to_string(count) + ")"); : "Source images (" + std::to_string(count) + ")");
} }
@@ -244,6 +390,7 @@ Gtk::Widget& EffectorWindow::buildCanvasSection() {
dpiSpin_.set_value(kDefaultDpi); dpiSpin_.set_value(kDefaultDpi);
for (auto* spin : {&widthSpin_, &heightSpin_, &dpiSpin_}) { for (auto* spin : {&widthSpin_, &heightSpin_, &dpiSpin_}) {
spin->set_hexpand(true); spin->set_hexpand(true);
scrollPastUnlessFocused(*spin);
} }
for (auto* spin : {&widthSpin_, &heightSpin_}) { for (auto* spin : {&widthSpin_, &heightSpin_}) {
spin->signal_value_changed().connect([this] { spin->signal_value_changed().connect([this] {
@@ -331,7 +478,7 @@ Gtk::Widget& EffectorWindow::buildPreview() {
picture_.set_can_shrink(true); picture_.set_can_shrink(true);
picture_.set_content_fit(Gtk::ContentFit::CONTAIN); picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
placeholder_.set_text("Add source images to start."); placeholder_.set_text("Add source images under Setup to start.");
placeholder_.set_wrap(true); placeholder_.set_wrap(true);
placeholder_.add_css_class("dim-label"); placeholder_.add_css_class("dim-label");
@@ -350,6 +497,12 @@ Gtk::Widget& EffectorWindow::buildPreview() {
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL); auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL);
box->append(*overlay); box->append(*overlay);
// A long chain makes a long status line; left to its natural width it
// would widen the preview column past the pane and push the picture off
// centre. Ellipsized, it never asks for more room than it is given.
status_.set_ellipsize(Pango::EllipsizeMode::END);
status_.set_margin_start(8);
status_.set_margin_end(8);
status_.set_margin_top(6); status_.set_margin_top(6);
status_.set_margin_bottom(8); status_.set_margin_bottom(8);
status_.add_css_class("dim-label"); status_.add_css_class("dim-label");
@@ -434,6 +587,7 @@ void EffectorWindow::buildParamWidgets() {
: paramDouble(values, spec.key, 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);
widget = std::move(spin); widget = std::move(spin);
break; break;
} }
@@ -1129,10 +1283,44 @@ void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result
// ---------------------------------------------------------------- chain --- // ---------------------------------------------------------------- chain ---
Gtk::Widget& EffectorWindow::buildChainSection() { // The move and remove buttons are faded out until the pointer is over their
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6); // row, or the row is selected or focused, so a long chain reads as a list of
box->append(*sectionLabel("Effect chain")); // names rather than a column of icons. They keep their place while hidden,
// so the names do not jump as the pointer moves.
const char* const stageRowCss = R"css(
row.stage-row .stage-action {
min-width: 24px;
min-height: 24px;
padding: 0;
opacity: 0;
transition: opacity 120ms;
}
row.stage-row:hover .stage-action,
row.stage-row:selected .stage-action,
row.stage-row:focus-within .stage-action {
opacity: 1;
}
)css";
Gtk::Widget& EffectorWindow::buildChainSection() {
auto css = Gtk::CssProvider::create();
css->load_from_string(stageRowCss);
Gtk::StyleProvider::add_provider_for_display(Gdk::Display::get_default(), css,
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
// "Add effect" sits beside the heading rather than under the list: under
// it, the split could drag it so far down the screen that the menu of
// effects had no room left to open.
auto* heading = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
auto* title = sectionLabel("Effect chain");
title->set_hexpand(true);
heading->append(*title);
heading->append(addEffectButton_);
box->append(*heading);
chainList_.set_name("chain-list");
chainList_.add_css_class("boxed-list"); chainList_.add_css_class("boxed-list");
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE); chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet"); auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet");
@@ -1145,7 +1333,13 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>(); auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(chainList_); scroller->set_child(chainList_);
scroller->set_size_request(-1, 150); // The list asks for the height of its rows, up to a cap, which is where
// the split above the params rests until it is dragged; the size request
// is only the least it may be dragged down to.
scroller->set_propagate_natural_height(true);
scroller->set_max_content_height(chainListMaxHeight);
scroller->set_size_request(-1, 100);
scroller->set_vexpand(true);
box->append(*scroller); box->append(*scroller);
// set_label() and set_icon_name() replace one another, so the icon and the // set_label() and set_icon_name() replace one another, so the icon and the
@@ -1158,12 +1352,14 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
addEffectButton_.set_always_show_arrow(true); addEffectButton_.set_always_show_arrow(true);
addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far"); addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far");
addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu())); addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu()));
addEffectButton_.set_halign(Gtk::Align::START); addEffectButton_.set_name("add-effect");
box->append(addEffectButton_);
return *box; return *box;
} }
// The tallest the menu of effects grows before it scrolls, in pixels.
constexpr int addMenuMaxHeight = 480;
// One entry per registered effect, so a new effect shows up here without this // One entry per registered effect, so a new effect shows up here without this
// file knowing anything about it. // file knowing anything about it.
Gtk::Widget& EffectorWindow::buildAddMenu() { Gtk::Widget& EffectorWindow::buildAddMenu() {
@@ -1182,7 +1378,15 @@ Gtk::Widget& EffectorWindow::buildAddMenu() {
list->append(*item); list->append(*item);
} }
popover->set_child(*list); // A popover that does not fit on the screen is not shown at all, and the
// list grows with every effect registered, so past a point it scrolls.
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_propagate_natural_height(true);
scroller->set_max_content_height(addMenuMaxHeight);
scroller->set_child(*list);
popover->set_child(*scroller);
return *popover; return *popover;
} }
@@ -1223,6 +1427,7 @@ void EffectorWindow::rebuildChainRows() {
// Only the rows: the placeholder is a child of the list box as well, and // Only the rows: the placeholder is a child of the list box as well, and
// removing it would leave the empty list blank. // removing it would leave the empty list blank.
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row); while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
stageLabels_.clear();
// The first stage that actually runs is the one handed the sources, which // The first stage that actually runs is the one handed the sources, which
// is not stage 1 once stage 1 is switched off. // is not stage 1 once stage 1 is switched off.
@@ -1233,9 +1438,10 @@ void EffectorWindow::rebuildChainRows() {
for (std::size_t i = 0; i < stages_.size(); ++i) { for (std::size_t i = 0; i < stages_.size(); ++i) {
const int index = static_cast<int>(i); const int index = static_cast<int>(i);
auto* row = Gtk::make_managed<Gtk::ListBoxRow>(); auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4); row->add_css_class("stage-row");
box->set_margin_top(4); auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 2);
box->set_margin_bottom(4); box->set_margin_top(1);
box->set_margin_bottom(1);
box->set_margin_start(8); box->set_margin_start(8);
box->set_margin_end(4); box->set_margin_end(4);
@@ -1252,8 +1458,8 @@ void EffectorWindow::rebuildChainRows() {
}); });
box->append(*check); box->append(*check);
auto* label = Gtk::make_managed<Gtk::Label>(std::to_string(index + 1) + ". " + auto* label = Gtk::make_managed<Gtk::Label>(stageLabel(i));
stages_[i].effect->title()); stageLabels_.push_back(label);
label->set_xalign(0.0F); label->set_xalign(0.0F);
label->set_hexpand(true); label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END); label->set_ellipsize(Pango::EllipsizeMode::END);
@@ -1275,6 +1481,8 @@ void EffectorWindow::rebuildChainRows() {
button->set_icon_name(action.icon); button->set_icon_name(action.icon);
button->set_tooltip_text(action.tip); button->set_tooltip_text(action.tip);
button->add_css_class("flat"); button->add_css_class("flat");
button->add_css_class("stage-action");
button->set_valign(Gtk::Align::CENTER);
const int delta = action.delta; const int delta = action.delta;
button->signal_clicked().connect([this, index, delta] { button->signal_clicked().connect([this, index, delta] {
if (delta == 0) { if (delta == 0) {
@@ -1295,6 +1503,56 @@ void EffectorWindow::rebuildChainRows() {
selectStage(selectedStage_); selectStage(selectedStage_);
suspendStageSelect_ = false; suspendStageSelect_ = false;
updateSourcesVisible(); updateSourcesVisible();
scheduleChainSplitFit();
}
// Once the rows are laid out, not in the middle of a rebuild or of an
// allocation, where moving the split would ask for another one.
void EffectorWindow::scheduleChainSplitFit() {
if (chainSplitDragged_ || chainSplitFit_.connected()) return;
chainSplitFit_ = Glib::signal_idle().connect([this] {
fitChainSplit();
return false;
});
}
// GtkPaned would size the fixed pane from its minimum height, which is only
// the floor it may be dragged down to, so the fit to the rows' natural height
// is done here.
void EffectorWindow::fitChainSplit() {
if (chainSplit_ == nullptr || chainSplitDragged_) return;
auto* chain = chainSplit_->get_start_child();
if (chain == nullptr) return;
int minimum = 0;
int natural = 0;
int minimumBaseline = 0;
int naturalBaseline = 0;
const int width = chainSplit_->get_width();
chain->measure(Gtk::Orientation::VERTICAL, width > 0 ? width : -1, minimum, natural,
minimumBaseline, naturalBaseline);
fittedChainSplit_ = natural;
suspendSplitTrack_ = true;
chainSplit_->set_position(natural);
suspendSplitTrack_ = false;
}
void EffectorWindow::refitChainSplit() {
chainSplitDragged_ = false;
fitChainSplit();
}
// Any move but a fit, or GTK clamping a fit into a window too short for it,
// is the user's drag.
void EffectorWindow::onChainSplitMoved() {
if (suspendSplitTrack_ || fittedChainSplit_ < 0) return;
const int position = chainSplit_->get_position();
if (position == fittedChainSplit_) return;
const int lowest = chainSplit_->property_min_position();
const int highest = chainSplit_->property_max_position();
if (fittedChainSplit_ > highest && position == highest) return;
if (fittedChainSplit_ < lowest && position == lowest) return;
chainSplitDragged_ = true;
chainSplitFit_.disconnect();
} }
// Switching a stage off leaves everything about it alone -- its settings, its // Switching a stage off leaves everything about it alone -- its settings, its
@@ -1325,8 +1583,17 @@ void EffectorWindow::onStageSelected() {
// ------------------------------------------------------------ rendering --- // ------------------------------------------------------------ rendering ---
std::string EffectorWindow::stageLabel(std::size_t index) const {
const ChainStage& stage = stages_[index];
return std::to_string(index + 1) + ". " + stage.effect->label(stageValues(stage, styles_));
}
void EffectorWindow::onParamChanged() { void EffectorWindow::onParamChanged() {
storeParams(); storeParams();
// A param can be what names the stage (the words of a text stage), and a
// styled one can rename every stage sharing the style.
for (std::size_t i = 0; i < stageLabels_.size() && i < stages_.size(); ++i)
stageLabels_[i]->set_text(stageLabel(i));
requestRender(); requestRender();
} }
@@ -1391,7 +1658,7 @@ bool EffectorWindow::canRender() const {
std::string EffectorWindow::missingText() const { std::string EffectorWindow::missingText() const {
if (stages_.empty()) return "Add an effect to start."; if (stages_.empty()) return "Add an effect to start.";
if (enabledStages(stages_).empty()) return "Every effect is switched off."; if (enabledStages(stages_).empty()) return "Every effect is switched off.";
return "Add source images to start."; return "Add source images under Setup to start.";
} }
void EffectorWindow::showPlaceholder() { void EffectorWindow::showPlaceholder() {
@@ -1400,6 +1667,7 @@ void EffectorWindow::showPlaceholder() {
placeholder_.set_text(missingText()); placeholder_.set_text(missingText());
previewStack_.set_visible_child("placeholder"); previewStack_.set_visible_child("placeholder");
status_.set_text(""); status_.set_text("");
status_.set_tooltip_text("");
} }
void EffectorWindow::requestRender() { void EffectorWindow::requestRender() {
@@ -1496,15 +1764,20 @@ void EffectorWindow::onRenderFinished() {
previewStack_.set_visible_child("picture"); previewStack_.set_visible_child("picture");
saveButton_.set_sensitive(true); saveButton_.set_sensitive(true);
// The line itself stays short; the full chain and setup go in the tooltip.
const std::size_t effects = enabledStages(stages_).size();
std::ostringstream status; std::ostringstream status;
status.setf(std::ios::fixed); status.setf(std::ios::fixed);
status.precision(1); status.precision(1);
status << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · "; status << effects << (effects == 1 ? " effect" : " effects") << " · " << millis << " ms";
std::ostringstream details;
details << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · ";
if (!sources_->empty()) { if (!sources_->empty()) {
status << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · "; details << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
} }
status << "seed " << seed_ << " · " << millis << " ms"; details << "seed " << seed_;
status_.set_text(status.str()); status_.set_text(status.str());
status_.set_tooltip_text(details.str());
} }
// -------------------------------------------------------------- project --- // -------------------------------------------------------------- project ---
@@ -1679,6 +1952,7 @@ void EffectorWindow::writeProject(const std::string& path) {
projectPath_ = path; projectPath_ = path;
updateTitle(); updateTitle();
status_.set_text("Saved " + path); status_.set_text("Saved " + path);
status_.set_tooltip_text("Saved " + path);
} }
void EffectorWindow::updateTitle() { void EffectorWindow::updateTitle() {
@@ -1722,6 +1996,7 @@ void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
} }
clearError(); clearError();
status_.set_text("Saved " + path.string()); status_.set_text("Saved " + path.string());
status_.set_tooltip_text("Saved " + path.string());
} }
// --------------------------------------------------------------- errors --- // --------------------------------------------------------------- errors ---
+20 -2
View File
@@ -29,6 +29,8 @@ public:
private: private:
void buildUi(); void buildUi();
Gtk::Widget& buildSidebar(); Gtk::Widget& buildSidebar();
Gtk::Widget& buildChainTab();
Gtk::Widget& buildSetupTab();
Gtk::Widget& buildPreview(); Gtk::Widget& buildPreview();
void buildParamWidgets(); void buildParamWidgets();
ParamValues paramValues() const; ParamValues paramValues() const;
@@ -72,7 +74,7 @@ private:
void closeLoadWindow(); void closeLoadWindow();
void cancelLoad(); void cancelLoad();
void appendSourceRow(const std::string& path); void appendSourceRow(const std::string& path);
void updateSourcesLabel(); // the collapsed expander shows the count void updateSourcesLabel(); // the heading shows the count
// The whole sources section only shows while the chain asks for sources. // The whole sources section only shows while the chain asks for sources.
void updateSourcesVisible(); void updateSourcesVisible();
@@ -93,6 +95,12 @@ private:
// The check on a chain row: the stage stays where it is, and is skipped. // The check on a chain row: the stage stays where it is, and is skipped.
void setStageEnabled(int index, bool enabled); void setStageEnabled(int index, bool enabled);
void rebuildChainRows(); void rebuildChainRows();
// Rests the split under the chain's rows, until the user drags it.
void scheduleChainSplitFit();
void fitChainSplit();
void refitChainSplit(); // the double-click on the handle
void onChainSplitMoved();
std::string stageLabel(std::size_t index) const;
void selectStage(int index); void selectStage(int index);
void onStageSelected(); void onStageSelected();
Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project
@@ -143,6 +151,15 @@ private:
StyleSet styles_; StyleSet styles_;
int selectedStage_ = 0; int selectedStage_ = 0;
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
// The split between the chain and the params, and where it was last put
// to fit the rows: any other position the user reached by dragging.
Gtk::Paned* chainSplit_ = nullptr;
int fittedChainSplit_ = -1;
bool chainSplitDragged_ = false;
bool suspendSplitTrack_ = false;
sigc::connection chainSplitFit_;
// The name label of each chain row, renamed in place as params change.
std::vector<Gtk::Label*> stageLabels_;
Image result_; Image result_;
std::uint64_t seed_ = 0; std::uint64_t seed_ = 0;
std::string projectPath_; // empty until the project has been saved once std::string projectPath_; // empty until the project has been saved once
@@ -183,7 +200,8 @@ private:
std::unique_ptr<Gtk::Window> loadWindow_; std::unique_ptr<Gtk::Window> loadWindow_;
Gtk::ProgressBar* loadBar_ = nullptr; Gtk::ProgressBar* loadBar_ = nullptr;
Gtk::Label* loadFileLabel_ = nullptr; Gtk::Label* loadFileLabel_ = nullptr;
Gtk::Expander sourcesExpander_; Gtk::Box sourcesSection_; // hidden while the chain needs no sources
Gtk::Label sourcesHeading_;
Gtk::ListBox sourceList_; Gtk::ListBox sourceList_;
Gtk::DropDown formatDrop_; Gtk::DropDown formatDrop_;
Gtk::SpinButton widthSpin_; Gtk::SpinButton widthSpin_;
+16
View File
@@ -5,3 +5,19 @@ target_link_libraries(effector_tests PRIVATE effector_core)
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME core COMMAND effector_tests) add_test(NAME core COMMAND effector_tests)
# The interface drives the real window, so it needs a display. Under ctest it
# gets a private Xvfb one when xvfb-run is installed; without it, or without a
# display of its own, the test reports itself skipped rather than failed.
add_executable(effector_gui_tests gui_tests.cpp)
target_link_libraries(effector_gui_tests PRIVATE effector_gui)
target_compile_options(effector_gui_tests PRIVATE -Wall -Wextra -Wpedantic)
find_program(XVFB_RUN xvfb-run)
if(XVFB_RUN)
add_test(NAME gui COMMAND ${XVFB_RUN} -a -s "-screen 0 1400x900x24"
env -u WAYLAND_DISPLAY GDK_BACKEND=x11 $<TARGET_FILE:effector_gui_tests>)
else()
add_test(NAME gui COMMAND effector_gui_tests)
endif()
set_tests_properties(gui PROPERTIES SKIP_RETURN_CODE 77)
+189 -1
View File
@@ -12,6 +12,7 @@
#include <locale> #include <locale>
#include <mutex> #include <mutex>
#include <optional> #include <optional>
#include <set>
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -201,6 +202,18 @@ effector::Image flat(int width, int height, std::uint8_t shade) {
return image; return image;
} }
// Renders any registered effect by name with its defaults, overridden by
// `values`, from a generator seeded with `seed`.
effector::Image renderNamed(const std::string& name, const std::vector<effector::Image>& sources,
int width, int height, std::uint64_t seed,
const effector::ParamValues& values) {
const auto effect = effector::effectByName(name);
effector::Rng rng(seed);
auto merged = effect->defaults();
for (const auto& [key, value] : values) merged[key] = value;
return effect->render(sources, width, height, rng, merged);
}
effector::Image render(const std::vector<effector::Image>& sources, int width, int height, effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) { std::uint64_t seed, const effector::ParamValues& values) {
effector::ScanlinesEffect effect; effector::ScanlinesEffect effect;
@@ -243,6 +256,15 @@ int main() {
threw = true; threw = true;
} }
check(threw, "unknown effect throws"); check(threw, "unknown effect throws");
// Flags share one namespace, so a key two effects declared would be
// handed to whichever came first.
std::set<std::string> keys;
bool unique = true;
for (const auto& effect : effects()) {
for (const auto& spec : effect->params()) unique = keys.insert(spec.key).second && unique;
}
check(unique, "no two effects declare the same param key");
} }
std::cout << "scanlines\n"; std::cout << "scanlines\n";
@@ -737,6 +759,20 @@ int main() {
check(countRed(moved, 100, 60, 200, 120) > 0 && countRed(moved, 0, 0, 100, 60) == 0, check(countRed(moved, 100, 60, 200, 120) > 0 && countRed(moved, 0, 0, 100, 60) == 0,
"the offset moves it down and to the right"); "the offset moves it down and to the right");
// A wide line turned a quarter stands upright: it reaches above and
// below the band it filled unturned, and no longer out to the sides.
ParamValues wide = plain;
wide["text"] = std::string("HHHHHH");
wide["font_size"] = 40.0;
const auto flat = renderText(blue, 200, 120, wide);
ParamValues turned = wide;
turned["text_rotation"] = 90.0;
const auto upright = renderText(blue, 200, 120, turned);
check(countRed(flat, 0, 0, 200, 25) == 0 && countRed(upright, 0, 0, 200, 25) > 0,
"rotation turns the text about its centre");
check(countRed(flat, 0, 0, 50, 120) > 0 && countRed(upright, 0, 0, 50, 120) == 0,
"and takes it in from the sides");
// Weight and size are the two knobs the glyphs themselves answer to: a // Weight and size are the two knobs the glyphs themselves answer to: a
// bolder or a larger setting covers strictly more of the canvas. // bolder or a larger setting covers strictly more of the canvas.
ParamValues bold = plain; ParamValues bold = plain;
@@ -821,6 +857,13 @@ int main() {
// Like blur and overlay it needs no sources of its own, and the same // Like blur and overlay it needs no sources of its own, and the same
// settings render the same bytes twice over. // settings render the same bytes twice over.
check(!TextEffect().needsSources(), "text can lead a chain"); check(!TextEffect().needsSources(), "text can lead a chain");
// A text stage is named after its words, on one line.
check(TextEffect().label({{"text", std::string(" Two\nlines ")}}) == "Text (Two lines)",
"a text stage is labelled with its words");
check(TextEffect().label({{"text", std::string(" \n")}}) == "Text",
"a text stage with no words is labelled with the title alone");
check(effectByName("blur")->label({}) == "Blur", "other effects are labelled with their title");
const auto alone = renderText({}, 200, 120, plain); const auto alone = renderText({}, 200, 120, plain);
check(alone.width() == 200 && countRed(alone, 0, 0, 200, 120) > 0, check(alone.width() == 200 && countRed(alone, 0, 0, 200, 120) > 0,
"without sources it draws on an empty canvas"); "without sources it draws on an empty canvas");
@@ -855,7 +898,8 @@ int main() {
carries("box_padding") && carries("box_opacity"), carries("box_padding") && carries("box_opacity"),
"the look is what it carries"); "the look is what it carries");
check(!carries("text") && !carries("text_wrap") && !carries("box_image") && check(!carries("text") && !carries("text_wrap") && !carries("box_image") &&
!carries("text_center") && !carries("text_offset_x") && !carries("text_offset_y"), !carries("text_center") && !carries("text_offset_x") && !carries("text_offset_y") &&
!carries("text_rotation"),
"the words and the placement stay on the stage"); "the words and the placement stay on the stage");
check(styleSpecs("").empty() && styleSpecs("nosuchkind").empty(), check(styleSpecs("").empty() && styleSpecs("nosuchkind").empty(),
"a kind no effect claims carries nothing"); "a kind no effect claims carries nothing");
@@ -933,6 +977,150 @@ int main() {
"and so does a chain handed no styles at all"); "and so does a chain handed no styles at all");
} }
std::cout << "chaos transforms\n";
{
const std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
const std::vector<Image> input = {gradient(400, 300, 40)};
// What every one of them must do, and what switches each one off.
const std::vector<std::pair<std::string, ParamValues>> transforms = {
{"slitshift", {{"shift_max", 0L}}},
{"channelsplit", {{"split_amount", 0L}}},
{"pixelsort", {{"sort_line_chance", 0.0}}},
{"corrupt", {{"corrupt_rate", 0.0}}},
{"shatter",
{{"shatter_displace", 0L}, {"shatter_rotate", 0.0}, {"shatter_zoom", 0.0},
{"shatter_crack_width", 0L}}},
{"warp", {{"warp_strength", 0L}}},
{"bitcrush", {{"crush_coverage", 0.0}}},
{"feedback", {{"echo_fade", 0.0}}},
};
for (const auto& [name, off] : transforms) {
check(!effectByName(name)->needsSources(), name + " needs no source images");
const auto first = renderNamed(name, sources, 400, 300, 4, {});
const auto again = renderNamed(name, sources, 400, 300, 4, {});
const auto other = renderNamed(name, sources, 400, 300, 11, {});
check(first.width() == 400 && first.height() == 300,
name + " renders the requested size");
check(sameBytes(first, again), name + " reproduces itself from a seed");
check(!sameBytes(first, other), name + " changes with the seed");
check(!sameBytes(renderNamed(name, input, 400, 300, 4, {}), input[0]),
name + " changes its input");
check(sameBytes(renderNamed(name, input, 400, 300, 4, off), input[0]),
name + " switched right down leaves its input alone");
const auto blank = renderNamed(name, {}, 120, 80, 1, {});
check(blank.width() == 120 && blank.height() == 80,
name + " renders without sources");
ChainStage scanlines{effectByName("scanlines"), {{"strip_height", 6L}}};
ChainStage stage{effectByName(name), {}};
Rng a(9);
Rng b(9);
check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a),
renderChain({scanlines, stage}, sources, 300, 200, b)),
name + " cascades reproducibly");
check(!chainNeedsSources({stage, scanlines}), "a chain led by " + name + " needs no sources");
// A canvas one pixel wide or high must not trip any of them up.
check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 &&
renderNamed(name, input, 1, 57, 2, {}).height() == 57 &&
renderNamed(name, input, 57, 1, 2, {}).width() == 57,
name + " copes with a sliver of a canvas");
}
// Wrapped slit shifts only rotate each row, so every row keeps its
// pixels, just not their places.
const auto wrapped = renderNamed("slitshift", input, 400, 300, 5, {{"shift_bands", 200L}});
bool rotated = true;
for (int y = 0; y < 300; ++y) {
std::vector<std::uint8_t> before(input[0].row(y), input[0].row(y) + 1200);
std::vector<std::uint8_t> after(wrapped.row(y), wrapped.row(y) + 1200);
std::sort(before.begin(), before.end());
std::sort(after.begin(), after.end());
rotated = rotated && before == after;
}
check(rotated, "a wrapped slit shift keeps every row's pixels");
const auto upright = renderNamed("slitshift", input, 400, 300, 5,
{{"shift_axis", std::string("vertical")}});
check(!sameBytes(upright, wrapped), "the vertical axis tears columns instead");
// Moving a plane of one colour moves nothing that can be seen.
const auto split = renderNamed("channelsplit", {solid(80, 60, 200, 90, 30)}, 80, 60, 3,
{{"split_mode", std::string("per_band")},
{"split_band_height", 7L}});
check(sameBytes(split, solid(80, 60, 200, 90, 30)), "a channel split keeps a flat colour");
const auto rc = renderNamed("channelsplit", input, 400, 300, 3,
{{"split_channels", std::string("red_cyan")}});
check(pixel(rc, 200, 150)[2] == pixel(input[0], 200, 150)[2],
"red_cyan leaves blue where a flat plane has it");
// With the thresholds wide open every row comes out sorted.
const auto sorted = renderNamed("pixelsort", {stripes(64, 16)}, 64, 16, 1,
{{"sort_low", 0.0}, {"sort_high", 1.0}});
bool ordered = true;
for (int y = 0; y < 16; ++y) {
for (int x = 1; x < 64; ++x) ordered = ordered && pixel(sorted, x - 1, y)[0] <= pixel(sorted, x, y)[0];
}
check(ordered, "an open pixel sort orders every row");
const auto downward = renderNamed("pixelsort", {stripes(64, 16)}, 64, 16, 1,
{{"sort_low", 0.0}, {"sort_high", 1.0},
{"sort_direction", std::string("vertical")}});
check(sameBytes(downward, stripes(64, 16)), "sorting uniform columns changes nothing");
// Every block repeated is the first block smeared across the row.
const auto smeared = renderNamed("corrupt", input, 64, 32, 2,
{{"corrupt_rate", 1.0},
{"corrupt_block", 16L},
{"corrupt_kinds", std::string("repeat")}});
bool periodic = true;
for (int y = 0; y < 32; ++y) {
for (int x = 16; x < 64; ++x) periodic = periodic && pixel(smeared, x, y) == pixel(smeared, x - 16, y);
}
check(periodic, "repeated blocks copy the block to their left");
const auto bled = renderNamed("corrupt", input, 400, 300, 2,
{{"corrupt_rate", 0.02}, {"corrupt_bleed", true}});
check(!sameBytes(bled, renderNamed("corrupt", input, 400, 300, 2, {{"corrupt_rate", 0.02}})),
"bleeding carries the damage further");
const auto cracked = renderNamed("shatter", {flat(200, 150, 128)}, 200, 150, 6,
{{"shatter_crack_colour", std::string("#ff0000")}});
bool red = false;
for (int y = 0; y < 150; ++y) {
for (int x = 0; x < 200; ++x) red = red || isRed(pixel(cracked, x, y));
}
check(red, "shatter draws its cracks in the crack colour");
check(!sameBytes(renderNamed("warp", input, 400, 300, 4, {{"warp_mode", std::string("swirl")}}),
renderNamed("warp", input, 400, 300, 4, {})),
"swirl flows differently from a plain push");
const auto poster = renderNamed("bitcrush", input, 400, 300, 3,
{{"crush_op", std::string("posterize")},
{"crush_coverage", 1.0}});
bool coarse = true;
for (std::size_t i = 0; i < poster.byteSize(); ++i) coarse = coarse && poster.data()[i] % 8 == 0;
check(coarse, "posterising three bits leaves multiples of eight");
const auto swapped = renderNamed("bitcrush", {solid(40, 40, 250, 120, 5)}, 40, 40, 3,
{{"crush_op", std::string("channel_swap")},
{"crush_coverage", 1.0}});
check(pixel(swapped, 20, 20) != std::array<int, 3>{250, 120, 5},
"a channel swap never leaves the order as it was");
// A shrunk, fully opaque echo lands the image's corner inside the
// canvas, while the border outside every echo stays the input's.
const auto tunnel = renderNamed("feedback", input, 400, 300, 1,
{{"echo_scale", 0.5}, {"echo_rotate", 0.0},
{"echo_drift", 0L}, {"echo_fade", 1.0}});
check(pixel(tunnel, 0, 0) == pixel(input[0], 0, 0), "the border outside the echoes is the input");
// Halfway to the corner reads the corner of the image at half size.
const auto near = pixel(tunnel, 100, 75);
check(std::abs(near[0] - pixel(input[0], 0, 0)[0]) < 4 &&
std::abs(near[1] - pixel(input[0], 0, 0)[1]) < 4,
"the echoes nest inside it");
}
std::cout << "chain\n"; std::cout << "chain\n";
{ {
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
+300
View File
@@ -0,0 +1,300 @@
// Checks for the interface, driven through the real window: the widgets are
// found by what a user sees (a name, a tooltip, a label) and pressed the way a
// click would press them. Needs a display; exits 77 (skipped) without one.
#include <gtkmm.h>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include "cli/options.hpp"
#include "core/registry.hpp"
#include "gui/window.hpp"
namespace {
int failures = 0;
void check(bool condition, const std::string& what) {
if (condition) {
std::cout << " ok " << what << "\n";
} else {
std::cout << " FAIL " << what << "\n";
++failures;
}
}
// Lets the main loop run for a while: popovers map, renders come back, and
// the rows rebuilt by a click are laid out.
void pump(int milliseconds = 100) {
auto context = Glib::MainContext::get_default();
const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(milliseconds);
while (std::chrono::steady_clock::now() < until) {
while (context->pending()) context->iteration(false);
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
template <typename T, typename Pred>
void collect(Gtk::Widget& root, Pred pred, std::vector<T*>& out) {
for (auto* child = root.get_first_child(); child != nullptr; child = child->get_next_sibling()) {
if (auto* match = dynamic_cast<T*>(child); match != nullptr && pred(*match)) out.push_back(match);
collect<T>(*child, pred, out);
}
}
template <typename T>
std::vector<T*> all(Gtk::Widget& root) {
std::vector<T*> out;
collect<T>(root, [](T&) { return true; }, out);
return out;
}
template <typename T>
T* named(Gtk::Widget& root, const std::string& name) {
std::vector<T*> out;
collect<T>(root, [&](T& w) { return w.get_name() == name; }, out);
return out.empty() ? nullptr : out.front();
}
Gtk::Button* withTooltip(Gtk::Widget& root, const std::string& tip) {
std::vector<Gtk::Button*> out;
collect<Gtk::Button>(root, [&](Gtk::Button& b) { return b.get_tooltip_text() == tip; }, out);
return out.empty() ? nullptr : out.front();
}
bool isAncestor(const Gtk::Widget& ancestor, const Gtk::Widget& widget) {
for (auto* w = widget.get_parent(); w != nullptr; w = w->get_parent()) {
if (w == &ancestor) return true;
}
return false;
}
int rowCount(Gtk::ListBox& list) {
int count = 0;
while (list.get_row_at_index(count) != nullptr) ++count;
return count;
}
std::string rowTitle(Gtk::ListBox& list, int index) {
auto* row = list.get_row_at_index(index);
if (row == nullptr) return {};
const auto labels = all<Gtk::Label>(*row);
return labels.empty() ? std::string{} : std::string(labels.front()->get_text());
}
// The top of `widget` in the window's coordinates.
double topIn(Gtk::Widget& widget, Gtk::Widget& window) {
graphene_rect_t bounds;
if (!gtk_widget_compute_bounds(widget.gobj(), window.gobj(), &bounds)) return -1.0;
return bounds.origin.y;
}
// The menu of effects: the popover the "Add effect" button opens, and the
// entry in it for one effect.
Gtk::Button* menuEntry(Gtk::Popover& menu, const std::string& title) {
for (auto* button : all<Gtk::Button>(menu)) {
if (button->get_label() == title) return button;
}
return nullptr;
}
// Presses a button the way the keyboard does. GTK shows the press for a
// quarter of a second before the button reports the click, so wait it out.
void press(Gtk::Button& button) {
button.activate();
pump(400);
}
bool addFromMenu(Gtk::MenuButton& add, const std::string& title) {
auto* menu = add.get_popover();
if (menu == nullptr) return false;
add.popup();
pump();
auto* entry = menuEntry(*menu, title);
if (entry == nullptr || !menu->get_mapped()) return false;
press(*entry);
return true;
}
void runTests(Gtk::Window& window) {
const auto& effects = effector::effects();
std::cout << "sidebar\n";
auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front();
check(stack != nullptr && stack->get_child_by_name("chain") != nullptr &&
stack->get_child_by_name("setup") != nullptr,
"the sidebar has a Chain and a Setup tab");
check(stack != nullptr && stack->get_visible_child_name() == "chain",
"it opens on the Chain tab");
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
auto* add = named<Gtk::MenuButton>(window, "add-effect");
auto* sources = named<Gtk::Box>(window, "sources");
check(chainList != nullptr && add != nullptr && sources != nullptr,
"the chain list, the Add effect button and the sources are there");
if (chainList == nullptr || add == nullptr || sources == nullptr) return;
std::vector<Gtk::Paned*> panes;
collect<Gtk::Paned>(window, [&](Gtk::Paned& p) { return isAncestor(p, *chainList); }, panes);
auto* split = panes.empty() ? nullptr : panes.back();
check(split != nullptr && split->get_orientation() == Gtk::Orientation::VERTICAL,
"the Chain tab is a vertical split");
check(split != nullptr && split->get_start_child() != nullptr &&
isAncestor(*split->get_start_child(), *chainList),
"the chain list is above the split");
check(split != nullptr && split->get_end_child() != nullptr &&
!isAncestor(*split->get_end_child(), *chainList),
"the params are below it, apart from the list");
check(split != nullptr && !split->get_shrink_start_child() && !split->get_shrink_end_child(),
"neither side can be dragged below its minimum");
check(topIn(*add, window) >= 0.0 && topIn(*add, window) < topIn(*chainList, window),
"Add effect sits above the chain list, not under it");
check(isAncestor(*stack->get_child_by_name("setup"), *sources),
"the sources are on the Setup tab");
std::cout << "add effect\n";
auto* menu = add->get_popover();
check(menu != nullptr, "Add effect has a menu");
if (menu == nullptr) return;
bool listed = true;
for (const auto& effect : effects) listed = listed && menuEntry(*menu, effect->title()) != nullptr;
check(listed, "the menu has an entry for every registered effect");
check(all<Gtk::Button>(*menu).size() == effects.size(), "and nothing else");
add->popup();
pump();
check(menu->get_mapped(), "the menu opens");
// A popover taller than the room below its button is not shown at all --
// though GTK still calls it mapped -- which is what broke the button once
// there were enough effects: the entries have to scroll instead, in a
// menu short enough to open below a button near the top of a 768-pixel
// screen.
const auto scrollers = all<Gtk::ScrolledWindow>(*menu);
const int cap = scrollers.empty() ? -1 : scrollers.front()->get_max_content_height();
check(!scrollers.empty() && isAncestor(*scrollers.front(), *menuEntry(*menu, effects.front()->title())),
"the entries scroll");
check(cap > 0 && cap <= 480, "past a height that fits a small screen");
check(menu->get_height() > 0 && menu->get_height() <= cap + 40,
"and the menu stays within it however many effects there are");
add->popdown();
pump();
std::vector<Gtk::ScrolledWindow*> listScrollers;
collect<Gtk::ScrolledWindow>(window, [&](Gtk::ScrolledWindow& s) {
return s.get_child() != nullptr && isAncestor(*s.get_child(), *chainList);
}, listScrollers);
auto* listScroller = listScrollers.empty() ? nullptr : listScrollers.back();
check(listScroller != nullptr && listScroller->get_propagate_natural_height() &&
listScroller->get_max_content_height() > 0,
"the chain list asks for the height of its rows, up to a cap");
const int emptySplit = split != nullptr ? split->get_position() : -1;
check(rowCount(*chainList) == 0, "the chain starts empty");
check(addFromMenu(*add, "Blur"), "picking Blur from the menu");
check(!menu->get_mapped(), "closes the menu");
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
"and appends a Blur stage");
check(chainList->get_selected_row() == chainList->get_row_at_index(0),
"the new stage is selected");
check(!sources->get_visible(), "a chain led by blur hides the sources");
check(addFromMenu(*add, "Scanlines"), "picking Scanlines");
check(rowCount(*chainList) == 2 && rowTitle(*chainList, 1) == "2. Scanlines",
"appends it after Blur");
check(chainList->get_selected_row() == chainList->get_row_at_index(1),
"and selects it");
std::cout << "stage rows\n";
if (rowCount(*chainList) != 2) return;
auto* second = chainList->get_row_at_index(1);
check(second->has_css_class("stage-row"), "a row is a stage row");
std::vector<Gtk::Button*> actions;
collect<Gtk::Button>(*second, [](Gtk::Button& b) { return b.has_css_class("stage-action"); }, actions);
check(actions.size() == 3, "with a move up, a move down and a remove action");
auto* first = chainList->get_row_at_index(0);
auto* firstUp = withTooltip(*first, "Move up");
auto* lastDown = withTooltip(*second, "Move down");
check(firstUp != nullptr && !firstUp->get_sensitive(), "the first stage cannot move up");
check(lastDown != nullptr && !lastDown->get_sensitive(), "the last stage cannot move down");
auto* up = withTooltip(*second, "Move up");
check(up != nullptr, "the second stage has Move up");
if (up != nullptr) press(*up);
check(rowTitle(*chainList, 0) == "1. Scanlines" && rowTitle(*chainList, 1) == "2. Blur",
"Move up swaps the stages");
check(chainList->get_selected_row() == chainList->get_row_at_index(0),
"and the moved stage stays selected");
check(sources->get_visible(), "a chain led by scanlines shows the sources");
auto* remove = withTooltip(*chainList->get_row_at_index(0), "Remove");
if (remove != nullptr) press(*remove);
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
"Remove drops the stage and renumbers the rest");
check(!sources->get_visible(), "and the sources hide again");
std::cout << "many stages\n";
for (int i = 0; i < 4; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
pump();
const int fewSplit = split != nullptr ? split->get_position() : -1;
check(split != nullptr && fewSplit > emptySplit,
"the split moves down as the chain grows");
for (int i = 4; i < 12; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
check(rowCount(*chainList) == 13, "the menu still works with a long chain");
check(addFromMenu(*add, effects.back()->title()) && rowCount(*chainList) == 14,
"including for the last effect in the menu");
pump();
const int longSplit = split != nullptr ? split->get_position() : -1;
check(longSplit > fewSplit && listScroller != nullptr &&
listScroller->get_height() <= listScroller->get_max_content_height() + 2,
"up to the cap, past which the list scrolls");
if (split != nullptr) split->set_position(emptySplit + 20);
pump();
addFromMenu(*add, effects.front()->title());
pump();
check(split != nullptr && split->get_position() == emptySplit + 20,
"once dragged, the split stays put as the chain grows");
// With the split dragged all the way down, the button must still work.
if (split != nullptr) split->set_position(10000);
pump();
add->popup();
pump();
check(menu->get_mapped(), "the menu opens with the split dragged to the bottom");
add->popdown();
pump();
}
} // namespace
int main(int argc, char** argv) {
// A crash should still leave every check before it on the screen.
std::cout << std::unitbuf;
const char* x11 = std::getenv("DISPLAY");
const char* wayland = std::getenv("WAYLAND_DISPLAY");
if ((x11 == nullptr || *x11 == '\0') && (wayland == nullptr || *wayland == '\0')) {
std::cout << "no display, skipped\n";
return 77;
}
auto app = Gtk::Application::create("org.effector.GuiTests",
Gio::Application::Flags::NON_UNIQUE);
app->signal_activate().connect([&app] {
auto window = std::make_unique<effector::EffectorWindow>(effector::Options{});
app->add_window(*window);
window->present();
pump(300);
runTests(*window);
app->remove_window(*window);
});
const int status = app->run(argc > 0 ? 1 : 0, argv);
if (status != 0) return status;
std::cout << (failures == 0 ? "all passed\n" : std::to_string(failures) + " failed\n");
return failures;
}