6 Commits
Author SHA1 Message Date
SiandClaude Opus 5.5 520c7c45fb Package Effector for Arch and give it a desktop entry
packaging/arch/PKGBUILD builds the tagged release from this repository
and installs it with pacman; the version test keeps its pkgver in step
with CMakeLists.txt. A desktop entry puts the interface in the app menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:22:18 +02:00
SiandClaude Opus 5.5 b9f905e04c Give Effector a version and a changelog, shown from the menu
The version is the project() line of CMakeLists.txt and nowhere else:
CMake writes it, with the text of CHANGELOG.md, into a generated
core/version.hpp, so the number and the release notes the binary shows
are the ones it was built from. effector --version (-V) prints it, and
the header-bar menu gains a section with Changelog, a window laying the
Markdown out as headings and bullets, and About Effector.

The core tests hold the newest changelog entry to the current version,
so a bump cannot go in without its notes. Releases are tagged vX.Y.Z;
this is 0.5.0, and the changelog reconstructs 0.1.0-0.4.0 from the
history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:14:43 +02:00
SiandClaude Opus 5.5 431fcf9c58 List recently opened projects under Open Recent
Projects opened or saved go into GTK's shared recent list, signed with the
effector application name, and the project menu lists the newest ten of
them. The GUI tests point XDG_DATA_HOME at a temporary folder so they leave
the user's own list alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:07:08 +02:00
SiandClaude Opus 5.5 9fb4428a3b Give the image overlay an optional shadow or glow
A blurred copy of the overlay's own alpha is drawn in a colour beneath it:
moved by an offset for a shadow, centred for a glow, with a size, a spread
and an opacity. The mask is integer-only so a seed still reproduces it, and
reaches past the canvas so an image lying off it still casts onto it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:56:27 +02:00
SiandClaude Opus 5.5 7d122ddd6c Keep the params' scrollbar clear of the spin buttons
The overlay scrollbar sat over the "+" of each spin button and took
clicks meant for it; give the param grid a gutter on its right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:48:33 +02:00
SiandClaude Opus 5.5 536b8620ea Reorder the chain by dragging a row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:43:51 +02:00
18 changed files with 947 additions and 17 deletions
+4
View File
@@ -5,3 +5,7 @@ compile_commands.json
__pycache__/
*.o
*.a
packaging/arch/effector/
packaging/arch/src/
packaging/arch/pkg/
packaging/arch/*.pkg.tar*
+80
View File
@@ -0,0 +1,80 @@
# Changelog
Every release of Effector, newest first. The version lives in one place, the
`project()` line of `CMakeLists.txt`, and each release is a `vX.Y.Z` tag on the
commit that made it. The interface shows this file under Changelog in its menu.
## [0.5.1] - 2026-09-26
### Added
- An Arch Linux `PKGBUILD` in `packaging/arch`: `makepkg -si` there builds and
installs the tagged release with pacman.
- A desktop entry, so an installed Effector shows up in the app menu.
## [0.5.0] - 2026-09-26
### Added
- A version number: `effector --version` prints it, and About Effector in the
menu shows it.
- This changelog, readable from the interface under Changelog in the menu.
- Open Recent in the project menu, listing the projects opened or saved here.
- An optional shadow or glow under the image overlay, with an offset, size,
spread, colour and opacity.
- Reordering the chain by dragging a row.
### Fixed
- The params' scrollbar no longer covers the spin buttons.
## [0.4.0] - 2026-09-23
### Added
- Eight chaotic effects: slit shift, channel split, pixel sort, corruption,
shatter, warp, bit crush and feedback. None needs sources, so any of them can
lead a chain.
- The sidebar split into Chain and Setup tabs.
- A rotation for the text effect's placement.
- Text stages labelled in the chain list with their words.
### Changed
- The preview stays centred however long the status line gets.
- The status line stays short, with the full chain and setup in its tooltip.
- The chain/params split fits the chain's rows until it is dragged; a
double-click on the handle fits it again.
- The mouse wheel scrolls past spin buttons that do not have the focus.
## [0.3.0] - 2026-09-19
### Added
- Named styles: several text stages can share one font and colour, and editing
the style moves every stage that follows it.
- Switching a chain stage off without taking it apart, in the interface and
with `--disable NAME` on the command line.
### Changed
- The interface opens on an empty chain, and shows the sources only when the
chain uses them.
## [0.2.0] - 2026-09-18
### Added
- Effect chains: stages cascade, each handed the previous one's output, built in
the sidebar or with repeated `-e`.
- Blur, film grain, black and white, image overlay, text and tiles effects.
- Projects saved and opened as plain JSON.
- Standard web and print canvas formats (`--format`, `--dpi`).
- Effects declare whether they need source images, so a chain led by blur
renders with none loaded.
### Changed
- The chain comes first in the sidebar.
### Fixed
- The canvas size is checked before it is narrowed to an int.
- Projects are written and read in the C locale, and keep the seed whole.
## [0.1.0] - 2026-09-18
### Added
- The C++20 port of the Python prototype: a GTK 4 interface and a headless
command line over pluggable effects, starting with scanlines.
- Source images load in the background, with a progress window and Cancel.
+21 -2
View File
@@ -33,6 +33,22 @@ Headless render (no display needed, the fastest end-to-end smoke test):
build/effector -o /tmp/out.png -s 800x600 --strip-height 6 --seed 1 a.jpg b.png
```
### Versions and the changelog
The version is the `project()` line of `CMakeLists.txt`, and nowhere else.
CMake writes it, together with the text of `CHANGELOG.md`, into a generated
`core/version.hpp` (`kVersion`, `kChangelog`, from `src/core/version.hpp.in`)
in the build tree. `--version` prints it, and the header-bar menu shows it
under About Effector and the changelog under Changelog. The `version` core test
fails unless the newest `## [X.Y.Z]` entry in `CHANGELOG.md` is the current
version, so a bump and its changelog entry go in together, and it also fails unless
`pkgver` in `packaging/arch/PKGBUILD` matches (that PKGBUILD builds the
`vX.Y.Z` tag, so reset `pkgrel=1` on a bump). Each release is an
annotated `vX.Y.Z` tag on the commit that made it. When you add a user-visible
change, add a line to the newest changelog entry, or start a new entry if that
one is already tagged. The Changelog window reads only headings, `- ` bullets
(wrapped onto indented lines) and `` `code` ``, so keep the file to those.
### Running the GUI from this shell
GTK picks Wayland from an inherited `WAYLAND_DISPLAY` and will not draw into an
@@ -113,7 +129,7 @@ both the style and the stage for that reason. `renderChain` takes the pool as a
trailing argument; the command line builds no styles and passes none.
The interface builds the chain in the sidebar ("Effect chain": add from the
menu, reorder, remove, switch off with the check on the row, one stage selected
menu, reorder by dragging a row or with its arrows, remove, switch off with the check on the row, one stage selected
at a time with its params below), the CLI builds it from repeated `-e`:
`-e scanlines --strip-height 6 -e blur --radius 5`, with `--disable NAME`
leaving a stage in place but skipping it. Flags share one namespace across all effects, so `buildChain`
@@ -148,7 +164,10 @@ can be moved or shared whole.
The interface opens and saves projects from the header-bar menu (New, Open,
Save, Save As, with the usual Ctrl+N/O/S accelerators); the window title shows
the project name. Opening one replaces the sources, so a decode still in flight
the project name. Open Recent lists the projects opened or saved here, kept in
GTK's shared `RecentManager` list and told apart from other programs' files by
the `effector` application name on each entry; the GUI tests point
`XDG_DATA_HOME` at a temporary folder so they never touch the user's list. Opening one replaces the sources, so a decode still in flight
is discarded rather than mixed into the new project (`dropLoadedImages_`).
### Two link targets, deliberately
+12 -2
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20)
project(effector VERSION 0.1.0 LANGUAGES CXX)
project(effector VERSION 0.5.1 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -21,6 +21,14 @@ pkg_check_modules(PANGOCAIRO REQUIRED IMPORTED_TARGET pangocairo)
find_package(Threads REQUIRED)
# --- version: the one number, and the changelog that goes with it ------------
# The version is the project() line above; each release is a vX.Y.Z tag on the
# commit that set it. CHANGELOG.md is baked in so the interface can show it,
# and editing it re-runs the configure step.
file(READ ${CMAKE_CURRENT_SOURCE_DIR}/CHANGELOG.md EFFECTOR_CHANGELOG)
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS CHANGELOG.md)
configure_file(src/core/version.hpp.in ${CMAKE_CURRENT_BINARY_DIR}/generated/core/version.hpp @ONLY)
# --- core: effects and image handling, no GUI ---------------------------------
add_library(effector_core STATIC
src/core/canvas.cpp
@@ -48,7 +56,7 @@ add_library(effector_core STATIC
src/effects/tiles.cpp
src/effects/warp.cpp
)
target_include_directories(effector_core PUBLIC src)
target_include_directories(effector_core PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated)
# The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding,
# so headless rendering works without a display.
target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF PkgConfig::PANGOCAIRO Threads::Threads)
@@ -69,6 +77,8 @@ target_link_libraries(effector PRIVATE effector_gui)
target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic)
install(TARGETS effector RUNTIME DESTINATION bin)
# The launcher entry, so the interface shows up in the desktop's app menu.
install(FILES data/org.effector.Effector.desktop DESTINATION share/applications)
# --- tests --------------------------------------------------------------------
include(CTest)
+19
View File
@@ -19,12 +19,20 @@ ctest --test-dir build --output-on-failure
The binary lands at `build/effector`; `cmake --install build --prefix ~/.local`
installs it to `~/.local/bin`.
On Arch, `packaging/arch/PKGBUILD` builds the release tagged `v<version>` from
this repository and installs it with pacman, desktop entry included:
```bash
cd packaging/arch && makepkg -si
```
## Run
```bash
build/effector # GTK interface
build/effector --list-effects
build/effector --help
build/effector --version
```
Headless rendering, no display needed:
@@ -60,6 +68,17 @@ batch. Small batches finish before the window would be drawn, so it only appears
if the decode is still going after 250 ms. On a terminal the headless mode keeps
a single `reading n/total` line updated on stderr; redirected output stays clean.
## Versions
Releases are listed in [CHANGELOG.md](CHANGELOG.md) and tagged `vX.Y.Z`
(`git tag -l 'v*'`). `effector --version` prints the version, and the menu in
the interface has Changelog and About Effector.
To cut a release: bump the `project()` version in `CMakeLists.txt`, move the
changes into a new `## [X.Y.Z] - date` entry at the top of `CHANGELOG.md` (the
core tests fail until the two agree), commit, and tag it:
`git tag -a vX.Y.Z -m "Effector X.Y.Z"`.
## Effects
### scanlines
+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=Effector
GenericName=Image Effects
Comment=Cascade glitch and collage effects over your photographs
Exec=effector %F
Icon=applications-graphics
Terminal=false
Categories=Graphics;2DGraphics;RasterGraphics;GTK;
MimeType=image/jpeg;image/png;image/webp;image/tiff;image/bmp;
Keywords=glitch;collage;scanlines;photo;effect;
StartupNotify=true
+30
View File
@@ -0,0 +1,30 @@
# Builds the release tagged v$pkgver from the repository this file sits in,
# so pkgver must follow the project() line of CMakeLists.txt (the version core
# test checks it). From this folder: makepkg -si
pkgname=effector
pkgver=0.5.1
pkgrel=1
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
arch=('x86_64' 'aarch64')
url='https://git.plan9.uber.space/hans/effector'
license=('unknown')
depends=('gtkmm-4.0' 'gdk-pixbuf2' 'pango' 'cairo' 'glib2' 'glibc' 'gcc-libs')
makedepends=('cmake' 'ninja' 'git')
checkdepends=('xorg-server-xvfb')
source=("$pkgname::git+file://${startdir%/packaging/arch}#tag=v$pkgver")
sha256sums=('SKIP')
build() {
cmake -S "$pkgname" -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr
cmake --build build
}
check() {
ctest --test-dir build --output-on-failure
}
package() {
DESTDIR="$pkgdir" cmake --install build
}
+3
View File
@@ -61,6 +61,8 @@ Options parseOptions(int argc, char** argv) {
if (arg == "-h" || arg == "--help") {
options.showHelp = true;
} else if (arg == "-V" || arg == "--version") {
options.showVersion = true;
} else if (arg == "--list-effects") {
options.listEffects = true;
} else if (arg == "-e" || arg == "--effect") {
@@ -184,6 +186,7 @@ std::string usage(const char* programName) {
<< " --disable NAME keep this effect in the chain but skip it\n"
<< " --seed N random seed (default: random)\n"
<< " --list-effects list available effects and exit\n"
<< " -V, --version show the version and exit\n"
<< " -h, --help show this help and exit\n";
out << "\nCanvas formats (--format):\n";
+1
View File
@@ -27,6 +27,7 @@ struct Options {
ParamValues params; // only the keys the user actually passed
bool listEffects = false;
bool showHelp = false;
bool showVersion = false;
};
// Throws EffectError on bad input. Effect flags are generated from the
+16
View File
@@ -0,0 +1,16 @@
#pragma once
// Generated by CMake from src/core/version.hpp.in; edit that, not the copy in
// the build tree. The version comes from the project() line of CMakeLists.txt
// and the changelog from CHANGELOG.md, both read at configure time.
#include <string_view>
namespace effector {
inline constexpr std::string_view kVersion = "@PROJECT_VERSION@";
// CHANGELOG.md as it stood when the build was configured, as Markdown.
inline constexpr std::string_view kChangelog = R"changelog(@EFFECTOR_CHANGELOG@)changelog";
} // namespace effector
+151 -2
View File
@@ -32,6 +32,28 @@ const std::vector<ParamSpec>& OverlayEffect::params() const {
ParamSpec::makeDouble("overlay_opacity", "Opacity", 1.0, 0.0, 1.0,
"How strongly the image is mixed over the input; its own "
"transparency is taken into account as well."),
ParamSpec::makeChoice("overlay_halo", "Halo", "none", {"none", "shadow", "glow"},
"Draws a soft copy of the image's outline beneath it: a shadow, "
"moved by the shadow offset, or a glow all around it."),
ParamSpec::makeColor("overlay_shadow_color", "Shadow colour", "#000000",
"The colour of the shadow."),
ParamSpec::makeColor("overlay_glow_color", "Glow colour", "#ffffff",
"The colour of the glow."),
ParamSpec::makeInt("overlay_halo_size", "Halo size", 12, 0, 500,
"How far the shadow or glow fades out, in pixels; 0 gives a hard "
"edge."),
ParamSpec::makeDouble("overlay_halo_spread", "Halo spread", 0.0, 0.0, 0.9,
"Pushes the full strength of the shadow or glow out from the "
"image's edge before it starts to fade; a glow usually wants some."),
ParamSpec::makeDouble("overlay_halo_opacity", "Halo opacity", 0.75, 0.0, 1.0,
"How strongly the shadow or glow is drawn, on top of the image's "
"own opacity."),
ParamSpec::makeInt("overlay_shadow_x", "Shadow X", 8, -2000, 2000,
"Moves the shadow right by this many pixels (left when negative); "
"a glow stays centred."),
ParamSpec::makeInt("overlay_shadow_y", "Shadow Y", 8, -2000, 2000,
"Moves the shadow down by this many pixels (up when negative); a "
"glow stays centred."),
};
return specs;
}
@@ -123,6 +145,44 @@ void sampleOverlay(const Image& src, const std::uint8_t* alpha, double u, double
}
}
// One box blur pass over a line of the halo mask. Unlike the blur effect's the
// window counts zero beyond both ends, so the halo fades out at the edge of
// the mask instead of smearing its last value outwards. Integers throughout,
// so a seed reproduces the halo byte for byte on any machine.
void maskPass(const std::int32_t* in, std::int32_t* out, int count, std::size_t stride,
int radius) {
const int window = 2 * radius + 1;
std::int32_t sum = 0;
for (int i = 0; i <= radius && i < count; ++i) sum += in[static_cast<std::size_t>(i) * stride];
for (int i = 0; i < count; ++i) {
out[static_cast<std::size_t>(i) * stride] = (sum + window / 2) / window;
if (i + radius + 1 < count) sum += in[static_cast<std::size_t>(i + radius + 1) * stride];
if (i - radius >= 0) sum -= in[static_cast<std::size_t>(i - radius) * stride];
}
}
// Three box passes each way, which is close enough to a gaussian: `size` is
// roughly how far the result reaches past the edge of what was covered.
void blurMask(std::vector<std::int32_t>& mask, int width, int height, int size) {
if (size <= 0) return;
const int radius = std::max(1, (size + 2) / 3);
std::vector<std::int32_t> scratch(mask.size());
for (int pass = 0; pass < 3; ++pass) {
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 std::size_t offset = y * static_cast<std::size_t>(width);
maskPass(mask.data() + offset, scratch.data() + offset, width, 1, radius);
}
});
parallelFor(static_cast<std::size_t>(width), [&](std::size_t begin, std::size_t end) {
for (std::size_t x = begin; x < end; ++x) {
maskPass(scratch.data() + x, mask.data() + x, height,
static_cast<std::size_t>(width), radius);
}
});
}
}
} // namespace
void OverlayEffect::loadOverlay(const std::string& path, Image& image,
@@ -187,11 +247,100 @@ Image OverlayEffect::render(const std::vector<Image>& sources, int width, int he
const int lastRow = tiling ? height : std::min(height, place.y + place.height);
const int firstCol = tiling ? 0 : std::max(0, place.x);
const int lastCol = tiling ? width : std::min(width, place.x + place.width);
if (firstRow >= lastRow || firstCol >= lastCol) return canvas; // entirely off the canvas
const int mix = static_cast<int>(opacity * 255.0 + 0.5);
const std::uint8_t* alphaData = alpha.data();
// The halo goes down first, so that the image covers it wherever it is
// opaque and shows it through wherever it is not.
const std::string halo = paramChoice(values, "overlay_halo", "none");
const double haloOpacity =
std::clamp(paramDouble(values, "overlay_halo_opacity", 0.75), 0.0, 1.0);
if ((halo == "shadow" || halo == "glow") && haloOpacity > 0.0) {
const bool shadow = halo == "shadow";
const int size =
static_cast<int>(std::clamp<long>(paramInt(values, "overlay_halo_size", 12), 0, 500));
const int reach = 3 * std::max(1, (size + 2) / 3);
const int shiftX = shadow ? static_cast<int>(paramInt(values, "overlay_shadow_x", 8)) : 0;
const int shiftY = shadow ? static_cast<int>(paramInt(values, "overlay_shadow_y", 8)) : 0;
std::uint8_t colour[3];
paramColor(values, shadow ? "overlay_shadow_color" : "overlay_glow_color",
shadow ? "#000000" : "#ffffff", colour);
// The mask covers the canvas as the shadow sees it (moved back by the
// offset) plus the blur's reach, so that parts of the image lying off
// the canvas still cast onto it. Outside the image nothing is covered,
// so the mask is cut down to the image plus that reach as well.
int left = -shiftX - reach;
int top = -shiftY - reach;
int right = width - shiftX + reach;
int bottom = height - shiftY + reach;
if (!tiling) {
left = std::max(left, place.x - reach);
top = std::max(top, place.y - reach);
right = std::min(right, place.x + place.width + reach);
bottom = std::min(bottom, place.y + place.height + reach);
}
if (left < right && top < bottom) {
const int maskWidth = right - left;
const int maskHeight = bottom - top;
std::vector<std::int32_t> mask(static_cast<std::size_t>(maskWidth) * maskHeight, 0);
parallelFor(static_cast<std::size_t>(maskHeight), [&](std::size_t begin,
std::size_t end) {
int rgb[3];
int pixelAlpha = 255;
for (std::size_t i = begin; i < end; ++i) {
const int y = top + static_cast<int>(i);
if (!tiling && (y < place.y || y >= place.y + place.height)) continue;
const double v = (y - place.y + 0.5) * stepY - 0.5;
std::int32_t* out = mask.data() + i * static_cast<std::size_t>(maskWidth);
for (int x = left; x < right; ++x) {
if (!tiling && (x < place.x || x >= place.x + place.width)) continue;
const double u = (x - place.x + 0.5) * stepX - 0.5;
sampleOverlay(overlay, alphaData, u, v, tiling, rgb, pixelAlpha);
out[x - left] = pixelAlpha;
}
}
});
blurMask(mask, maskWidth, maskHeight, size);
const int haloMix = static_cast<int>(haloOpacity * mix + 0.5);
// Spread amplifies the blurred mask, so the part near the edge
// saturates and only the outer rim still fades.
const double spread =
std::clamp(paramDouble(values, "overlay_halo_spread", 0.0), 0.0, 0.9);
const std::int32_t gain = static_cast<std::int32_t>(256.0 / (1.0 - spread) + 0.5);
const int fromRow = std::max(0, top + shiftY);
const int toRow = std::min(height, bottom + shiftY);
const int fromCol = std::max(0, left + shiftX);
const int toCol = std::min(width, right + shiftX);
if (fromRow < toRow && fromCol < toCol) {
parallelFor(static_cast<std::size_t>(toRow - fromRow), [&](std::size_t begin,
std::size_t end) {
for (std::size_t i = begin; i < end; ++i) {
const int y = fromRow + static_cast<int>(i);
const std::int32_t* in =
mask.data() + static_cast<std::size_t>(y - shiftY - top) * maskWidth;
std::uint8_t* out = canvas.row(y);
for (int x = fromCol; x < toCol; ++x) {
const int weight =
std::clamp<int>(in[x - shiftX - left] * gain / 256, 0, 255) *
haloMix / 255;
if (weight == 0) continue;
std::uint8_t* px = out + static_cast<std::size_t>(x) * 3;
for (int c = 0; c < 3; ++c) {
px[c] = static_cast<std::uint8_t>(
px[c] + (colour[c] - px[c]) * weight / 255);
}
}
}
});
}
}
}
// A shadow can reach the canvas from an image lying wholly beside it.
if (firstRow >= lastRow || firstCol >= lastCol) return canvas;
parallelFor(static_cast<std::size_t>(lastRow - firstRow), [&](std::size_t begin,
std::size_t end) {
int rgb[3];
+4 -2
View File
@@ -11,7 +11,8 @@ namespace effector {
// Lays one picture from disk over its input: a logo, a texture, a frame. The
// overlay is fitted to the canvas in the chosen way, placed from the top left
// or from the centre, nudged by an offset and mixed in by its opacity. Like the
// or from the centre, nudged by an offset and mixed in by its opacity, with a
// soft shadow or glow cut from its own outline beneath it if asked. Like the
// blur it needs no source images of its own, so it can lead a chain; given
// sources it fills the canvas from a randomly chosen one first.
class OverlayEffect : public Effect {
@@ -20,7 +21,8 @@ public:
std::string title() const override { return "Image overlay"; }
std::string description() const override {
return "Lays a single image from disk over its input, fitted to the canvas, centred or "
"not, nudged by an offset and mixed in by its opacity.";
"not, nudged by an offset and mixed in by its opacity, optionally with a shadow or "
"a glow around it.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
+326 -7
View File
@@ -13,6 +13,7 @@
#include "core/project.hpp"
#include "core/registry.hpp"
#include "core/rng.hpp"
#include "core/version.hpp"
namespace effector {
namespace {
@@ -21,6 +22,11 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
"*.JPG", "*.JPEG", "*.WEBP"};
// How this program signs the entries it adds to the shared recent list, so
// Open Recent lists its projects and nothing else.
const char* const kRecentApp = "effector";
const std::size_t kRecentCount = 10;
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
auto filter = Gtk::FileFilter::create();
filter->set_name("Effector projects");
@@ -108,6 +114,7 @@ EffectorWindow::EffectorWindow(const Options& initial) {
EffectorWindow::~EffectorWindow() {
debounce_.disconnect();
chainSplitFit_.disconnect();
recentChanged_.disconnect();
loadWindowDelay_.disconnect();
loadCancel_.store(true, std::memory_order_relaxed);
if (loader_.joinable()) loader_.join();
@@ -192,6 +199,9 @@ Gtk::Widget& EffectorWindow::buildChainTab() {
params->append(paramHeading_);
paramGrid_.set_column_spacing(8);
paramGrid_.set_row_spacing(6);
// A gutter for the overlay scrollbar, which otherwise sits over the spin
// buttons' "+" and takes the clicks meant for it.
paramGrid_.set_margin_end(14);
auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>();
paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
paramScroller->set_child(paramGrid_);
@@ -1300,6 +1310,12 @@ row.stage-row:selected .stage-action,
row.stage-row:focus-within .stage-action {
opacity: 1;
}
row.stage-row.drop-before {
box-shadow: inset 0 2px @theme_fg_color;
}
row.stage-row.drop-after {
box-shadow: inset 0 -2px @theme_fg_color;
}
)css";
Gtk::Widget& EffectorWindow::buildChainSection() {
@@ -1330,6 +1346,34 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
chainList_.signal_row_selected().connect(
[this](Gtk::ListBoxRow*) { onStageSelected(); });
// A row is dragged by its index (see rebuildChainRows) and dropped between
// two rows, which a line along the edge of the row below or above shows.
auto drop = Gtk::DropTarget::create(Glib::Value<int>::value_type(), Gdk::DragAction::MOVE);
drop->signal_motion().connect(
[this](double, double y) {
showChainDropSlot(chainDropSlot(y));
return Gdk::DragAction::MOVE;
},
false);
drop->signal_leave().connect([this] { showChainDropSlot(-1); });
drop->signal_drop().connect(
[this](const Glib::ValueBase& value, double, double y) {
showChainDropSlot(-1);
Glib::Value<int> from;
from.init(value.gobj());
const int index = from.get();
const int slot = chainDropSlot(y);
// The slot counts the dragged row itself, which leaves its place
// before it takes the new one.
const int to = slot > index ? slot - 1 : slot;
// Not while the drag is still finishing: the move rebuilds the
// rows, the dragged one and its drag source with them.
Glib::signal_idle().connect_once([this, index, to] { moveStage(index, to); });
return true;
},
false);
chainList_.add_controller(drop);
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(chainList_);
@@ -1409,12 +1453,13 @@ void EffectorWindow::removeStage(int index) {
requestRender();
}
void EffectorWindow::moveStage(int index, int delta) {
const int target = index + delta;
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
if (target < 0 || static_cast<std::size_t>(target) >= stages_.size()) return;
std::swap(stages_[static_cast<std::size_t>(index)], stages_[static_cast<std::size_t>(target)]);
selectedStage_ = target;
void EffectorWindow::moveStage(int from, int to) {
const auto count = static_cast<int>(stages_.size());
if (from < 0 || from >= count || to < 0 || to >= count || from == to) return;
ChainStage stage = std::move(stages_[static_cast<std::size_t>(from)]);
stages_.erase(stages_.begin() + from);
stages_.insert(stages_.begin() + to, std::move(stage));
selectedStage_ = to;
rebuildChainRows();
buildParamWidgets();
requestRender();
@@ -1488,7 +1533,7 @@ void EffectorWindow::rebuildChainRows() {
if (delta == 0) {
removeStage(index);
} else {
moveStage(index, delta);
moveStage(index, index + delta);
}
});
if (delta == -1) button->set_sensitive(index > 0);
@@ -1497,6 +1542,23 @@ void EffectorWindow::rebuildChainRows() {
}
row->set_child(*box);
// The row itself is what is dragged, under the pointer where it was
// picked up; a press that goes nowhere is still a click on the row.
auto drag = Gtk::DragSource::create();
drag->set_actions(Gdk::DragAction::MOVE);
drag->signal_prepare().connect(
[source = drag.get(), row, index](double x, double y) {
source->set_icon(Gtk::WidgetPaintable::create(*row), static_cast<int>(x),
static_cast<int>(y));
Glib::Value<int> value;
value.init(Glib::Value<int>::value_type());
value.set(index);
return Gdk::ContentProvider::create(value);
},
false);
row->add_controller(drag);
chainList_.append(*row);
}
@@ -1506,6 +1568,39 @@ void EffectorWindow::rebuildChainRows() {
scheduleChainSplitFit();
}
// Where a row dropped at `y` goes: before the row whose upper half `y` is in,
// or after the last row.
int EffectorWindow::chainDropSlot(double y) const {
for (int i = 0;; ++i) {
auto* row = chainList_.get_row_at_index(i);
if (row == nullptr) return i;
graphene_rect_t bounds;
if (!gtk_widget_compute_bounds(GTK_WIDGET(row->gobj()), GTK_WIDGET(chainList_.gobj()),
&bounds))
continue;
if (y < bounds.origin.y + bounds.size.height / 2) return i;
}
}
// Marks the edge a drop into `slot` lands on; -1 clears the mark.
void EffectorWindow::showChainDropSlot(int slot) {
const int count = static_cast<int>(stages_.size());
for (int i = 0; auto* row = chainList_.get_row_at_index(i); ++i) {
const bool before = i == slot;
const bool after = slot == count && i == count - 1;
if (before) {
row->add_css_class("drop-before");
} else {
row->remove_css_class("drop-before");
}
if (after) {
row->add_css_class("drop-after");
} else {
row->remove_css_class("drop-after");
}
}
}
// 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() {
@@ -1789,16 +1884,37 @@ Gtk::Widget& EffectorWindow::buildMainMenu() {
add_action("open-project", sigc::mem_fun(*this, &EffectorWindow::onOpenProject));
add_action("save-project", sigc::mem_fun(*this, &EffectorWindow::onSaveProject));
add_action("save-project-as", sigc::mem_fun(*this, &EffectorWindow::onSaveProjectAs));
// The entry carries the file's URI rather than its path: a target must be
// UTF-8, and a file name need not be.
add_action_with_parameter("open-recent", Glib::VARIANT_TYPE_STRING,
[this](const Glib::VariantBase& target) {
const auto uri =
Glib::VariantBase::cast_dynamic<Glib::Variant<Glib::ustring>>(target).get();
openProject(Glib::filename_from_uri(uri));
});
// Never enabled: it only stands in for an empty list.
add_action("no-recent")->set_enabled(false);
add_action("show-changelog", sigc::mem_fun(*this, &EffectorWindow::showChangelog));
add_action("about", sigc::mem_fun(*this, &EffectorWindow::showAbout));
auto menu = Gio::Menu::create();
auto open = Gio::Menu::create();
open->append("New Project", "win.new-project");
open->append("Open Project…", "win.open-project");
recentMenu_ = Gio::Menu::create();
open->append_submenu("Open Recent", recentMenu_);
rebuildRecentMenu();
recentChanged_ = Gtk::RecentManager::get_default()->signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::rebuildRecentMenu));
menu->append_section(open);
auto save = Gio::Menu::create();
save->append("Save Project", "win.save-project");
save->append("Save Project As…", "win.save-project-as");
menu->append_section(save);
auto help = Gio::Menu::create();
help->append("Changelog", "win.show-changelog");
help->append("About Effector", "win.about");
menu->append_section(help);
// The shortcuts hang off the window rather than the application, which the
// window does not belong to yet while it is being built.
@@ -1900,11 +2016,213 @@ void EffectorWindow::onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& resul
return; // cancelled
}
openProject(path);
}
void EffectorWindow::openProject(const std::string& path) {
try {
applyProject(loadProject(path), path);
} catch (const std::exception& error) {
showError(std::string("Could not open the project: ") + error.what());
return;
}
rememberProject(path);
}
void EffectorWindow::rememberProject(const std::string& path) {
Gtk::RecentManager::Data data;
data.app_name = kRecentApp;
data.app_exec = "effector";
data.mime_type = "application/json";
try {
Gtk::RecentManager::get_default()->add_item(
Glib::filename_to_uri(std::filesystem::absolute(path).string()), data);
} catch (const Glib::Error&) {
// Not a path a URI can name; the project is open all the same.
}
}
void EffectorWindow::rebuildRecentMenu() {
std::vector<Glib::RefPtr<Gtk::RecentInfo>> recent;
for (const auto& info : Gtk::RecentManager::get_default()->get_items()) {
if (info->has_application(kRecentApp) && info->exists()) recent.push_back(info);
}
std::sort(recent.begin(), recent.end(), [](const auto& a, const auto& b) {
return a->get_modified().compare(b->get_modified()) > 0;
});
if (recent.size() > kRecentCount) recent.resize(kRecentCount);
// A project is listed by its name, and by its folder too only where two
// of the same name would otherwise read alike.
std::vector<std::filesystem::path> paths;
for (const auto& info : recent) paths.emplace_back(Glib::filename_from_uri(info->get_uri()));
recentMenu_->remove_all();
for (std::size_t i = 0; i < recent.size(); ++i) {
std::string label = paths[i].stem().string();
const auto sameName = std::count_if(paths.begin(), paths.end(), [&](const auto& other) {
return other.stem() == paths[i].stem();
});
if (sameName > 1) label += " (" + paths[i].parent_path().filename().string() + ")";
// The menu reads an underscore as a mnemonic.
const std::string display = Glib::filename_display_name(label);
std::string escaped;
for (const char c : display) {
escaped += c;
if (c == '_') escaped += '_';
}
auto item = Gio::MenuItem::create(escaped, "");
item->set_action_and_target("win.open-recent",
Glib::Variant<Glib::ustring>::create(recent[i]->get_uri()));
recentMenu_->append_item(item);
}
if (recent.empty()) recentMenu_->append("No Recent Projects", "win.no-recent");
}
// ------------------------------------------------------------ changelog ---
namespace {
// Lays CHANGELOG.md out in a text buffer. It reads only the little Markdown
// the file uses -- headings, bullets wrapped onto indented lines, and `code`
// -- which is all a changelog needs.
void fillChangelog(const Glib::RefPtr<Gtk::TextBuffer>& buffer, std::string_view markdown) {
auto tags = buffer->get_tag_table();
auto tag = [&](const char* name, auto&& configure) {
auto made = Gtk::TextTag::create(name);
configure(*made);
tags->add(made);
};
tag("title", [](Gtk::TextTag& t) {
t.property_weight() = Pango::Weight::BOLD;
t.property_scale() = 1.6;
t.property_pixels_below_lines() = 6;
});
tag("release", [](Gtk::TextTag& t) {
t.property_weight() = Pango::Weight::BOLD;
t.property_scale() = 1.3;
t.property_pixels_above_lines() = 18;
t.property_pixels_below_lines() = 2;
});
tag("section", [](Gtk::TextTag& t) {
t.property_weight() = Pango::Weight::BOLD;
t.property_pixels_above_lines() = 8;
t.property_pixels_below_lines() = 2;
});
tag("item", [](Gtk::TextTag& t) {
t.property_left_margin() = 28;
t.property_indent() = -14;
t.property_pixels_below_lines() = 3;
});
tag("para", [](Gtk::TextTag& t) { t.property_pixels_below_lines() = 6; });
tag("code", [](Gtk::TextTag& t) { t.property_family() = "monospace"; });
// Gather each block -- a heading, a bullet, a paragraph -- whole first,
// since a bullet or a paragraph goes on over several lines.
struct Block {
std::string kind;
std::string text;
};
std::vector<Block> blocks;
bool open = false; // the last block still takes continuation lines
std::size_t pos = 0;
while (pos <= markdown.size()) {
auto end = markdown.find('\n', pos);
if (end == std::string_view::npos) end = markdown.size();
std::string line(markdown.substr(pos, end - pos));
pos = end + 1;
const auto first = line.find_first_not_of(' ');
if (first == std::string::npos) {
open = false;
} else if (line.rfind("# ", 0) == 0) {
blocks.push_back({"title", line.substr(2)});
open = false;
} else if (line.rfind("## ", 0) == 0) {
// "[0.5.0] - 2026-09-26" reads as "0.5.0 — 2026-09-26".
std::string text = line.substr(3);
std::erase(text, '[');
std::erase(text, ']');
if (const auto dash = text.find(" - "); dash != std::string::npos) {
text.replace(dash, 3, " — ");
}
blocks.push_back({"release", text});
open = false;
} else if (line.rfind("### ", 0) == 0) {
blocks.push_back({"section", line.substr(4)});
open = false;
} else if (line.rfind("- ", 0) == 0) {
blocks.push_back({"item", "• " + line.substr(2)});
open = true;
} else if (open) {
blocks.back().text += " " + line.substr(first);
} else {
blocks.push_back({"para", line.substr(first)});
open = true;
}
}
auto at = buffer->end();
for (std::size_t i = 0; i < blocks.size(); ++i) {
const auto& block = blocks[i];
// Split on the backticks: every odd piece is code.
std::size_t start = 0;
bool code = false;
while (start <= block.text.size()) {
auto tick = block.text.find('`', start);
if (tick == std::string::npos) tick = block.text.size();
if (tick > start) {
std::vector<Glib::ustring> names{block.kind};
if (code) names.emplace_back("code");
at = buffer->insert_with_tags_by_name(at, block.text.substr(start, tick - start), names);
}
start = tick + 1;
code = !code;
}
if (i + 1 < blocks.size()) at = buffer->insert_with_tag(at, "\n", block.kind);
}
}
} // namespace
void EffectorWindow::showChangelog() {
if (!changelogWindow_) {
changelogWindow_ = std::make_unique<Gtk::Window>();
changelogWindow_->set_title("Changelog");
changelogWindow_->set_transient_for(*this);
changelogWindow_->set_default_size(560, 640);
changelogWindow_->set_hide_on_close(true);
auto* text = Gtk::make_managed<Gtk::TextView>();
text->set_name("changelog");
text->set_editable(false);
text->set_cursor_visible(false);
text->set_wrap_mode(Gtk::WrapMode::WORD);
text->set_left_margin(18);
text->set_right_margin(18);
text->set_top_margin(12);
text->set_bottom_margin(18);
fillChangelog(text->get_buffer(), kChangelog);
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(*text);
changelogWindow_->set_child(*scroller);
}
changelogWindow_->present();
}
void EffectorWindow::showAbout() {
if (!aboutDialog_) {
aboutDialog_ = std::make_unique<Gtk::AboutDialog>();
aboutDialog_->set_transient_for(*this);
aboutDialog_->set_modal(true);
aboutDialog_->set_hide_on_close(true);
aboutDialog_->set_program_name("Effector");
aboutDialog_->set_version(std::string(kVersion));
aboutDialog_->set_comments("Image effects, cascaded through a chain.");
aboutDialog_->set_logo_icon_name("applications-graphics");
}
aboutDialog_->present();
}
void EffectorWindow::onSaveProject() {
@@ -1951,6 +2269,7 @@ void EffectorWindow::writeProject(const std::string& path) {
clearError();
projectPath_ = path;
updateTitle();
rememberProject(path);
status_.set_text("Saved " + path);
status_.set_tooltip_text("Saved " + path);
}
+17 -1
View File
@@ -91,7 +91,11 @@ private:
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
void appendStage(const EffectPtr& effect);
void removeStage(int index);
void moveStage(int index, int delta);
void moveStage(int from, int to); // `to` is the index it ends up at
// Dragging a row: the slot between rows it would drop into, and the line
// that shows it.
int chainDropSlot(double y) const;
void showChainDropSlot(int slot);
// The check on a chain row: the stage stays where it is, and is skipped.
void setStageEnabled(int index, bool enabled);
void rebuildChainRows();
@@ -104,11 +108,18 @@ private:
void selectStage(int index);
void onStageSelected();
Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project
void showChangelog();
void showAbout();
Project currentProject() const;
void applyProject(const Project& project, const std::string& path);
void clearSources();
void onNewProject();
void onOpenProject();
void openProject(const std::string& path);
// Open Recent: the projects this program opened or saved, newest first,
// kept in the desktop's shared list of recently used files.
void rememberProject(const std::string& path);
void rebuildRecentMenu();
void onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void onSaveProject(); // falls back to Save As while the project is untitled
@@ -163,6 +174,8 @@ private:
Image result_;
std::uint64_t seed_ = 0;
std::string projectPath_; // empty until the project has been saved once
Glib::RefPtr<Gio::Menu> recentMenu_;
sigc::connection recentChanged_; // the recent list outlives the window
bool suspendRender_ = true;
// Source loading. Decoding a batch of large photographs takes seconds, so
@@ -198,6 +211,9 @@ private:
Gtk::Button* addButton_ = nullptr;
// Built fresh with the progress window, and null whenever it is closed.
std::unique_ptr<Gtk::Window> loadWindow_;
// Opened from the menu on first use, then shown again rather than rebuilt.
std::unique_ptr<Gtk::Window> changelogWindow_;
std::unique_ptr<Gtk::AboutDialog> aboutDialog_;
Gtk::ProgressBar* loadBar_ = nullptr;
Gtk::Label* loadFileLabel_ = nullptr;
Gtk::Box sourcesSection_; // hidden while the chain needs no sources
+5
View File
@@ -13,6 +13,7 @@
#include "core/image.hpp"
#include "core/registry.hpp"
#include "core/rng.hpp"
#include "core/version.hpp"
#include "gui/app.hpp"
namespace {
@@ -78,6 +79,10 @@ int main(int argc, char** argv) {
std::cout << effector::usage(argv[0]);
return 0;
}
if (options.showVersion) {
std::cout << "effector " << effector::kVersion << "\n";
return 0;
}
if (options.listEffects) {
for (const auto& effect : effector::effects()) {
std::cout << effect->name() << "\t" << effect->title() << " - "
+2
View File
@@ -3,6 +3,8 @@
add_executable(effector_tests core_tests.cpp ../src/cli/options.cpp)
target_link_libraries(effector_tests PRIVATE effector_core)
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
# The version test reads the Arch PKGBUILD to keep its pkgver in step.
target_compile_definitions(effector_tests PRIVATE EFFECTOR_SOURCE_DIR="${PROJECT_SOURCE_DIR}")
add_test(NAME core COMMAND effector_tests)
+66
View File
@@ -24,6 +24,7 @@
#include "core/registry.hpp"
#include "core/rng.hpp"
#include "core/style.hpp"
#include "core/version.hpp"
#include "effects/blur.hpp"
#include "effects/grain.hpp"
#include "effects/monochrome.hpp"
@@ -638,6 +639,49 @@ int main() {
solid(100, 100, 0, 0, 255)),
"zero opacity leaves the input untouched");
// Halo: none by default, a shadow moved by its offset, a glow all round.
check(sameBytes(renderOverlay(blue, 100, 100, at),
renderOverlay(blue, 100, 100, [&] {
ParamValues none = at;
none["overlay_halo"] = std::string("none");
return none;
}())),
"no halo is the default");
ParamValues shadowed = at;
shadowed["overlay_halo"] = std::string("shadow");
shadowed["overlay_halo_size"] = 0L;
shadowed["overlay_halo_opacity"] = 1.0;
shadowed["overlay_shadow_x"] = 5L;
shadowed["overlay_shadow_y"] = 5L;
const auto shadow = renderOverlay(blue, 100, 100, shadowed);
const auto dark = pixel(shadow, 72, 62); // past the logo's bottom right corner (70, 60)
check(dark[0] == 0 && dark[1] == 0 && dark[2] == 0,
"a hard shadow is cast down and right by its offset");
check(isBlue(pixel(shadow, 28, 50)) && isRed(pixel(shadow, 50, 50)),
"it leaves the other side alone and stays beneath the image");
shadowed["overlay_halo_size"] = 12L;
const auto soft = pixel(renderOverlay(blue, 100, 100, shadowed), 78, 50);
check(soft[2] > 0 && soft[2] < 255, "a soft shadow fades out past the edge");
ParamValues glowing = at;
glowing["overlay_halo"] = std::string("glow");
const auto glow = renderOverlay(blue, 100, 100, glowing);
const auto left = pixel(glow, 27, 50);
const auto right = pixel(glow, 72, 50);
check(left[0] > 0 && left[0] == right[0] && left[1] == right[1],
"a glow lightens both sides alike");
check(isBlue(pixel(glow, 2, 2)), "and fades out before the far corners");
glowing["overlay_halo_spread"] = 0.5;
check(pixel(renderOverlay(blue, 100, 100, glowing), 27, 50)[0] > left[0],
"spread strengthens the halo near the edge");
ParamValues offCanvas = shadowed;
offCanvas["overlay_halo_size"] = 0L;
offCanvas["overlay_offset_x"] = -70L; // the logo ends just left of the canvas
offCanvas["overlay_shadow_x"] = 20L;
const auto cast = pixel(renderOverlay(blue, 100, 100, offCanvas), 5, 50);
check(cast[2] == 0, "an image off the canvas still casts its shadow onto it");
// No image chosen is the state the interface starts in: a pass through.
check(sameBytes(renderOverlay(blue, 100, 100, {}), solid(100, 100, 0, 0, 255)),
"with no image chosen the stage passes its input through");
@@ -1672,6 +1716,28 @@ int main() {
std::filesystem::remove_all(dir);
}
std::cout << "version\n";
{
// A release is the version in CMakeLists.txt and its entry at the top
// of CHANGELOG.md; bumping one without the other fails here.
const std::string_view log = kChangelog;
const auto newest = log.find("\n## [");
check(newest != std::string_view::npos &&
log.substr(newest + 5).starts_with(std::string(kVersion) + "]"),
"the newest changelog entry is this version");
std::ifstream pkgbuild(std::string(EFFECTOR_SOURCE_DIR) + "/packaging/arch/PKGBUILD");
std::string line;
std::string pkgver;
while (std::getline(pkgbuild, line)) {
if (line.starts_with("pkgver=")) pkgver = line.substr(7);
}
check(pkgver == kVersion, "the Arch PKGBUILD packages this version");
const auto options = parsedOptions({"--version"});
check(options && options->showVersion, "--version is understood");
const auto shortOptions = parsedOptions({"-V"});
check(shortOptions && shortOptions->showVersion, "and so is -V");
}
std::cout << (failures == 0 ? "\nall checks passed\n" : "\n" + std::to_string(failures) + " failed\n");
return failures == 0 ? 0 : 1;
}
+178 -1
View File
@@ -6,6 +6,7 @@
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
@@ -13,7 +14,9 @@
#include <vector>
#include "cli/options.hpp"
#include "core/project.hpp"
#include "core/registry.hpp"
#include "core/version.hpp"
#include "gui/window.hpp"
namespace {
@@ -122,6 +125,30 @@ bool addFromMenu(Gtk::MenuButton& add, const std::string& title) {
return true;
}
// The drop target the chain list reorders its rows with.
GtkDropTarget* chainDropTarget(Gtk::Widget& list) {
GListModel* controllers = gtk_widget_observe_controllers(list.gobj());
GtkDropTarget* found = nullptr;
for (guint i = 0; i < g_list_model_get_n_items(controllers) && found == nullptr; ++i) {
auto* controller = g_list_model_get_item(controllers, i);
if (GTK_IS_DROP_TARGET(controller)) found = GTK_DROP_TARGET(controller);
g_object_unref(controller);
}
g_object_unref(controllers);
return found;
}
// What dropping the row of stage `index` at `y` does; a real drag needs a
// pointer, which the tests do not have.
void dropStage(GtkDropTarget* drop, int index, double y) {
GValue value = G_VALUE_INIT;
g_value_init(&value, G_TYPE_INT);
g_value_set_int(&value, index);
gboolean handled = FALSE;
g_signal_emit_by_name(drop, "drop", &value, 1.0, y, &handled);
g_value_unset(&value);
}
void runTests(Gtk::Window& window) {
const auto& effects = effector::effects();
@@ -239,6 +266,43 @@ void runTests(Gtk::Window& window) {
"Remove drops the stage and renumbers the rest");
check(!sources->get_visible(), "and the sources hide again");
std::cout << "drag and drop\n";
addFromMenu(*add, "Scanlines");
for (const auto& effect : effects) {
if (effect->title() != "Blur" && effect->title() != "Scanlines") {
addFromMenu(*add, effect->title());
break;
}
}
GtkDropTarget* drop = chainDropTarget(*chainList);
check(drop != nullptr, "the chain list takes drops");
if (drop != nullptr && rowCount(*chainList) == 3) {
const std::string first = rowTitle(*chainList, 0).substr(3);
const std::string second = rowTitle(*chainList, 1).substr(3);
const std::string third = rowTitle(*chainList, 2).substr(3);
const double bottom = chainList->get_row_at_index(2)->get_height() * 3.0;
GdkDragAction action = GDK_ACTION_NONE;
g_signal_emit_by_name(drop, "motion", 1.0, bottom, &action);
check(chainList->get_row_at_index(2)->has_css_class("drop-after"),
"dragging past the last row marks its bottom edge");
dropStage(drop, 0, bottom);
pump();
check(rowTitle(*chainList, 0) == "1. " + second && rowTitle(*chainList, 1) == "2. " + third &&
rowTitle(*chainList, 2) == "3. " + first,
"dropping the first stage past the last moves it to the end");
check(chainList->get_selected_row() == chainList->get_row_at_index(2),
"and selects it");
check(!chainList->get_row_at_index(2)->has_css_class("drop-after"),
"and clears the mark");
dropStage(drop, 2, 1.0);
pump();
check(rowTitle(*chainList, 0) == "1. " + first && rowTitle(*chainList, 1) == "2. " + second,
"dropping on the top edge of the first row moves it to the front");
}
for (int i = rowCount(*chainList) - 1; i > 0; --i) {
if (auto* removeRow = withTooltip(*chainList->get_row_at_index(i), "Remove")) press(*removeRow);
}
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();
@@ -271,6 +335,111 @@ void runTests(Gtk::Window& window) {
pump();
}
// The labels of the entries under the "Open Recent" submenu of `model`, or
// nothing when there is no such submenu.
void recentLabels(GMenuModel* model, std::vector<std::string>& out, bool& found) {
for (int i = 0; i < g_menu_model_get_n_items(model); ++i) {
char* label = nullptr;
g_menu_model_get_item_attribute(model, i, G_MENU_ATTRIBUTE_LABEL, "s", &label);
const std::string text = label != nullptr ? label : "";
g_free(label);
if (GMenuModel* section = g_menu_model_get_item_link(model, i, G_MENU_LINK_SECTION)) {
recentLabels(section, out, found);
g_object_unref(section);
}
if (GMenuModel* submenu = g_menu_model_get_item_link(model, i, G_MENU_LINK_SUBMENU)) {
if (text == "Open Recent") {
found = true;
for (int j = 0; j < g_menu_model_get_n_items(submenu); ++j) {
char* entry = nullptr;
g_menu_model_get_item_attribute(submenu, j, G_MENU_ATTRIBUTE_LABEL, "s", &entry);
out.emplace_back(entry != nullptr ? entry : "");
g_free(entry);
}
}
g_object_unref(submenu);
}
}
}
std::vector<std::string> recentEntries(Gtk::Window& window) {
std::vector<Gtk::MenuButton*> buttons;
collect<Gtk::MenuButton>(window, [](Gtk::MenuButton& b) { return b.get_tooltip_text() == "Project menu"; },
buttons);
std::vector<std::string> labels;
bool found = false;
if (buttons.empty()) return labels;
auto* popover = dynamic_cast<Gtk::PopoverMenu*>(buttons.front()->get_popover());
if (popover == nullptr || !popover->get_menu_model()) return labels;
recentLabels(popover->get_menu_model()->gobj(), labels, found);
if (!found) labels.emplace_back("<no Open Recent submenu>");
return labels;
}
// The version and the changelog, each a menu entry away.
void testHelpWindows(Gtk::Window& window) {
std::cout << "changelog and about\n";
auto toplevel = [](const std::string& title) -> Gtk::Window* {
for (auto* w : Gtk::Window::list_toplevels()) {
if (w->get_title() == title && w->get_visible()) return w;
}
return nullptr;
};
window.activate_action("win.show-changelog");
pump(200);
auto* changelog = toplevel("Changelog");
check(changelog != nullptr, "Changelog opens a window");
auto* text = changelog != nullptr ? named<Gtk::TextView>(*changelog, "changelog") : nullptr;
const std::string shown = text != nullptr ? std::string(text->get_buffer()->get_text()) : "";
check(shown.find(std::string(effector::kVersion) + " — ") != std::string::npos,
"listing this version as a release");
check(shown.find("[") == std::string::npos && shown.find("`") == std::string::npos &&
shown.find("### ") == std::string::npos,
"with the Markdown laid out rather than shown");
if (changelog != nullptr) changelog->close();
pump(50);
check(toplevel("Changelog") == nullptr, "and closes");
window.activate_action("win.about");
pump(200);
Gtk::AboutDialog* about = nullptr;
for (auto* w : Gtk::Window::list_toplevels()) {
if (auto* dialog = dynamic_cast<Gtk::AboutDialog*>(w); dialog != nullptr && dialog->get_visible()) {
about = dialog;
}
}
check(about != nullptr && about->get_version() == std::string(effector::kVersion),
"About Effector shows the version");
if (about != nullptr) about->close();
pump(50);
}
void testRecentProjects(Gtk::Window& window, const std::filesystem::path& dir) {
std::cout << "recent projects\n";
check(recentEntries(window) == std::vector<std::string>{"No Recent Projects"},
"Open Recent starts out empty");
// Two projects of the same name in different folders, and a third.
std::filesystem::create_directories(dir / "first");
std::filesystem::create_directories(dir / "second");
const std::filesystem::path paths[] = {dir / "first" / "my_look.json", dir / "second" / "my_look.json",
dir / "other.json"};
effector::Project project;
project.stages.push_back({effector::effectByName("blur"), {}}); // renders with no sources
for (const auto& path : paths) {
effector::saveProject(path.string(), project);
window.activate_action("win.open-recent",
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(50);
}
pump(500); // the recent list is written, and announced, from an idle
check(window.get_title() == "other — Effector", "an entry opens its project");
const auto labels = recentEntries(window);
check(labels == std::vector<std::string>{"other", "my__look (second)", "my__look (first)"},
"opened projects are listed newest first, a shared name with its folder");
}
} // namespace
int main(int argc, char** argv) {
@@ -283,17 +452,25 @@ int main(int argc, char** argv) {
return 77;
}
// Keep the projects the tests open out of the user's own recent list.
std::string dataTemplate = (std::filesystem::temp_directory_path() / "effector-gui-XXXXXX").string();
const std::filesystem::path dataDir = g_mkdtemp(dataTemplate.data());
g_setenv("XDG_DATA_HOME", dataDir.c_str(), TRUE);
auto app = Gtk::Application::create("org.effector.GuiTests",
Gio::Application::Flags::NON_UNIQUE);
app->signal_activate().connect([&app] {
app->signal_activate().connect([&app, &dataDir] {
auto window = std::make_unique<effector::EffectorWindow>(effector::Options{});
app->add_window(*window);
window->present();
pump(300);
runTests(*window);
testHelpWindows(*window);
testRecentProjects(*window, dataDir / "projects");
app->remove_window(*window);
});
const int status = app->run(argc > 0 ? 1 : 0, argv);
std::filesystem::remove_all(dataDir);
if (status != 0) return status;
std::cout << (failures == 0 ? "all passed\n" : std::to_string(failures) + " failed\n");
return failures;