Files
effector/src/gui/window.cpp
T
SiandClaude Opus 5.5 7d122ddd6c Keep the params' scrollbar clear of the spin buttons
The overlay scrollbar sat over the "+" of each spin button and took
clicks meant for it; give the param grid a gutter on its right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:48:33 +02:00

2100 lines
81 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 <cmath>
#include <cstdint>
#include <filesystem>
#include <sstream>
#include <utility>
#include "core/canvas.hpp"
#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;
}
// A GtkSpinButton takes every wheel turn over it: it grabs the focus and
// spins its value, so scrolling down a column of params stops dead at the
// first spin under the pointer and quietly changes it. This hands the wheel to
// the view instead unless the spin already has the focus -- the user clicked
// into it and means to adjust it. It runs in the capture phase, ahead of the
// spin's own controller, and so has to scroll the enclosing ScrolledWindow
// itself: stopping the event there stops it for the window as well.
void scrollPastUnlessFocused(Gtk::SpinButton& spin) {
auto scroll = Gtk::EventControllerScroll::create();
scroll->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
scroll->set_flags(Gtk::EventControllerScroll::Flags::VERTICAL);
auto* controller = scroll.get();
scroll->signal_scroll().connect(
[&spin, controller](double, double dy) {
if ((spin.get_state_flags() & Gtk::StateFlags::FOCUS_WITHIN) == Gtk::StateFlags::FOCUS_WITHIN) {
return false;
}
auto* scroller = spin.get_ancestor(GTK_TYPE_SCROLLED_WINDOW);
if (scroller == nullptr) return true;
auto adjustment = static_cast<Gtk::ScrolledWindow*>(scroller)->get_vadjustment();
// The step a ScrolledWindow itself takes for one notch of a wheel;
// a touchpad already reports its deltas in pixels.
double step = dy;
if (controller->get_unit() == Gdk::ScrollUnit::WHEEL) {
step *= std::pow(adjustment->get_page_size(), 2.0 / 3.0);
}
adjustment->set_value(std::clamp(adjustment->get_value() + step, adjustment->get_lower(),
adjustment->get_upper() - adjustment->get_page_size()));
return true;
},
false);
spin.add_controller(scroll);
}
} // namespace
EffectorWindow::EffectorWindow(const Options& initial) {
set_title("Effector");
set_default_size(1180, 760);
effects_ = effects();
// The chain starts empty: the interface opens on "No effects yet" and the
// user picks the first stage, rather than being handed one at random.
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();
chainSplitFit_.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 sidebar is two tabs. "Chain" is what the interface is about: the stages
// and the params of the selected one, with the whole height to themselves.
// "Setup" holds what is set once per project -- the sources, the canvas and
// the seed -- so it never takes room from the chain however long that grows.
Gtk::Widget& EffectorWindow::buildSidebar() {
auto* stack = Gtk::make_managed<Gtk::Stack>();
stack->set_vexpand(true);
stack->set_transition_type(Gtk::StackTransitionType::CROSSFADE);
stack->add(buildChainTab(), "chain", "Chain");
stack->add(buildSetupTab(), "setup", "Setup");
auto* switcher = Gtk::make_managed<Gtk::StackSwitcher>();
switcher->set_stack(*stack);
switcher->set_halign(Gtk::Align::CENTER);
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sidebar->set_margin(12);
sidebar->append(*switcher);
sidebar->append(*stack);
return *sidebar;
}
// The chain and the params of the selected stage share the tab through a
// split. Until the user drags it, the split rests under the chain's rows
// (fitChainSplit), so a short chain leaves the tab to the params and a long
// one grows up to chainListMaxHeight and then scrolls; once dragged, it stays
// where it was put.
// The tallest the chain list grows on its own before it scrolls, in pixels:
// room for eight stages.
constexpr int chainListMaxHeight = 270;
Gtk::Widget& EffectorWindow::buildChainTab() {
auto* params = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
params->set_margin_top(12);
paramHeading_.set_xalign(0.0F);
paramHeading_.add_css_class("heading");
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_);
paramScroller->set_vexpand(true);
params->append(*paramScroller);
auto& chain = buildChainSection();
chain.set_margin_bottom(6);
auto* split = Gtk::make_managed<Gtk::Paned>(Gtk::Orientation::VERTICAL);
split->set_start_child(chain);
split->set_end_child(*params);
split->set_resize_start_child(false);
split->set_resize_end_child(true);
split->set_shrink_start_child(false);
split->set_shrink_end_child(false);
chainSplit_ = split;
split->property_position().signal_changed().connect([this] { onChainSplitMoved(); });
// The room the split has changes with the window, and a fit it had to
// clamp may fit whole now.
split->property_max_position().signal_changed().connect([this] { scheduleChainSplitFit(); });
// A double-click on the handle undoes the drag: the split fits the rows
// again, and follows them from then on. The handle is the one child of the
// split that is neither pane, but the click is heard on the split itself,
// grouped with the split's own drag: that drag claims every press on the
// handle, which would cancel a click gesture anywhere else.
Gtk::Widget* handle = nullptr;
for (auto* child = split->get_first_child(); child != nullptr; child = child->get_next_sibling()) {
if (child != &chain && child != params) handle = child;
}
if (handle != nullptr) {
handle->set_tooltip_text("Drag to share the room, double-click to fit the chain");
auto click = Gtk::GestureClick::create();
click->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
// The split's drag ends every press on the handle, which starts the
// click's count over, so the second press is counted here. And that
// drag is still going on during the second press -- the least jitter
// puts the handle back under the pointer -- so the fit waits for it
// to end.
std::vector<Glib::RefPtr<Gtk::GestureDrag>> drags;
const auto controllers = split->observe_controllers();
for (guint i = 0; i < controllers->get_n_items(); ++i) {
auto other = std::dynamic_pointer_cast<Gtk::Gesture>(controllers->get_object(i));
if (other) click->group(other);
if (auto drag = std::dynamic_pointer_cast<Gtk::GestureDrag>(other)) drags.push_back(drag);
}
auto lastPress = std::make_shared<guint32>(0);
auto refitPending = std::make_shared<bool>(false);
for (const auto& drag : drags) {
drag->signal_drag_end().connect([this, refitPending](double, double) {
if (!*refitPending) return;
*refitPending = false;
refitChainSplit();
});
}
auto* gesture = click.get(); // not the RefPtr: the gesture owns this slot
const bool waitForDrag = !drags.empty();
click->signal_pressed().connect(
[this, split, handle, gesture, lastPress, refitPending, waitForDrag](int, double x, double y) {
// The handle is thin, and the drag takes presses a little past it.
const auto bounds = handle->compute_bounds(*split);
constexpr double slack = 6.0;
if (!bounds || y < bounds->get_y() - slack ||
y > bounds->get_y() + bounds->get_height() + slack ||
x < bounds->get_x() || x > bounds->get_x() + bounds->get_width()) {
*lastPress = 0;
return;
}
const guint32 now = gesture->get_current_event_time();
const int doubleClick = Gtk::Settings::get_default()->property_gtk_double_click_time();
if (*lastPress != 0 && now - *lastPress <= static_cast<guint32>(doubleClick)) {
*lastPress = 0;
if (waitForDrag) {
*refitPending = true;
} else {
refitChainSplit();
}
} else {
*lastPress = now;
}
});
split->add_controller(click);
}
rebuildChainRows();
buildParamWidgets();
return *split;
}
// The sources come first because they are the first thing a project needs;
// their list takes whatever height the canvas and seed leave over.
Gtk::Widget& EffectorWindow::buildSetupTab() {
auto* tab = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sourceList_.set_selection_mode(Gtk::SelectionMode::NONE);
sourceList_.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_vexpand(true);
sourcesHeading_.set_xalign(0.0F);
sourcesHeading_.add_css_class("heading");
sourcesSection_.set_name("sources");
sourcesSection_.set_orientation(Gtk::Orientation::VERTICAL);
sourcesSection_.set_spacing(6);
sourcesSection_.set_vexpand(true);
sourcesSection_.append(sourcesHeading_);
sourcesSection_.append(*scroller);
// Same shape as "Add effect": the icon and the text are put together by
// 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));
sourcesSection_.append(*addButton);
addButton_ = addButton;
updateSourcesLabel();
tab->append(sourcesSection_);
tab->append(*sectionLabel("Canvas"));
tab->append(buildCanvasSection());
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_);
tab->append(*seedBox);
return *tab;
}
// Only the first stage is ever handed the sources, so whether the chain wants
// them at all is `chainNeedsSources`. An effect that starts from nothing (blur,
// text) makes the whole sources section beside the point, so it is hidden
// rather than left there to be filled in vain. This is the same declarative
// answer the render gate uses -- no effect is named here.
void EffectorWindow::updateSourcesVisible() {
sourcesSection_.set_visible(chainNeedsSources(stages_));
}
// The heading carries the count, which a long list scrolled away would hide.
void EffectorWindow::updateSourcesLabel() {
int count = 0;
while (sourceList_.get_row_at_index(count) != nullptr) ++count;
sourcesHeading_.set_text(count == 0 ? "Source images"
: "Source images (" + std::to_string(count) + ")");
}
// The canvas is either a named format — a web size, or a sheet of paper at a
// resolution — or the user's own pixels. A format owns the two spin buttons
// while it is picked, which is why they are shown greyed out rather than
// hidden: the size a format works out to is worth seeing.
Gtk::Widget& EffectorWindow::buildCanvasSection() {
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
// Index 0 is "Custom"; every later index is canvasFormats()[index - 1].
std::vector<Glib::ustring> labels{"Custom"};
for (const auto& format : canvasFormats()) {
labels.push_back(format.label + " (" + canvasFormatDetail(format) + ")");
}
formatDrop_.set_model(Gtk::StringList::create(labels));
formatDrop_.set_selected(0);
formatDrop_.set_tooltip_text("Standard web and print sizes, or your own pixels");
formatDrop_.property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::syncCanvasWidgets));
box->append(formatDrop_);
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);
dpiSpin_.set_range(kMinDpi, kMaxDpi);
dpiSpin_.set_increments(1, 50);
dpiSpin_.set_value(kDefaultDpi);
for (auto* spin : {&widthSpin_, &heightSpin_, &dpiSpin_}) {
spin->set_hexpand(true);
scrollPastUnlessFocused(*spin);
}
for (auto* spin : {&widthSpin_, &heightSpin_}) {
spin->signal_value_changed().connect([this] {
if (!suspendCanvasSync_) onParamChanged();
});
}
// The dpi decides the pixels of a sheet, so moving it goes through the
// same path a format change does.
dpiSpin_.signal_value_changed().connect([this] {
if (!suspendCanvasSync_) syncCanvasWidgets();
});
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);
auto* dpiLabel = Gtk::make_managed<Gtk::Label>("DPI");
dpiLabel->set_xalign(0.0F);
dpiLabel_ = dpiLabel;
grid->attach(*widthLabel, 0, 0);
grid->attach(widthSpin_, 1, 0);
grid->attach(*heightLabel, 0, 1);
grid->attach(heightSpin_, 1, 1);
grid->attach(*dpiLabel, 0, 2);
grid->attach(dpiSpin_, 1, 2);
box->append(*grid);
// Quietly: the sidebar is still being built, and nothing is to be rendered
// until the initial options or a project have had their say.
suspendCanvasSync_ = true;
syncCanvasWidgets();
suspendCanvasSync_ = false;
return *box;
}
std::string EffectorWindow::canvasFormatKey() const {
const auto selected = formatDrop_.get_selected();
if (selected == GTK_INVALID_LIST_POSITION || selected == 0) return {};
const auto index = static_cast<std::size_t>(selected) - 1;
const auto& formats = canvasFormats();
return index < formats.size() ? formats[index].key : std::string();
}
// A key the build does not know — an old project, or a hand-edited file — lands
// on "Custom", which leaves the pixels the project carries alone.
void EffectorWindow::setCanvasFormat(const std::string& key, int dpi) {
const bool suspended = suspendCanvasSync_;
suspendCanvasSync_ = true;
dpiSpin_.set_value(std::clamp(dpi, kMinDpi, kMaxDpi));
guint selected = 0;
const auto& formats = canvasFormats();
for (std::size_t i = 0; i < formats.size(); ++i) {
if (formats[i].key == key) selected = static_cast<guint>(i + 1);
}
formatDrop_.set_selected(selected);
syncCanvasWidgets(); // still suspended, so the spins follow without a render
suspendCanvasSync_ = suspended;
}
void EffectorWindow::syncCanvasWidgets() {
// Suspended means a caller is putting the canvas together — the initial
// options, or a project being opened — and will ask for the render itself.
const bool quiet = suspendCanvasSync_;
const auto* format = canvasFormatByKey(canvasFormatKey());
const bool print = format != nullptr && format->print;
dpiLabel_->set_visible(print);
dpiSpin_.set_visible(print);
widthSpin_.set_sensitive(format == nullptr);
heightSpin_.set_sensitive(format == nullptr);
if (format != nullptr) {
int width = 0;
int height = 0;
canvasFormatSize(*format, static_cast<int>(dpiSpin_.get_value()), width, height);
const bool suspended = suspendCanvasSync_;
suspendCanvasSync_ = true; // one render for the change, not one per spin
widthSpin_.set_value(width);
heightSpin_.set_value(height);
suspendCanvasSync_ = suspended;
}
if (!quiet) onParamChanged();
}
Gtk::Widget& EffectorWindow::buildPreview() {
picture_.set_can_shrink(true);
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
placeholder_.set_text("Add source images under Setup 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);
// A long chain makes a long status line; left to its natural width it
// would widen the preview column past the pane and push the picture off
// centre. Ellipsized, it never asks for more room than it is given.
status_.set_ellipsize(Pango::EllipsizeMode::END);
status_.set_margin_start(8);
status_.set_margin_end(8);
status_.set_margin_top(6);
status_.set_margin_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();
styleDrop_ = nullptr;
if (stages_.empty()) {
paramHeading_.set_text("No effects");
return;
}
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
// The settings of a stage that is switched off stay editable: it is still
// being worked on, it just is not in the picture for the moment.
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
(stage.enabled ? " settings" : " settings (off)"));
int row = 0;
// The style the stage follows, when its effect takes one at all: the
// params it carries are then shown and edited on the style rather than on
// the stage, which is what makes one change move every stage using it.
const Style* style = selectedStyle();
if (!stage.effect->styleKind().empty()) {
auto label = std::make_unique<Gtk::Label>("Style");
label->set_xalign(0.0F);
label->set_tooltip_text(
"A named set of formatting shared by several stages: the font, the colour and the "
"box, but never the words or the placement.");
auto widget = buildStyleRow(stage);
widget->set_hexpand(true);
paramGrid_.attach(*label, 0, row);
paramGrid_.attach(*widget, 1, row);
ownedWidgets_.push_back(std::move(label));
ownedWidgets_.push_back(std::move(widget));
++row;
}
for (const auto& spec : stage.effect->params()) {
// A styled param is read from the style while one is followed, so the
// widgets show what the stage will actually render with.
const bool shared = style != nullptr && spec.inStyle;
const ParamValues& values = shared ? style->values : stage.values;
auto label = std::make_unique<Gtk::Label>(spec.label);
label->set_xalign(0.0F);
std::string help = spec.help;
if (shared) {
label->set_markup(Glib::Markup::escape_text(spec.label) +
" <span alpha='55%'>(style)</span>");
if (!help.empty()) help += "\n\n";
help += "Part of the style \"" + style->name +
"\": changing it changes every stage that follows it.";
}
if (!help.empty()) label->set_tooltip_text(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(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(values, spec.key,
static_cast<long>(spec.defaultValue)))
: paramDouble(values, spec.key, spec.defaultValue));
spin->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
scrollPastUnlessFocused(*spin);
widget = std::move(spin);
break;
}
case ParamSpec::Type::Path: {
// An entry holding the path, with a chooser beside it; the
// entry is what paramValues() reads back.
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
auto* entry = Gtk::make_managed<Gtk::Entry>();
entry->set_hexpand(true);
entry->set_text(paramText(values, spec.key, ""));
entry->set_placeholder_text("No image chosen");
entry->signal_activate().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
auto* choose = Gtk::make_managed<Gtk::Button>("Choose…");
const int index = selectedStage_;
const std::string key = spec.key;
const std::string title = spec.label;
choose->signal_clicked().connect(
[this, index, key, title] { onChoosePath(index, key, title); });
box->append(*entry);
box->append(*choose);
paramWidgets_.push_back({spec, entry});
widget = std::move(box);
break;
}
case ParamSpec::Type::Text: {
auto entry = std::make_unique<Gtk::Entry>();
entry->set_text(paramText(values, spec.key, spec.defaultChoice));
// Re-rendering on every keystroke would start a render per
// letter typed, so the text is taken when the entry is left or
// Enter is pressed.
entry->signal_activate().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
auto focus = Gtk::EventControllerFocus::create();
focus->signal_leave().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
entry->add_controller(focus);
widget = std::move(entry);
break;
}
case ParamSpec::Type::Color: {
auto button = std::make_unique<Gtk::ColorDialogButton>(Gtk::ColorDialog::create());
button->set_halign(Gtk::Align::START);
std::uint8_t rgb[3];
paramColor(values, spec.key, spec.defaultChoice, rgb);
Gdk::RGBA colour;
colour.set_rgba(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0);
button->set_rgba(colour);
button->property_rgba().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(button);
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(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);
// A path attaches a box but reads back from the entry inside it, which
// it has registered itself.
if (spec.type != ParamSpec::Type::Path) 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::Path:
case ParamSpec::Type::Text:
values[entry.spec.key] =
std::string(static_cast<Gtk::Entry*>(entry.widget)->get_text());
break;
case ParamSpec::Type::Color: {
const auto colour = static_cast<Gtk::ColorDialogButton*>(entry.widget)->get_rgba();
const std::uint8_t rgb[3] = {
static_cast<std::uint8_t>(std::lround(colour.get_red() * 255.0)),
static_cast<std::uint8_t>(std::lround(colour.get_green() * 255.0)),
static_cast<std::uint8_t>(std::lround(colour.get_blue() * 255.0))};
values[entry.spec.key] = formatColor(rgb);
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;
auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const ParamValues values = paramValues();
stage.values = values;
// A param the style carries is written to the style too -- that is how the
// look of every stage following it is changed from here. The stage keeps
// its own copy of the same value, so leaving the style, or losing it, does
// not move the picture.
if (Style* style = selectedStyle()) {
for (const auto& spec : stage.effect->params()) {
if (!spec.inStyle) continue;
const auto found = values.find(spec.key);
if (found != values.end()) style->values[spec.key] = found->second;
}
}
}
// ---------------------------------------------------------- text styles ---
std::vector<std::string> EffectorWindow::styleNames(const std::string& kind) const {
std::vector<std::string> names;
for (const auto& style : styles_) {
if (style.kind == kind) names.push_back(style.name);
}
return names;
}
Style* EffectorWindow::selectedStyle() {
return const_cast<Style*>(std::as_const(*this).selectedStyle());
}
const Style* EffectorWindow::selectedStyle() const {
if (stages_.empty()) return nullptr;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
return findStyle(styles_, stage.effect->styleKind(), stage.style);
}
bool EffectorWindow::styleNameTaken(const std::string& kind, const std::string& name) const {
return findStyle(styles_, kind, name) != nullptr;
}
std::unique_ptr<Gtk::Widget> EffectorWindow::buildStyleRow(const ChainStage& stage) {
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
const auto names = styleNames(stage.effect->styleKind());
// "Custom" first so that the stage standing on its own is the plain case,
// and "New style…" last so that the names in between keep their indices.
std::vector<Glib::ustring> entries{"Custom"};
for (const auto& name : names) entries.emplace_back(name);
entries.emplace_back("New style…");
auto* drop = Gtk::make_managed<Gtk::DropDown>();
drop->set_model(Gtk::StringList::create(entries));
drop->set_hexpand(true);
suspendStyleSelect_ = true;
drop->set_selected(0);
for (std::size_t i = 0; i < names.size(); ++i) {
if (names[i] == stage.style) drop->set_selected(i + 1);
}
suspendStyleSelect_ = false;
drop->property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onStyleSelected));
box->append(*drop);
styleDrop_ = drop;
// Renaming and removing only make sense for a style that is being followed.
auto* rename = Gtk::make_managed<Gtk::Button>("Rename…");
rename->set_sensitive(!stage.style.empty());
rename->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRenameStyle));
box->append(*rename);
auto* remove = Gtk::make_managed<Gtk::Button>("Remove");
remove->set_sensitive(!stage.style.empty());
remove->set_tooltip_text(
"Drops the style. Every stage following it keeps the look it has and goes back to its own "
"settings.");
remove->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRemoveStyle));
box->append(*remove);
return box;
}
void EffectorWindow::onStyleSelected() {
if (suspendStyleSelect_ || styleDrop_ == nullptr || stages_.empty()) return;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const auto names = styleNames(stage.effect->styleKind());
const auto index = styleDrop_->get_selected();
if (index == 0) {
adoptStyle("");
} else if (index <= names.size()) {
adoptStyle(names[index - 1]);
} else {
onNewStyle(); // the last entry
}
}
// Pointing the stage at a style, or at nothing. The style's values are copied
// onto the stage as well, so the project file always says what the stage would
// render if the style went away -- which is the same picture.
void EffectorWindow::adoptStyle(const std::string& name) {
if (stages_.empty()) return;
auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
stage.style = name;
if (const Style* style = selectedStyle()) {
for (const auto& spec : stage.effect->params()) {
if (!spec.inStyle) continue;
const auto found = style->values.find(spec.key);
if (found != style->values.end()) stage.values[spec.key] = found->second;
}
}
buildParamWidgets();
requestRender();
}
void EffectorWindow::onNewStyle() {
if (stages_.empty()) return;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const std::string kind = stage.effect->styleKind();
if (kind.empty()) return;
askStyleName("New style", "Style " + std::to_string(styleNames(kind).size() + 1),
[this, kind](const std::string& name) {
// Seeded from the stage it was made on, so the style
// starts as the look that is already on screen.
Style style;
style.kind = kind;
style.name = name;
style.values = styleDefaults(kind);
const auto& current = stages_[static_cast<std::size_t>(selectedStage_)];
for (const auto& spec : current.effect->params()) {
if (!spec.inStyle) continue;
const auto found = current.values.find(spec.key);
if (found != current.values.end()) style.values[spec.key] = found->second;
}
styles_.push_back(std::move(style));
adoptStyle(name);
});
}
void EffectorWindow::onRenameStyle() {
const Style* style = selectedStyle();
if (style == nullptr) return;
const std::string kind = style->kind;
const std::string was = style->name;
askStyleName("Rename style", was, [this, kind, was](const std::string& name) {
if (name == was) return;
for (auto& candidate : styles_) {
if (candidate.kind == kind && candidate.name == was) candidate.name = name;
}
// Every stage that followed the old name follows the new one: a rename
// must not quietly cut a stage loose from its look.
for (auto& stage : stages_) {
if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name;
}
buildParamWidgets();
});
}
void EffectorWindow::onRemoveStyle() {
const Style* style = selectedStyle();
if (style == nullptr) return;
const std::string kind = style->kind;
const std::string name = style->name;
// The stages keep the values they were rendering with, so dropping a style
// changes nothing in the picture -- it only stops the stages moving
// together.
for (auto& stage : stages_) {
if (stage.effect->styleKind() == kind && stage.style == name) stage.style.clear();
}
styles_.erase(std::remove_if(styles_.begin(), styles_.end(),
[&](const Style& candidate) {
return candidate.kind == kind && candidate.name == name;
}),
styles_.end());
buildParamWidgets();
requestRender();
}
// Built from scratch each time it is needed, like the progress window, so the
// entry it holds is a raw pointer that closeStyleWindow() nulls.
void EffectorWindow::askStyleName(const std::string& title, const std::string& initial,
const std::function<void(const std::string&)>& accept) {
closeStyleWindow();
if (stages_.empty()) return;
const std::string kind = stages_[static_cast<std::size_t>(selectedStage_)].effect->styleKind();
styleWindow_ = std::make_shared<Gtk::Window>();
styleWindow_->set_title(title);
styleWindow_->set_transient_for(*this);
styleWindow_->set_modal(true);
styleWindow_->set_resizable(false);
styleWindow_->set_default_size(360, -1);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
box->set_margin(18);
auto* entry = Gtk::make_managed<Gtk::Entry>();
entry->set_text(initial);
entry->set_activates_default(false);
box->append(*entry);
styleNameEntry_ = entry;
auto* error = Gtk::make_managed<Gtk::Label>("");
error->set_xalign(0.0F);
error->add_css_class("dim-label");
error->set_visible(false);
box->append(*error);
styleNameError_ = error;
auto* buttons = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
buttons->set_halign(Gtk::Align::END);
auto* cancel = Gtk::make_managed<Gtk::Button>("Cancel");
cancel->signal_clicked().connect([this] {
closeStyleWindow();
// The dropdown may be sitting on "New style…"; put it back on what the
// stage actually follows.
buildParamWidgets();
});
buttons->append(*cancel);
auto* confirm = Gtk::make_managed<Gtk::Button>("OK");
confirm->add_css_class("suggested-action");
// A name that is empty or already in the pool is refused in place rather
// than silently changed, so two styles can never share a name.
const auto submit = [this, kind, accept] {
if (styleNameEntry_ == nullptr) return;
const std::string name = styleNameEntry_->get_text();
const char* complaint = nullptr;
if (name.empty()) {
complaint = "A style needs a name.";
} else if (styleNameTaken(kind, name)) {
complaint = "There is already a style with that name.";
}
if (complaint != nullptr) {
if (styleNameError_ != nullptr) {
styleNameError_->set_text(complaint);
styleNameError_->set_visible(true);
}
return;
}
closeStyleWindow();
accept(name);
};
confirm->signal_clicked().connect(submit);
entry->signal_activate().connect(submit);
buttons->append(*confirm);
box->append(*buttons);
styleWindow_->signal_close_request().connect(
[this] {
closeStyleWindow();
buildParamWidgets();
return true;
},
false);
styleWindow_->set_child(*box);
styleWindow_->present();
entry->grab_focus();
}
void EffectorWindow::closeStyleWindow() {
styleNameEntry_ = nullptr;
styleNameError_ = nullptr;
if (!styleWindow_) return;
styleWindow_->set_hide_on_close(false);
styleWindow_->hide();
// Closing is asked for by the window's own buttons and by its close
// request, so it is torn down once that signal has finished running rather
// than underneath it.
Glib::signal_idle().connect_once([window = styleWindow_] {});
styleWindow_.reset();
}
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);
setCanvasFormat(initial.format, initial.dpi); // a format fills the spins itself
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 ---
// The move and remove buttons are faded out until the pointer is over their
// row, or the row is selected or focused, so a long chain reads as a list of
// names rather than a column of icons. They keep their place while hidden,
// so the names do not jump as the pointer moves.
const char* const stageRowCss = R"css(
row.stage-row .stage-action {
min-width: 24px;
min-height: 24px;
padding: 0;
opacity: 0;
transition: opacity 120ms;
}
row.stage-row:hover .stage-action,
row.stage-row:selected .stage-action,
row.stage-row:focus-within .stage-action {
opacity: 1;
}
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() {
auto css = Gtk::CssProvider::create();
css->load_from_string(stageRowCss);
Gtk::StyleProvider::add_provider_for_display(Gdk::Display::get_default(), css,
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
// "Add effect" sits beside the heading rather than under the list: under
// it, the split could drag it so far down the screen that the menu of
// effects had no room left to open.
auto* heading = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
auto* title = sectionLabel("Effect chain");
title->set_hexpand(true);
heading->append(*title);
heading->append(addEffectButton_);
box->append(*heading);
chainList_.set_name("chain-list");
chainList_.add_css_class("boxed-list");
chainList_.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(); });
// 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_);
// The list asks for the height of its rows, up to a cap, which is where
// the split above the params rests until it is dragged; the size request
// is only the least it may be dragged down to.
scroller->set_propagate_natural_height(true);
scroller->set_max_content_height(chainListMaxHeight);
scroller->set_size_request(-1, 100);
scroller->set_vexpand(true);
box->append(*scroller);
// 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_name("add-effect");
return *box;
}
// The tallest the menu of effects grows before it scrolls, in pixels.
constexpr int addMenuMaxHeight = 480;
// One entry per registered effect, so a new effect shows up here without this
// 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);
}
// A popover that does not fit on the screen is not shown at all, and the
// list grows with every effect registered, so past a point it scrolls.
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_propagate_natural_height(true);
scroller->set_max_content_height(addMenuMaxHeight);
scroller->set_child(*list);
popover->set_child(*scroller);
return *popover;
}
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 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();
}
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);
stageLabels_.clear();
// The first stage that actually runs is the one handed the sources, which
// is not stage 1 once stage 1 is switched off.
const auto running = enabledStages(stages_);
const int firstRunning =
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
for (std::size_t i = 0; i < stages_.size(); ++i) {
const int index = static_cast<int>(i);
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
row->add_css_class("stage-row");
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 2);
box->set_margin_top(1);
box->set_margin_bottom(1);
box->set_margin_start(8);
box->set_margin_end(4);
// The check is what switches a stage off: it keeps its settings and
// its place in the chain, and the render simply skips it.
auto* check = Gtk::make_managed<Gtk::CheckButton>();
check->set_active(stages_[i].enabled);
check->set_tooltip_text("Apply this effect");
check->set_valign(Gtk::Align::CENTER);
// Connected after set_active, so filling in the rebuilt rows does not
// look like the user reaching for the check.
check->signal_toggled().connect([this, index, check] {
setStageEnabled(index, check->get_active());
});
box->append(*check);
auto* label = Gtk::make_managed<Gtk::Label>(stageLabel(i));
stageLabels_.push_back(label);
label->set_xalign(0.0F);
label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END);
if (!stages_[i].enabled) label->add_css_class("dim-label");
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
: index == firstRunning ? "Applied to the source images"
: "Applied to the output so far");
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");
button->add_css_class("stage-action");
button->set_valign(Gtk::Align::CENTER);
const int delta = action.delta;
button->signal_clicked().connect([this, index, delta] {
if (delta == 0) {
removeStage(index);
} else {
moveStage(index, 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);
// 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);
}
selectStage(selectedStage_);
suspendStageSelect_ = false;
updateSourcesVisible();
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() {
if (chainSplitDragged_ || chainSplitFit_.connected()) return;
chainSplitFit_ = Glib::signal_idle().connect([this] {
fitChainSplit();
return false;
});
}
// GtkPaned would size the fixed pane from its minimum height, which is only
// the floor it may be dragged down to, so the fit to the rows' natural height
// is done here.
void EffectorWindow::fitChainSplit() {
if (chainSplit_ == nullptr || chainSplitDragged_) return;
auto* chain = chainSplit_->get_start_child();
if (chain == nullptr) return;
int minimum = 0;
int natural = 0;
int minimumBaseline = 0;
int naturalBaseline = 0;
const int width = chainSplit_->get_width();
chain->measure(Gtk::Orientation::VERTICAL, width > 0 ? width : -1, minimum, natural,
minimumBaseline, naturalBaseline);
fittedChainSplit_ = natural;
suspendSplitTrack_ = true;
chainSplit_->set_position(natural);
suspendSplitTrack_ = false;
}
void EffectorWindow::refitChainSplit() {
chainSplitDragged_ = false;
fitChainSplit();
}
// Any move but a fit, or GTK clamping a fit into a window too short for it,
// is the user's drag.
void EffectorWindow::onChainSplitMoved() {
if (suspendSplitTrack_ || fittedChainSplit_ < 0) return;
const int position = chainSplit_->get_position();
if (position == fittedChainSplit_) return;
const int lowest = chainSplit_->property_min_position();
const int highest = chainSplit_->property_max_position();
if (fittedChainSplit_ > highest && position == highest) return;
if (fittedChainSplit_ < lowest && position == lowest) return;
chainSplitDragged_ = true;
chainSplitFit_.disconnect();
}
// Switching a stage off leaves everything about it alone -- its settings, its
// place in the chain, the stage the params below are showing -- so the only
// visible change is the dimmed row and the picture the render comes back with.
void EffectorWindow::setStageEnabled(int index, bool enabled) {
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
auto& stage = stages_[static_cast<std::size_t>(index)];
if (stage.enabled == enabled) return;
stage.enabled = enabled;
rebuildChainRows(); // the row dims, and another stage may now lead the chain
if (index == selectedStage_) buildParamWidgets(); // the heading says "off"
requestRender();
}
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 ---
std::string EffectorWindow::stageLabel(std::size_t index) const {
const ChainStage& stage = stages_[index];
return std::to_string(index + 1) + ". " + stage.effect->label(stageValues(stage, styles_));
}
void EffectorWindow::onParamChanged() {
storeParams();
// A param can be what names the stage (the words of a text stage), and a
// styled one can rename every stage sharing the style.
for (std::size_t i = 0; i < stageLabels_.size() && i < stages_.size(); ++i)
stageLabels_[i]->set_text(stageLabel(i));
requestRender();
}
void EffectorWindow::onChoosePath(int stage, const std::string& key, const std::string& label) {
auto dialog = Gtk::FileDialog::create();
dialog->set_title("Select the " + label);
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(*this, [this, dialog, stage, key](const Glib::RefPtr<Gio::AsyncResult>& result) {
std::string path;
try {
const auto file = dialog->open_finish(result);
if (file) path = file->get_path();
} catch (const Glib::Error&) {
return; // cancelled
}
if (path.empty() || stage < 0 || static_cast<std::size_t>(stage) >= stages_.size()) return;
// The widgets may be showing another stage by now: store what they hold
// first, then write the path into the stage that asked for it.
storeParams();
stages_[static_cast<std::size_t>(stage)].values[key] = path;
if (stage == selectedStage_) buildParamWidgets();
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 (enabledStages(stages_).empty()) return false;
return !sources_->empty() || !chainNeedsSources(stages_);
}
std::string EffectorWindow::missingText() const {
if (stages_.empty()) return "Add an effect to start.";
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
return "Add source images under Setup to start.";
}
void EffectorWindow::showPlaceholder() {
result_ = Image();
saveButton_.set_sensitive(false);
placeholder_.set_text(missingText());
previewStack_.set_visible_child("placeholder");
status_.set_text("");
status_.set_tooltip_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 auto styles = styles_; // the same, so the worker reads nothing live
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, styles, 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, styles);
} 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);
// The line itself stays short; the full chain and setup go in the tooltip.
const std::size_t effects = enabledStages(stages_).size();
std::ostringstream status;
status.setf(std::ios::fixed);
status.precision(1);
status << effects << (effects == 1 ? " effect" : " effects") << " · " << millis << " ms";
std::ostringstream details;
details << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · ";
if (!sources_->empty()) {
details << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
}
details << "seed " << seed_;
status_.set_text(status.str());
status_.set_tooltip_text(details.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.styles = styles_;
project.width = static_cast<int>(widthSpin_.get_value());
project.height = static_cast<int>(heightSpin_.get_value());
project.format = canvasFormatKey();
project.dpi = static_cast<int>(dpiSpin_.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;
closeStyleWindow(); // it names a style of the project being replaced
clearSources();
stages_ = project.stages;
// Before the rows and the params: both ask which style a stage follows.
styles_ = project.styles;
selectedStage_ = 0;
rebuildChainRows();
buildParamWidgets();
widthSpin_.set_value(project.width);
heightSpin_.set_value(project.height);
setCanvasFormat(project.format, project.dpi);
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() {
applyProject(Project(), ""); // same empty chain the window opens with
}
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);
status_.set_tooltip_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());
status_.set_tooltip_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