Effector: C++/CMake image effects app

Port of the Python prototype to C++20 with a CMake build:

- effector_core: GUI-free static library (effects, image IO via gdk-pixbuf,
  declarative param specs, registry, portable seeded RNG, thread helper)
- scanlines effect: plans strips from the seeded RNG, then copies pixels in
  parallel with memcpy and doubling horizontal tiling
- gtkmm 4 interface: native file dialogs, widgets generated from the param
  specs, coalesced background renders, preview as a GDK memory texture
- CLI: flags generated from the effect registry, headless rendering with no
  display required
- ctest suite covering the RNG, registry, effect geometry and image round trip

The Python prototype is kept under python-reference/.
This commit is contained in:
hans
2026-09-18 14:28:13 +02:00
commit 925a719da0
36 changed files with 2859 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
#include "gui/app.hpp"
#include <gtkmm.h>
#include "gui/window.hpp"
namespace effector {
int runGui(const Options& initial, int argc, char** argv) {
// Our own parser already consumed argv, so GTK must not see it.
(void)argc;
(void)argv;
auto app = Gtk::Application::create("org.effector.Effector",
Gio::Application::Flags::NON_UNIQUE);
return app->make_window_and_run<EffectorWindow>(0, nullptr, initial);
}
} // namespace effector
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include "cli/options.hpp"
namespace effector {
// Runs the GTK interface with `initial` as the starting state.
int runGui(const Options& initial, int argc, char** argv);
} // namespace effector
+603
View File
@@ -0,0 +1,603 @@
#include "gui/window.hpp"
#include <chrono>
#include <filesystem>
#include <sstream>
#include "core/registry.hpp"
#include "core/rng.hpp"
namespace effector {
namespace {
const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*.bmp",
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
"*.JPG", "*.JPEG", "*.WEBP"};
Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) {
auto bytes = Glib::Bytes::create(image.data(), image.byteSize());
return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8,
bytes, image.stride());
}
Gtk::Label* sectionLabel(const char* text) {
auto* label = Gtk::make_managed<Gtk::Label>(text);
label->set_xalign(0.0F);
label->add_css_class("heading");
return label;
}
} // namespace
EffectorWindow::EffectorWindow(const Options& initial) {
set_title("Effector");
set_default_size(1180, 760);
effects_ = effects();
effect_ = effects_.front();
seed_ = Rng::randomSeed();
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
buildUi();
applyInitial(initial);
suspendRender_ = false;
if (!sources_->empty()) startRender();
}
EffectorWindow::~EffectorWindow() {
debounce_.disconnect();
if (worker_.joinable()) worker_.join();
}
// ------------------------------------------------------------------- UI ---
void EffectorWindow::buildUi() {
auto* header = Gtk::make_managed<Gtk::HeaderBar>();
set_titlebar(*header);
auto* addButton = Gtk::make_managed<Gtk::Button>();
addButton->set_icon_name("list-add-symbolic");
addButton->set_tooltip_text("Add images");
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
header->pack_start(*addButton);
std::vector<Glib::ustring> titles;
for (const auto& effect : effects_) titles.push_back(effect->title());
effectDropdown_.set_model(Gtk::StringList::create(titles));
effectDropdown_.set_tooltip_text("Effect");
effectDropdown_.property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onEffectChanged));
header->pack_start(effectDropdown_);
saveButton_.set_sensitive(false);
saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave));
header->pack_end(saveButton_);
recreateButton_.set_icon_name("view-refresh-symbolic");
recreateButton_.set_tooltip_text("Recreate with a new seed");
recreateButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRecreate));
header->pack_end(recreateButton_);
auto* root = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL);
set_child(*root);
banner_.set_xalign(0.0F);
banner_.set_wrap(true);
banner_.set_visible(false);
banner_.add_css_class("error");
banner_.set_margin(8);
root->append(banner_);
auto* paned = Gtk::make_managed<Gtk::Paned>(Gtk::Orientation::HORIZONTAL);
paned->set_vexpand(true);
paned->set_position(330);
paned->set_shrink_start_child(false);
paned->set_start_child(buildSidebar());
paned->set_end_child(buildPreview());
root->append(*paned);
}
Gtk::Widget& EffectorWindow::buildSidebar() {
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sidebar->set_margin(12);
sidebar->append(*sectionLabel("Source images"));
sourceList_.set_selection_mode(Gtk::SelectionMode::NONE);
sourceList_.add_css_class("boxed-list");
auto* empty = Gtk::make_managed<Gtk::Label>("No images yet");
empty->set_margin_top(18);
empty->set_margin_bottom(18);
sourceList_.set_placeholder(*empty);
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(sourceList_);
scroller->set_size_request(-1, 170);
scroller->set_vexpand(true);
sidebar->append(*scroller);
sidebar->append(*sectionLabel("Canvas"));
auto* grid = Gtk::make_managed<Gtk::Grid>();
grid->set_column_spacing(8);
grid->set_row_spacing(6);
widthSpin_.set_range(1, 20000);
widthSpin_.set_increments(1, 100);
widthSpin_.set_value(1920);
heightSpin_.set_range(1, 20000);
heightSpin_.set_increments(1, 100);
heightSpin_.set_value(1080);
for (auto* spin : {&widthSpin_, &heightSpin_}) {
spin->set_hexpand(true);
spin->signal_value_changed().connect(sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
}
auto* widthLabel = Gtk::make_managed<Gtk::Label>("Width");
widthLabel->set_xalign(0.0F);
auto* heightLabel = Gtk::make_managed<Gtk::Label>("Height");
heightLabel->set_xalign(0.0F);
grid->attach(*widthLabel, 0, 0);
grid->attach(widthSpin_, 1, 0);
grid->attach(*heightLabel, 0, 1);
grid->attach(heightSpin_, 1, 1);
sidebar->append(*grid);
sidebar->append(*sectionLabel("Effect settings"));
paramGrid_.set_column_spacing(8);
paramGrid_.set_row_spacing(6);
sidebar->append(paramGrid_);
buildParamWidgets();
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
seedLabel->set_xalign(0.0F);
seedBox->append(*seedLabel);
seedEntry_.set_hexpand(true);
seedEntry_.set_text(std::to_string(seed_));
seedEntry_.signal_activate().connect(sigc::mem_fun(*this, &EffectorWindow::onSeedEntered));
seedBox->append(seedEntry_);
sidebar->append(*seedBox);
return *sidebar;
}
Gtk::Widget& EffectorWindow::buildPreview() {
picture_.set_can_shrink(true);
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
placeholder_.set_text("Add source images to start.");
placeholder_.set_wrap(true);
placeholder_.add_css_class("dim-label");
previewStack_.add(placeholder_, "placeholder");
previewStack_.add(picture_, "picture");
previewStack_.set_visible_child("placeholder");
spinner_.set_halign(Gtk::Align::CENTER);
spinner_.set_valign(Gtk::Align::CENTER);
auto* overlay = Gtk::make_managed<Gtk::Overlay>();
overlay->set_hexpand(true);
overlay->set_vexpand(true);
overlay->set_child(previewStack_);
overlay->add_overlay(spinner_);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL);
box->append(*overlay);
status_.set_margin_top(6);
status_.set_margin_bottom(8);
status_.add_css_class("dim-label");
box->append(status_);
return *box;
}
void EffectorWindow::buildParamWidgets() {
while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child);
paramWidgets_.clear();
ownedWidgets_.clear();
int row = 0;
for (const auto& spec : effect_->params()) {
auto label = std::make_unique<Gtk::Label>(spec.label);
label->set_xalign(0.0F);
if (!spec.help.empty()) label->set_tooltip_text(spec.help);
std::unique_ptr<Gtk::Widget> widget;
switch (spec.type) {
case ParamSpec::Type::Bool: {
auto toggle = std::make_unique<Gtk::Switch>();
toggle->set_halign(Gtk::Align::START);
toggle->set_active(spec.defaultValue != 0.0);
toggle->property_active().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(toggle);
break;
}
case ParamSpec::Type::Int:
case ParamSpec::Type::Double: {
auto spin = std::make_unique<Gtk::SpinButton>();
spin->set_range(spec.minimum, spec.maximum);
spin->set_increments(spec.step, spec.step * 10);
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
spin->set_value(spec.defaultValue);
spin->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(spin);
break;
}
case ParamSpec::Type::Choice: {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>();
dropdown->set_model(Gtk::StringList::create(choices));
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
if (spec.choices[i] == spec.defaultChoice) dropdown->set_selected(i);
}
dropdown->property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(dropdown);
break;
}
}
widget->set_hexpand(true);
paramGrid_.attach(*label, 0, row);
paramGrid_.attach(*widget, 1, row);
paramWidgets_.push_back({spec, widget.get()});
ownedWidgets_.push_back(std::move(label));
ownedWidgets_.push_back(std::move(widget));
++row;
}
}
ParamValues EffectorWindow::paramValues() const {
ParamValues values = effect_->defaults();
for (const auto& entry : paramWidgets_) {
switch (entry.spec.type) {
case ParamSpec::Type::Bool:
values[entry.spec.key] = static_cast<Gtk::Switch*>(entry.widget)->get_active();
break;
case ParamSpec::Type::Int:
values[entry.spec.key] =
static_cast<long>(static_cast<Gtk::SpinButton*>(entry.widget)->get_value());
break;
case ParamSpec::Type::Double:
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
break;
case ParamSpec::Type::Choice: {
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
break;
}
}
}
return values;
}
void EffectorWindow::applyInitial(const Options& initial) {
for (std::size_t i = 0; i < effects_.size(); ++i) {
if (effects_[i]->name() == initial.effect) effectDropdown_.set_selected(i);
}
widthSpin_.set_value(initial.width);
heightSpin_.set_value(initial.height);
if (initial.seed) {
seed_ = *initial.seed;
seedEntry_.set_text(std::to_string(seed_));
}
for (const auto& entry : paramWidgets_) {
const auto it = initial.params.find(entry.spec.key);
if (it == initial.params.end()) continue;
switch (entry.spec.type) {
case ParamSpec::Type::Bool:
static_cast<Gtk::Switch*>(entry.widget)
->set_active(paramBool(initial.params, entry.spec.key, false));
break;
case ParamSpec::Type::Int:
static_cast<Gtk::SpinButton*>(entry.widget)
->set_value(static_cast<double>(paramInt(initial.params, entry.spec.key, 0)));
break;
case ParamSpec::Type::Double:
static_cast<Gtk::SpinButton*>(entry.widget)
->set_value(paramDouble(initial.params, entry.spec.key, 0.0));
break;
case ParamSpec::Type::Choice: {
const auto value = paramChoice(initial.params, entry.spec.key, entry.spec.defaultChoice);
for (std::size_t i = 0; i < entry.spec.choices.size(); ++i) {
if (entry.spec.choices[i] == value) {
static_cast<Gtk::DropDown*>(entry.widget)->set_selected(i);
}
}
break;
}
}
}
if (!initial.images.empty()) addPaths(initial.images);
}
// -------------------------------------------------------------- sources ---
// Replaces the source snapshot, moving the images across when nothing else
// holds the old list (the common case) and copying only if a render is reading it.
template <typename Mutate>
void EffectorWindow::mutateSources(Mutate&& mutate) {
auto next = std::make_shared<SourceList>();
if (sources_.use_count() == 1) {
*next = std::move(const_cast<SourceList&>(*sources_));
} else {
*next = *sources_;
}
mutate(*next);
sources_ = std::move(next);
}
void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
std::vector<LoadFailure> failures;
auto images = loadImages(paths, failures);
for (const auto& image : images) appendSourceRow(image.path());
mutateSources([&images](SourceList& list) {
for (auto& image : images) list.push_back(std::move(image));
});
if (!failures.empty()) {
std::string names;
for (const auto& failure : failures) {
if (!names.empty()) names += ", ";
names += std::filesystem::path(failure.path).filename().string();
}
showError("Could not read: " + names);
} else if (!images.empty()) {
clearError();
}
requestRender();
}
void EffectorWindow::appendSourceRow(const std::string& path) {
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
box->set_margin_top(4);
box->set_margin_bottom(4);
box->set_margin_start(8);
box->set_margin_end(4);
auto* label = Gtk::make_managed<Gtk::Label>(std::filesystem::path(path).filename().string());
label->set_xalign(0.0F);
label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END);
label->set_tooltip_text(path);
box->append(*label);
auto* remove = Gtk::make_managed<Gtk::Button>();
remove->set_icon_name("window-close-symbolic");
remove->add_css_class("flat");
remove->signal_clicked().connect([this, row] { removeSource(row); });
box->append(*remove);
row->set_child(*box);
sourceList_.append(*row);
}
void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
const int index = row->get_index();
mutateSources([index](SourceList& list) {
if (index >= 0 && static_cast<std::size_t>(index) < list.size()) {
list.erase(list.begin() + index);
}
});
sourceList_.remove(*row);
if (!sources_->empty()) {
requestRender();
} else {
result_ = Image();
saveButton_.set_sensitive(false);
previewStack_.set_visible_child("placeholder");
status_.set_text("");
}
}
void EffectorWindow::onAddImages() {
auto dialog = Gtk::FileDialog::create();
dialog->set_title("Select source images");
auto filter = Gtk::FileFilter::create();
filter->set_name("Images");
for (const char* pattern : kImagePatterns) filter->add_pattern(pattern);
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
filters->append(filter);
dialog->set_filters(filters);
dialog->set_default_filter(filter);
dialog->open_multiple(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onImagesChosen(result, dialog);
});
}
void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
std::vector<std::string> paths;
try {
for (const auto& file : dialog->open_multiple_finish(result)) {
if (file) paths.push_back(file->get_path());
}
} catch (const Glib::Error&) {
return; // cancelled
}
if (!paths.empty()) addPaths(paths);
}
// ------------------------------------------------------------ rendering ---
void EffectorWindow::onEffectChanged() {
const auto index = effectDropdown_.get_selected();
if (index >= effects_.size()) return;
effect_ = effects_[index];
buildParamWidgets();
requestRender();
}
void EffectorWindow::onParamChanged() { requestRender(); }
void EffectorWindow::onSeedEntered() {
try {
seed_ = std::stoull(seedEntry_.get_text());
} catch (const std::exception&) {
seedEntry_.set_text(std::to_string(seed_));
return;
}
requestRender();
}
void EffectorWindow::onRecreate() {
seed_ = Rng::randomSeed();
seedEntry_.set_text(std::to_string(seed_));
if (sources_->empty()) {
showError("Add at least one source image first.");
return;
}
requestRender();
}
void EffectorWindow::requestRender() {
if (suspendRender_ || sources_->empty()) return;
// Spin buttons fire on every step while they are held down; coalesce those
// into one render instead of queueing a job per tick.
debounce_.disconnect();
debounce_ = Glib::signal_timeout().connect(
[this] {
startRender();
return false;
},
120);
}
void EffectorWindow::startRender() {
if (sources_->empty()) return;
if (rendering_) {
staleResult_ = true; // a fresh render follows this one
return;
}
rendering_ = true;
const auto sources = sources_;
const auto effect = effect_;
const int width = static_cast<int>(widthSpin_.get_value());
const int height = static_cast<int>(heightSpin_.get_value());
const std::uint64_t seed = seed_;
const ParamValues values = paramValues();
spinner_.start();
recreateButton_.set_sensitive(false);
worker_ = std::thread([this, sources, effect, width, height, seed, values] {
Image image;
std::string error;
const auto start = std::chrono::steady_clock::now();
try {
Rng rng(seed);
image = effect->render(*sources, width, height, rng, values);
} catch (const std::exception& exception) {
error = exception.what();
}
const auto millis = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - start)
.count();
{
std::lock_guard<std::mutex> lock(resultMutex_);
pendingImage_ = std::move(image);
pendingError_ = std::move(error);
pendingMillis_ = millis;
}
renderDone_.emit();
});
}
void EffectorWindow::onRenderFinished() {
if (worker_.joinable()) worker_.join();
rendering_ = false;
Image image;
std::string error;
double millis = 0.0;
{
std::lock_guard<std::mutex> lock(resultMutex_);
image = std::move(pendingImage_);
error = pendingError_;
millis = pendingMillis_;
}
if (staleResult_) { // settings changed while this one was running
staleResult_ = false;
startRender();
return;
}
spinner_.stop();
recreateButton_.set_sensitive(true);
if (!error.empty()) {
result_ = Image();
saveButton_.set_sensitive(false);
showError(error);
return;
}
clearError();
result_ = std::move(image);
picture_.set_paintable(toTexture(result_));
previewStack_.set_visible_child("picture");
saveButton_.set_sensitive(true);
std::ostringstream status;
status.setf(std::ios::fixed);
status.precision(1);
status << effect_->title() << " · " << result_.width() << "×" << result_.height() << " · "
<< sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · seed "
<< seed_ << " · " << millis << " ms";
status_.set_text(status.str());
}
// --------------------------------------------------------------- saving ---
void EffectorWindow::onSave() {
if (result_.empty()) return;
auto dialog = Gtk::FileDialog::create();
dialog->set_title("Save image");
dialog->set_initial_name("effector-" + effect_->name() + "-" + std::to_string(seed_) + ".png");
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveChosen(result, dialog);
});
}
void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
std::filesystem::path path;
try {
const auto file = dialog->save_finish(result);
if (!file) return;
path = file->get_path();
} catch (const Glib::Error&) {
return; // cancelled
}
if (path.extension().empty()) path.replace_extension(".png");
try {
result_.save(path.string());
} catch (const std::exception& error) {
showError(std::string("Could not save: ") + error.what());
return;
}
clearError();
status_.set_text("Saved " + path.string());
}
// --------------------------------------------------------------- errors ---
void EffectorWindow::showError(const std::string& message) {
banner_.set_text(message);
banner_.set_visible(true);
}
void EffectorWindow::clearError() { banner_.set_visible(false); }
} // namespace effector
+103
View File
@@ -0,0 +1,103 @@
#pragma once
#include <gtkmm.h>
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <vector>
#include "cli/options.hpp"
#include "core/effect.hpp"
#include "core/image.hpp"
namespace effector {
class EffectorWindow : public Gtk::ApplicationWindow {
public:
explicit EffectorWindow(const Options& initial);
~EffectorWindow() override;
private:
void buildUi();
Gtk::Widget& buildSidebar();
Gtk::Widget& buildPreview();
void buildParamWidgets();
ParamValues paramValues() const;
void applyInitial(const Options& initial);
void requestRender(); // coalesced: restarts the short debounce timer
void startRender(); // starts a worker, or marks the result stale
void onAddImages();
void onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void addPaths(const std::vector<std::string>& paths);
void appendSourceRow(const std::string& path);
void removeSource(Gtk::ListBoxRow* row);
void onEffectChanged();
void onParamChanged();
void onSeedEntered();
void onRecreate();
void onSave();
void onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void onRenderFinished(); // runs on the main thread via the dispatcher
// Sources are handed to the worker as an immutable snapshot, so adding or
// removing an image while a render is in flight cannot race with it.
using SourceList = std::vector<Image>;
template <typename Mutate>
void mutateSources(Mutate&& mutate);
void showError(const std::string& message);
void clearError();
// Model
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
std::vector<EffectPtr> effects_;
EffectPtr effect_;
Image result_;
std::uint64_t seed_ = 0;
bool suspendRender_ = true;
// Render job
Glib::Dispatcher renderDone_;
std::thread worker_;
std::mutex resultMutex_;
bool rendering_ = false;
bool staleResult_ = false;
sigc::connection debounce_;
Image pendingImage_;
std::string pendingError_;
double pendingMillis_ = 0.0;
// Widgets
Gtk::Label banner_;
Gtk::ListBox sourceList_;
Gtk::SpinButton widthSpin_;
Gtk::SpinButton heightSpin_;
Gtk::Grid paramGrid_;
Gtk::Entry seedEntry_;
Gtk::DropDown effectDropdown_;
Gtk::Button recreateButton_{"Recreate"};
Gtk::Button saveButton_{"Save As…"};
Gtk::Picture picture_;
Gtk::Stack previewStack_;
Gtk::Label placeholder_;
Gtk::Spinner spinner_;
Gtk::Label status_;
struct ParamWidget {
ParamSpec spec;
Gtk::Widget* widget = nullptr;
};
std::vector<ParamWidget> paramWidgets_;
std::vector<std::unique_ptr<Gtk::Widget>> ownedWidgets_;
};
} // namespace effector