#include "gui/window.hpp" #include #include #include #include #include #include #include #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> projectFilters() { auto filter = Gtk::FileFilter::create(); filter->set_name("Effector projects"); filter->add_pattern("*.json"); auto filters = Gio::ListStore::create(); filters->append(filter); return filters; } Glib::RefPtr 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(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(); // 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(); 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(); set_titlebar(*header); menuButton_.set_icon_name("open-menu-symbolic"); menuButton_.set_tooltip_text("Project menu"); menuButton_.set_popover(static_cast(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::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::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::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(); 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::Orientation::HORIZONTAL)); sourceList_.set_selection_mode(Gtk::SelectionMode::NONE); sourceList_.add_css_class("boxed-list"); auto* empty = Gtk::make_managed("No images yet"); empty->set_margin_top(12); empty->set_margin_bottom(12); sourceList_.set_placeholder(*empty); auto* scroller = Gtk::make_managed(); 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::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::Orientation::HORIZONTAL, 6); addBox->append(*Gtk::make_managed( Gio::ThemedIcon::create("list-add-symbolic"))); addBox->append(*Gtk::make_managed("Add images")); auto* addButton = Gtk::make_managed(); 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")); sidebar->append(buildCanvasSection()); auto* seedBox = Gtk::make_managed(Gtk::Orientation::HORIZONTAL, 8); auto* seedLabel = Gtk::make_managed("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; } // 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() { sourcesExpander_.set_visible(chainNeedsSources(stages_)); } // 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) + ")"); } // 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::Orientation::VERTICAL, 6); // Index 0 is "Custom"; every later index is canvasFormats()[index - 1]. std::vector 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(); 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); } 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("Width"); widthLabel->set_xalign(0.0F); auto* heightLabel = Gtk::make_managed("Height"); heightLabel->set_xalign(0.0F); auto* dpiLabel = Gtk::make_managed("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(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(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(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 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(); overlay->set_hexpand(true); overlay->set_vexpand(true); overlay->set_child(previewStack_); overlay->add_overlay(spinner_); auto* box = Gtk::make_managed(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(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("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(spec.label); label->set_xalign(0.0F); std::string help = spec.help; if (shared) { label->set_markup(Glib::Markup::escape_text(spec.label) + " (style)"); 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 widget; switch (spec.type) { case ParamSpec::Type::Bool: { auto toggle = std::make_unique(); 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(); 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(paramInt(values, spec.key, static_cast(spec.defaultValue))) : paramDouble(values, spec.key, spec.defaultValue)); spin->signal_value_changed().connect( sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); 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::Orientation::HORIZONTAL, 6); auto* entry = Gtk::make_managed(); 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("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(); 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::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 choices(spec.choices.begin(), spec.choices.end()); auto dropdown = std::make_unique(); 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(selectedStage_)].values; for (const auto& entry : paramWidgets_) { switch (entry.spec.type) { case ParamSpec::Type::Bool: values[entry.spec.key] = static_cast(entry.widget)->get_active(); break; case ParamSpec::Type::Int: values[entry.spec.key] = static_cast(static_cast(entry.widget)->get_value()); break; case ParamSpec::Type::Double: values[entry.spec.key] = static_cast(entry.widget)->get_value(); break; case ParamSpec::Type::Path: case ParamSpec::Type::Text: values[entry.spec.key] = std::string(static_cast(entry.widget)->get_text()); break; case ParamSpec::Type::Color: { const auto colour = static_cast(entry.widget)->get_rgba(); const std::uint8_t rgb[3] = { static_cast(std::lround(colour.get_red() * 255.0)), static_cast(std::lround(colour.get_green() * 255.0)), static_cast(std::lround(colour.get_blue() * 255.0))}; values[entry.spec.key] = formatColor(rgb); break; } case ParamSpec::Type::Choice: { const auto index = static_cast(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(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 EffectorWindow::styleNames(const std::string& kind) const { std::vector names; for (const auto& style : styles_) { if (style.kind == kind) names.push_back(style.name); } return names; } Style* EffectorWindow::selectedStyle() { return const_cast(std::as_const(*this).selectedStyle()); } const Style* EffectorWindow::selectedStyle() const { if (stages_.empty()) return nullptr; const auto& stage = stages_[static_cast(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 EffectorWindow::buildStyleRow(const ChainStage& stage) { auto box = std::make_unique(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 entries{"Custom"}; for (const auto& name : names) entries.emplace_back(name); entries.emplace_back("New style…"); auto* drop = Gtk::make_managed(); 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("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("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(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(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(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(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& accept) { closeStyleWindow(); if (stages_.empty()) return; const std::string kind = stages_[static_cast(selectedStage_)].effect->styleKind(); styleWindow_ = std::make_shared(); 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::Orientation::VERTICAL, 12); box->set_margin(18); auto* entry = Gtk::make_managed(); entry->set_text(initial); entry->set_activates_default(false); box->append(*entry); styleNameEntry_ = entry; auto* error = Gtk::make_managed(""); 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::Orientation::HORIZONTAL, 6); buttons->set_halign(Gtk::Align::END); auto* cancel = Gtk::make_managed("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("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 void EffectorWindow::mutateSources(Mutate&& mutate) { auto next = std::make_shared(); if (sources_.use_count() == 1) { *next = std::move(const_cast(*sources_)); } else { *next = *sources_; } mutate(*next); sources_ = std::move(next); } void EffectorWindow::addPaths(const std::vector& 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 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 lock(loadMutex_); loadCurrent_ = path; } loadDone_.store(done, std::memory_order_relaxed); loadProgress_.emit(); }; std::vector failures; auto images = loadImages(paths, failures, &progress); { std::lock_guard 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 lock(loadMutex_); current = loadCurrent_; } if (loadBar_ != nullptr && loadTotal_ > 0) { loadBar_->set_fraction(static_cast(done) / static_cast(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 images; std::vector failures; { std::lock_guard 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(); 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::Orientation::VERTICAL, 12); box->set_margin(18); auto* heading = Gtk::make_managed("Reading source images…"); heading->set_xalign(0.0F); heading->add_css_class("heading"); box->append(*heading); auto* bar = Gtk::make_managed(); bar->set_show_text(true); bar->set_text("0 of " + std::to_string(loadTotal_)); box->append(*bar); loadBar_ = bar; auto* file = Gtk::make_managed(""); 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("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(); auto* box = Gtk::make_managed(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(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(); 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(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::create(); filters->append(filter); dialog->set_filters(filters); dialog->set_default_filter(filter); dialog->open_multiple(*this, [this, dialog](const Glib::RefPtr& result) { onImagesChosen(result, dialog); }); } void EffectorWindow::onImagesChosen(const Glib::RefPtr& result, const Glib::RefPtr& dialog) { std::vector 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::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("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(); 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::Orientation::HORIZONTAL, 6); addBox->append(*Gtk::make_managed( Gio::ThemedIcon::create("list-add-symbolic"))); addBox->append(*Gtk::make_managed("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(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(); auto* list = Gtk::make_managed(Gtk::Orientation::VERTICAL, 2); for (const auto& effect : effects_) { auto* item = Gtk::make_managed(effect->title()); item->add_css_class("flat"); item->set_tooltip_text(effect->description()); if (auto* child = dynamic_cast(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(stages_.size()) - 1; rebuildChainRows(); buildParamWidgets(); requestRender(); } void EffectorWindow::removeStage(int index) { if (index < 0 || static_cast(index) >= stages_.size()) return; stages_.erase(stages_.begin() + index); selectedStage_ = std::min(selectedStage_, static_cast(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(index) >= stages_.size()) return; if (target < 0 || static_cast(target) >= stages_.size()) return; std::swap(stages_[static_cast(index)], stages_[static_cast(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); // 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(running.front() - stages_.data()); for (std::size_t i = 0; i < stages_.size(); ++i) { const int index = static_cast(i); auto* row = Gtk::make_managed(); auto* box = Gtk::make_managed(Gtk::Orientation::HORIZONTAL, 4); box->set_margin_top(4); box->set_margin_bottom(4); 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(); 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(std::to_string(index + 1) + ". " + stages_[i].effect->title()); 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(); 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(index) + 1 < stages_.size()); box->append(*button); } row->set_child(*box); chainList_.append(*row); } selectStage(selectedStage_); suspendStageSelect_ = false; updateSourcesVisible(); } // 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(index) >= stages_.size()) return; auto& stage = stages_[static_cast(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 --- void EffectorWindow::onParamChanged() { storeParams(); 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::create(); filters->append(filter); dialog->set_filters(filters); dialog->set_default_filter(filter); dialog->open(*this, [this, dialog, stage, key](const Glib::RefPtr& 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(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(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 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(widthSpin_.get_value()); const int height = static_cast(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( std::chrono::steady_clock::now() - start) .count(); { std::lock_guard 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 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()); status_.set_tooltip_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[] = {{"n", "win.new-project"}, {"o", "win.open-project"}, {"s", "win.save-project"}, {"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(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(widthSpin_.get_value()); project.height = static_cast(heightSpin_.get_value()); project.format = canvasFormatKey(); project.dpi = static_cast(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& result) { onProjectChosen(result, dialog); }); } void EffectorWindow::onProjectChosen(const Glib::RefPtr& result, const Glib::RefPtr& 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& result) { onSaveProjectChosen(result, dialog); }); } void EffectorWindow::onSaveProjectChosen(const Glib::RefPtr& result, const Glib::RefPtr& 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& result) { onSaveChosen(result, dialog); }); } void EffectorWindow::onSaveChosen(const Glib::RefPtr& result, const Glib::RefPtr& 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