Files
effector/src/gui/window.cpp
T
SiandClaude Opus 5 67417727e7 Check the canvas size before narrowing it to an int
A canvas side was cast to int with no range check, so a number that did
not fit came back as one that did: `-s 4294968296x600` rendered a
perfectly plausible 1000x600, and a hand-edited project file could ask
for a width the reader turned into something else (or, for a double out
of range, into undefined behaviour).

Both front-ends now check the side before the cast, against one shared
bound in chain.hpp — the 1..20000 the GUI spin buttons already offered,
so the help text, the CLI error, the project reader and the interface
all move together.

The tests gain a `canvas size` section covering the round trip at both
ends of the range and the refusals on either side of it, through the
project reader and through `-s`; the test binary picks up the CLI parser
to do it, which stays GUI-free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:49:44 +02:00

1179 lines
41 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "gui/window.hpp"
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <sstream>
#include "core/chain.hpp"
#include "core/project.hpp"
#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<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
auto filter = Gtk::FileFilter::create();
filter->set_name("Effector projects");
filter->add_pattern("*.json");
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
filters->append(filter);
return filters;
}
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();
stages_.push_back({effects_.front(), effects_.front()->defaults()});
seed_ = Rng::randomSeed();
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress));
loadFinished_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadFinished));
buildUi();
applyInitial(initial);
suspendRender_ = false;
if (loading_) {
placeholder_.set_text(missingText());
} else if (canRender()) {
startRender();
} else {
showPlaceholder();
}
}
EffectorWindow::~EffectorWindow() {
debounce_.disconnect();
loadWindowDelay_.disconnect();
loadCancel_.store(true, std::memory_order_relaxed);
if (loader_.joinable()) loader_.join();
if (worker_.joinable()) worker_.join();
}
// ------------------------------------------------------------------- UI ---
void EffectorWindow::buildUi() {
auto* header = Gtk::make_managed<Gtk::HeaderBar>();
set_titlebar(*header);
menuButton_.set_icon_name("open-menu-symbolic");
menuButton_.set_tooltip_text("Project menu");
menuButton_.set_popover(static_cast<Gtk::PopoverMenu&>(buildMainMenu()));
header->pack_end(menuButton_);
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);
}
// The chain is what the interface is about, so it comes first and the params
// of the selected stage sit right below it. The sources are a prerequisite,
// not the subject: they live at the bottom in a collapsed expander.
Gtk::Widget& EffectorWindow::buildSidebar() {
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sidebar->set_margin(12);
sidebar->append(buildChainSection());
paramHeading_.set_xalign(0.0F);
paramHeading_.add_css_class("heading");
sidebar->append(paramHeading_);
paramGrid_.set_column_spacing(8);
paramGrid_.set_row_spacing(6);
auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>();
paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
paramScroller->set_child(paramGrid_);
paramScroller->set_vexpand(true);
sidebar->append(*paramScroller);
rebuildChainRows();
buildParamWidgets();
sidebar->append(*Gtk::make_managed<Gtk::Separator>(Gtk::Orientation::HORIZONTAL));
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(12);
empty->set_margin_bottom(12);
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, 140);
scroller->set_margin_top(6);
auto* sourcesBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
sourcesBox->append(*scroller);
// Same shape as "Add effect": the icon and the text are put together by
// hand because set_label() and set_icon_name() replace one another.
auto* addBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
addBox->append(*Gtk::make_managed<Gtk::Image>(
Gio::ThemedIcon::create("list-add-symbolic")));
addBox->append(*Gtk::make_managed<Gtk::Label>("Add images"));
auto* addButton = Gtk::make_managed<Gtk::Button>();
addButton->set_child(*addBox);
addButton->set_halign(Gtk::Align::START);
addButton->set_tooltip_text("Add source images");
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
sourcesBox->append(*addButton);
addButton_ = addButton;
sourcesExpander_.set_child(*sourcesBox);
sourcesExpander_.set_expanded(false);
updateSourcesLabel();
sidebar->append(sourcesExpander_);
sidebar->append(*sectionLabel("Canvas"));
auto* grid = Gtk::make_managed<Gtk::Grid>();
grid->set_column_spacing(8);
grid->set_row_spacing(6);
widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
widthSpin_.set_increments(1, 100);
widthSpin_.set_value(1920);
heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
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);
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;
}
// The expander is collapsed by default, so its label carries the count that
// used to be plain to see in the list.
void EffectorWindow::updateSourcesLabel() {
int count = 0;
while (sourceList_.get_row_at_index(count) != nullptr) ++count;
sourcesExpander_.set_label(count == 0 ? "Source images"
: "Source images (" + std::to_string(count) + ")");
}
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();
if (stages_.empty()) {
paramHeading_.set_text("No effects");
return;
}
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
" settings");
int row = 0;
for (const auto& spec : stage.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(paramBool(stage.values, spec.key, 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.type == ParamSpec::Type::Int
? static_cast<double>(paramInt(stage.values, spec.key,
static_cast<long>(spec.defaultValue)))
: paramDouble(stage.values, spec.key, 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));
const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice);
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
if (spec.choices[i] == current) 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 {
if (stages_.empty()) return {};
ParamValues values = stages_[static_cast<std::size_t>(selectedStage_)].values;
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;
}
// The widgets only ever show one stage, so their values are written back
// whenever they change; the other stages keep the settings they were given.
void EffectorWindow::storeParams() {
if (stages_.empty()) return;
stages_[static_cast<std::size_t>(selectedStage_)].values = paramValues();
}
void EffectorWindow::applyInitial(const Options& initial) {
// `-e scanlines -e blur` starts the interface with that cascade already set
// up, flags included.
auto stages = buildChain(initial);
if (!stages.empty()) stages_ = std::move(stages);
selectedStage_ = 0;
rebuildChainRows();
buildParamWidgets();
widthSpin_.set_value(initial.width);
heightSpin_.set_value(initial.height);
if (initial.seed) {
seed_ = *initial.seed;
seedEntry_.set_text(std::to_string(seed_));
}
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) {
if (paths.empty()) return;
if (loading_) { // a second selection arrived mid-batch; take it afterwards
loadQueue_.insert(loadQueue_.end(), paths.begin(), paths.end());
return;
}
loading_ = true;
loadCancel_.store(false, std::memory_order_relaxed);
loadDone_.store(0, std::memory_order_relaxed);
loadTotal_ = paths.size();
{
std::lock_guard<std::mutex> lock(loadMutex_);
loadCurrent_.clear();
}
if (addButton_ != nullptr) addButton_->set_sensitive(false);
// A couple of small files decode faster than a window can be drawn, so the
// progress window only appears if the batch is still going after a moment.
loadWindowDelay_.disconnect();
loadWindowDelay_ = Glib::signal_timeout().connect(
[this] {
showLoadWindow();
return false;
},
250);
loader_ = std::thread([this, paths] {
LoadProgress progress;
progress.cancel = &loadCancel_;
progress.onFile = [this](std::size_t done, std::size_t, const std::string& path) {
{
std::lock_guard<std::mutex> lock(loadMutex_);
loadCurrent_ = path;
}
loadDone_.store(done, std::memory_order_relaxed);
loadProgress_.emit();
};
std::vector<LoadFailure> failures;
auto images = loadImages(paths, failures, &progress);
{
std::lock_guard<std::mutex> lock(loadMutex_);
loadedImages_ = std::move(images);
loadFailures_ = std::move(failures);
}
loadFinished_.emit();
});
}
void EffectorWindow::onLoadProgress() {
const std::size_t done = loadDone_.load(std::memory_order_relaxed);
std::string current;
{
std::lock_guard<std::mutex> lock(loadMutex_);
current = loadCurrent_;
}
if (loadBar_ != nullptr && loadTotal_ > 0) {
loadBar_->set_fraction(static_cast<double>(done) / static_cast<double>(loadTotal_));
loadBar_->set_text(std::to_string(done) + " of " + std::to_string(loadTotal_));
}
if (loadFileLabel_ != nullptr && !current.empty()) {
loadFileLabel_->set_text(std::filesystem::path(current).filename().string());
}
}
void EffectorWindow::onLoadFinished() {
if (loader_.joinable()) loader_.join();
loading_ = false;
loadWindowDelay_.disconnect();
closeLoadWindow();
if (addButton_ != nullptr) addButton_->set_sensitive(true);
std::vector<Image> images;
std::vector<LoadFailure> failures;
{
std::lock_guard<std::mutex> lock(loadMutex_);
images = std::move(loadedImages_);
failures = std::move(loadFailures_);
loadedImages_.clear();
loadFailures_.clear();
}
if (loadCancel_.load(std::memory_order_relaxed)) {
// A cancelled batch is incomplete, so none of it is taken.
loadQueue_.clear();
dropLoadedImages_ = false;
showError("Loading cancelled.");
return;
}
if (dropLoadedImages_) {
// A project was opened while this batch was decoding: its images belong
// to the previous source list, and the project's own are queued behind.
dropLoadedImages_ = false;
images.clear();
failures.clear();
}
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();
if (!loadQueue_.empty()) {
auto queued = std::move(loadQueue_);
loadQueue_.clear();
addPaths(queued);
}
}
void EffectorWindow::showLoadWindow() {
if (!loading_ || loadWindow_) return;
loadWindow_ = std::make_unique<Gtk::Window>();
loadWindow_->set_title("Reading images");
loadWindow_->set_transient_for(*this);
loadWindow_->set_modal(true);
loadWindow_->set_resizable(false);
loadWindow_->set_default_size(420, -1);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
box->set_margin(18);
auto* heading = Gtk::make_managed<Gtk::Label>("Reading source images…");
heading->set_xalign(0.0F);
heading->add_css_class("heading");
box->append(*heading);
auto* bar = Gtk::make_managed<Gtk::ProgressBar>();
bar->set_show_text(true);
bar->set_text("0 of " + std::to_string(loadTotal_));
box->append(*bar);
loadBar_ = bar;
auto* file = Gtk::make_managed<Gtk::Label>("");
file->set_xalign(0.0F);
file->set_ellipsize(Pango::EllipsizeMode::MIDDLE);
file->add_css_class("dim-label");
box->append(*file);
loadFileLabel_ = file;
auto* cancel = Gtk::make_managed<Gtk::Button>("Cancel");
cancel->set_halign(Gtk::Align::END);
cancel->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::cancelLoad));
box->append(*cancel);
// Closing the window means the same thing as pressing Cancel, but the
// window stays up until the decoding threads have actually stopped.
loadWindow_->signal_close_request().connect(
[this] {
cancelLoad();
return true;
},
false);
loadWindow_->set_child(*box);
onLoadProgress(); // show whatever finished while the window was pending
loadWindow_->present();
}
void EffectorWindow::closeLoadWindow() {
loadBar_ = nullptr;
loadFileLabel_ = nullptr;
if (!loadWindow_) return;
loadWindow_->set_hide_on_close(false);
loadWindow_->hide();
loadWindow_.reset();
}
void EffectorWindow::cancelLoad() {
if (!loading_) return;
loadCancel_.store(true, std::memory_order_relaxed);
if (loadBar_ != nullptr) loadBar_->set_text("Cancelling…");
if (loadFileLabel_ != nullptr) loadFileLabel_->set_text("");
}
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);
updateSourcesLabel();
}
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);
updateSourcesLabel();
requestRender();
}
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);
}
// ---------------------------------------------------------------- chain ---
Gtk::Widget& EffectorWindow::buildChainSection() {
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
box->append(*sectionLabel("Effect chain"));
chainList_.add_css_class("boxed-list");
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet");
empty->set_margin_top(12);
empty->set_margin_bottom(12);
chainList_.set_placeholder(*empty);
chainList_.signal_row_selected().connect(
[this](Gtk::ListBoxRow*) { onStageSelected(); });
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(chainList_);
scroller->set_size_request(-1, 150);
box->append(*scroller);
// set_label() and set_icon_name() replace one another, so the icon and the
// text are put together by hand.
auto* addBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
addBox->append(*Gtk::make_managed<Gtk::Image>(
Gio::ThemedIcon::create("list-add-symbolic")));
addBox->append(*Gtk::make_managed<Gtk::Label>("Add effect"));
addEffectButton_.set_child(*addBox);
addEffectButton_.set_always_show_arrow(true);
addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far");
addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu()));
addEffectButton_.set_halign(Gtk::Align::START);
box->append(addEffectButton_);
return *box;
}
// One entry per registered effect, so a new effect shows up here without this
// file knowing anything about it.
Gtk::Widget& EffectorWindow::buildAddMenu() {
auto* popover = Gtk::make_managed<Gtk::Popover>();
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
for (const auto& effect : effects_) {
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
item->add_css_class("flat");
item->set_tooltip_text(effect->description());
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F);
item->signal_clicked().connect([this, effect] {
addEffectButton_.popdown();
appendStage(effect);
});
list->append(*item);
}
popover->set_child(*list);
return *popover;
}
void EffectorWindow::appendStage(const EffectPtr& effect) {
stages_.push_back({effect, effect->defaults()});
selectedStage_ = static_cast<int>(stages_.size()) - 1;
rebuildChainRows();
buildParamWidgets();
requestRender();
}
void EffectorWindow::removeStage(int index) {
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
stages_.erase(stages_.begin() + index);
selectedStage_ = std::min(selectedStage_, static_cast<int>(stages_.size()) - 1);
if (selectedStage_ < 0) selectedStage_ = 0;
rebuildChainRows();
buildParamWidgets();
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;
rebuildChainRows();
buildParamWidgets();
requestRender();
}
void EffectorWindow::rebuildChainRows() {
// Rebuilding drops and re-adds the selection, which would otherwise look
// like the user picking a different stage.
suspendStageSelect_ = true;
// Only the rows: the placeholder is a child of the list box as well, and
// removing it would leave the empty list blank.
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
for (std::size_t i = 0; i < stages_.size(); ++i) {
const int index = static_cast<int>(i);
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4);
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::to_string(index + 1) + ". " +
stages_[i].effect->title());
label->set_xalign(0.0F);
label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END);
label->set_tooltip_text(index == 0 ? "Applied to the source images"
: "Applied to the output of the stage above");
box->append(*label);
struct Action {
const char* icon;
const char* tip;
int delta; // 0 means remove
};
for (const Action& action : {Action{"go-up-symbolic", "Move up", -1},
Action{"go-down-symbolic", "Move down", 1},
Action{"window-close-symbolic", "Remove", 0}}) {
auto* button = Gtk::make_managed<Gtk::Button>();
button->set_icon_name(action.icon);
button->set_tooltip_text(action.tip);
button->add_css_class("flat");
const int delta = action.delta;
button->signal_clicked().connect([this, index, delta] {
if (delta == 0) {
removeStage(index);
} else {
moveStage(index, delta);
}
});
if (delta == -1) button->set_sensitive(index > 0);
if (delta == 1) button->set_sensitive(static_cast<std::size_t>(index) + 1 < stages_.size());
box->append(*button);
}
row->set_child(*box);
chainList_.append(*row);
}
selectStage(selectedStage_);
suspendStageSelect_ = false;
}
void EffectorWindow::selectStage(int index) {
if (auto* row = chainList_.get_row_at_index(index)) chainList_.select_row(*row);
}
void EffectorWindow::onStageSelected() {
if (suspendStageSelect_) return;
auto* row = chainList_.get_selected_row();
if (row == nullptr) return;
if (row->get_index() == selectedStage_) return;
selectedStage_ = row->get_index();
buildParamWidgets();
}
// ------------------------------------------------------------ rendering ---
void EffectorWindow::onParamChanged() {
storeParams();
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 (!canRender()) {
showError(missingText());
return;
}
requestRender();
}
// Whether the effect chain has everything it needs. Only the first stage is
// ever handed the sources, so an effect that does not want them (blur) renders
// happily with none loaded.
bool EffectorWindow::canRender() const {
if (stages_.empty()) return false;
return !sources_->empty() || !chainNeedsSources(stages_);
}
std::string EffectorWindow::missingText() const {
if (stages_.empty()) return "Add an effect to start.";
return "Add source images to start.";
}
void EffectorWindow::showPlaceholder() {
result_ = Image();
saveButton_.set_sensitive(false);
placeholder_.set_text(missingText());
previewStack_.set_visible_child("placeholder");
status_.set_text("");
}
void EffectorWindow::requestRender() {
if (suspendRender_) return;
if (!canRender()) {
showPlaceholder();
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 (!canRender()) return;
if (rendering_) {
staleResult_ = true; // a fresh render follows this one
return;
}
rendering_ = true;
const auto sources = sources_;
const auto stages = stages_; // a snapshot, like the sources
const int width = static_cast<int>(widthSpin_.get_value());
const int height = static_cast<int>(heightSpin_.get_value());
const std::uint64_t seed = seed_;
spinner_.start();
recreateButton_.set_sensitive(false);
worker_ = std::thread([this, sources, stages, width, height, seed] {
Image image;
std::string error;
const auto start = std::chrono::steady_clock::now();
try {
Rng rng(seed);
image = renderChain(stages, *sources, width, height, rng);
} 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 << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · ";
if (!sources_->empty()) {
status << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
}
status << "seed " << seed_ << " · " << millis << " ms";
status_.set_text(status.str());
}
// -------------------------------------------------------------- project ---
// The project menu lives in the header bar, next to the buttons that act on
// the current render.
Gtk::Widget& EffectorWindow::buildMainMenu() {
add_action("new-project", sigc::mem_fun(*this, &EffectorWindow::onNewProject));
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));
auto menu = Gio::Menu::create();
auto open = Gio::Menu::create();
open->append("New Project", "win.new-project");
open->append("Open Project…", "win.open-project");
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);
// The shortcuts hang off the window rather than the application, which the
// window does not belong to yet while it is being built.
auto shortcuts = Gtk::ShortcutController::create();
shortcuts->set_scope(Gtk::ShortcutScope::GLOBAL);
const struct Accel {
const char* keys;
const char* action;
} accels[] = {{"<Control>n", "win.new-project"},
{"<Control>o", "win.open-project"},
{"<Control>s", "win.save-project"},
{"<Control><Shift>s", "win.save-project-as"}};
for (const Accel& accel : accels) {
shortcuts->add_shortcut(Gtk::Shortcut::create(
Gtk::ShortcutTrigger::parse_string(accel.keys), Gtk::NamedAction::create(accel.action)));
}
add_controller(shortcuts);
return *Gtk::make_managed<Gtk::PopoverMenu>(menu);
}
Project EffectorWindow::currentProject() const {
Project project;
for (const auto& image : *sources_) project.sources.push_back(image.path());
project.stages = stages_;
project.width = static_cast<int>(widthSpin_.get_value());
project.height = static_cast<int>(heightSpin_.get_value());
project.seed = seed_;
return project;
}
void EffectorWindow::applyProject(const Project& project, const std::string& path) {
// One render at the end rather than one per piece of the project.
suspendRender_ = true;
clearSources();
stages_ = project.stages;
selectedStage_ = 0;
rebuildChainRows();
buildParamWidgets();
widthSpin_.set_value(project.width);
heightSpin_.set_value(project.height);
seed_ = project.seed.value_or(Rng::randomSeed());
seedEntry_.set_text(std::to_string(seed_));
projectPath_ = path;
updateTitle();
clearError();
suspendRender_ = false;
if (project.sources.empty()) {
requestRender(); // a chain that needs no sources still has a picture
} else {
addPaths(project.sources); // renders once the images are decoded
}
}
void EffectorWindow::clearSources() {
while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row);
updateSourcesLabel();
mutateSources([](SourceList& list) { list.clear(); });
loadQueue_.clear();
if (loading_) dropLoadedImages_ = true;
result_ = Image();
saveButton_.set_sensitive(false);
previewStack_.set_visible_child("placeholder");
}
void EffectorWindow::onNewProject() {
Project fresh;
fresh.stages.push_back({effects_.front(), effects_.front()->defaults()});
applyProject(fresh, "");
}
void EffectorWindow::onOpenProject() {
auto dialog = Gtk::FileDialog::create();
dialog->set_title("Open project");
dialog->set_filters(projectFilters());
dialog->open(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onProjectChosen(result, dialog);
});
}
void EffectorWindow::onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
std::string path;
try {
const auto file = dialog->open_finish(result);
if (!file) return;
path = file->get_path();
} catch (const Glib::Error&) {
return; // cancelled
}
try {
applyProject(loadProject(path), path);
} catch (const std::exception& error) {
showError(std::string("Could not open the project: ") + error.what());
}
}
void EffectorWindow::onSaveProject() {
if (projectPath_.empty()) {
onSaveProjectAs();
return;
}
writeProject(projectPath_);
}
void EffectorWindow::onSaveProjectAs() {
auto dialog = Gtk::FileDialog::create();
dialog->set_title("Save project");
dialog->set_filters(projectFilters());
dialog->set_initial_name(projectPath_.empty()
? "effector-" + chainSlug(stages_) + ".json"
: std::filesystem::path(projectPath_).filename().string());
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveProjectChosen(result, dialog);
});
}
void EffectorWindow::onSaveProjectChosen(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(".json");
writeProject(path.string());
}
void EffectorWindow::writeProject(const std::string& path) {
try {
saveProject(path, currentProject());
} catch (const std::exception& error) {
showError(std::string("Could not save the project: ") + error.what());
return;
}
clearError();
projectPath_ = path;
updateTitle();
status_.set_text("Saved " + path);
}
void EffectorWindow::updateTitle() {
if (projectPath_.empty()) {
set_title("Effector");
return;
}
set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector");
}
// --------------------------------------------------------------- saving ---
void EffectorWindow::onSave() {
if (result_.empty()) return;
auto dialog = Gtk::FileDialog::create();
dialog->set_title("Save image");
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + 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