Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
520c7c45fb | ||
|
|
b9f905e04c | ||
|
|
431fcf9c58 | ||
|
|
9fb4428a3b | ||
|
|
7d122ddd6c | ||
|
|
536b8620ea |
@@ -5,3 +5,7 @@ compile_commands.json
|
||||
__pycache__/
|
||||
*.o
|
||||
*.a
|
||||
packaging/arch/effector/
|
||||
packaging/arch/src/
|
||||
packaging/arch/pkg/
|
||||
packaging/arch/*.pkg.tar*
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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() << " - "
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user