#include "gui/window.hpp" #include #include #include #include #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(); stages_.push_back({effects_.front(), effects_.front()->defaults()}); seed_ = Rng::randomSeed(); renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished)); loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress)); loadFinished_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadFinished)); buildUi(); applyInitial(initial); suspendRender_ = false; if (loading_) { placeholder_.set_text(missingText()); } else if (canRender()) { startRender(); } else { showPlaceholder(); } } EffectorWindow::~EffectorWindow() { debounce_.disconnect(); loadWindowDelay_.disconnect(); loadCancel_.store(true, std::memory_order_relaxed); if (loader_.joinable()) loader_.join(); if (worker_.joinable()) worker_.join(); } // ------------------------------------------------------------------- UI --- void EffectorWindow::buildUi() { auto* header = Gtk::make_managed(); 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")); 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); for (auto* spin : {&widthSpin_, &heightSpin_}) { spin->set_hexpand(true); spin->signal_value_changed().connect(sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); } auto* widthLabel = Gtk::make_managed("Width"); widthLabel->set_xalign(0.0F); auto* heightLabel = Gtk::make_managed("Height"); heightLabel->set_xalign(0.0F); grid->attach(*widthLabel, 0, 0); grid->attach(widthSpin_, 1, 0); grid->attach(*heightLabel, 0, 1); grid->attach(heightSpin_, 1, 1); sidebar->append(*grid); auto* seedBox = Gtk::make_managed(Gtk::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; } // The expander is collapsed by default, so its label carries the count that // used to be plain to see in the list. void EffectorWindow::updateSourcesLabel() { int count = 0; while (sourceList_.get_row_at_index(count) != nullptr) ++count; sourcesExpander_.set_label(count == 0 ? "Source images" : "Source images (" + std::to_string(count) + ")"); } Gtk::Widget& EffectorWindow::buildPreview() { picture_.set_can_shrink(true); picture_.set_content_fit(Gtk::ContentFit::CONTAIN); placeholder_.set_text("Add source images to start."); placeholder_.set_wrap(true); placeholder_.add_css_class("dim-label"); previewStack_.add(placeholder_, "placeholder"); previewStack_.add(picture_, "picture"); previewStack_.set_visible_child("placeholder"); spinner_.set_halign(Gtk::Align::CENTER); spinner_.set_valign(Gtk::Align::CENTER); auto* overlay = Gtk::make_managed(); 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); status_.set_margin_top(6); status_.set_margin_bottom(8); status_.add_css_class("dim-label"); box->append(status_); return *box; } void EffectorWindow::buildParamWidgets() { while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child); paramWidgets_.clear(); ownedWidgets_.clear(); if (stages_.empty()) { paramHeading_.set_text("No effects"); return; } const auto& stage = stages_[static_cast(selectedStage_)]; paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() + " settings"); int row = 0; for (const auto& spec : stage.effect->params()) { auto label = std::make_unique(spec.label); label->set_xalign(0.0F); if (!spec.help.empty()) label->set_tooltip_text(spec.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(stage.values, spec.key, spec.defaultValue != 0.0)); toggle->property_active().signal_changed().connect( sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); widget = std::move(toggle); break; } case ParamSpec::Type::Int: case ParamSpec::Type::Double: { auto spin = std::make_unique(); 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(stage.values, spec.key, static_cast(spec.defaultValue))) : paramDouble(stage.values, spec.key, spec.defaultValue)); spin->signal_value_changed().connect( sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); widget = std::move(spin); break; } case ParamSpec::Type::Choice: { std::vector choices(spec.choices.begin(), spec.choices.end()); auto dropdown = std::make_unique(); dropdown->set_model(Gtk::StringList::create(choices)); const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice); for (std::size_t i = 0; i < spec.choices.size(); ++i) { if (spec.choices[i] == current) dropdown->set_selected(i); } dropdown->property_selected().signal_changed().connect( sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); widget = std::move(dropdown); break; } } widget->set_hexpand(true); paramGrid_.attach(*label, 0, row); paramGrid_.attach(*widget, 1, row); paramWidgets_.push_back({spec, widget.get()}); ownedWidgets_.push_back(std::move(label)); ownedWidgets_.push_back(std::move(widget)); ++row; } } ParamValues EffectorWindow::paramValues() const { if (stages_.empty()) return {}; ParamValues values = stages_[static_cast(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::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; stages_[static_cast(selectedStage_)].values = paramValues(); } void EffectorWindow::applyInitial(const Options& initial) { // `-e scanlines -e blur` starts the interface with that cascade already set // up, flags included. auto stages = buildChain(initial); if (!stages.empty()) stages_ = std::move(stages); selectedStage_ = 0; rebuildChainRows(); buildParamWidgets(); widthSpin_.set_value(initial.width); heightSpin_.set_value(initial.height); if (initial.seed) { seed_ = *initial.seed; seedEntry_.set_text(std::to_string(seed_)); } if (!initial.images.empty()) addPaths(initial.images); } // -------------------------------------------------------------- sources --- // Replaces the source snapshot, moving the images across when nothing else // holds the old list (the common case) and copying only if a render is reading it. template 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); 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); 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); label->set_tooltip_text(index == 0 ? "Applied to the source images" : "Applied to the output of the stage above"); box->append(*label); struct Action { const char* icon; const char* tip; int delta; // 0 means remove }; for (const Action& action : {Action{"go-up-symbolic", "Move up", -1}, Action{"go-down-symbolic", "Move down", 1}, Action{"window-close-symbolic", "Remove", 0}}) { auto* button = Gtk::make_managed(); 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; } void EffectorWindow::selectStage(int index) { if (auto* row = chainList_.get_row_at_index(index)) chainList_.select_row(*row); } void EffectorWindow::onStageSelected() { if (suspendStageSelect_) return; auto* row = chainList_.get_selected_row(); if (row == nullptr) return; if (row->get_index() == selectedStage_) return; selectedStage_ = row->get_index(); buildParamWidgets(); } // ------------------------------------------------------------ rendering --- void EffectorWindow::onParamChanged() { storeParams(); requestRender(); } void EffectorWindow::onSeedEntered() { try { seed_ = std::stoull(seedEntry_.get_text()); } catch (const std::exception&) { seedEntry_.set_text(std::to_string(seed_)); return; } requestRender(); } void EffectorWindow::onRecreate() { seed_ = Rng::randomSeed(); seedEntry_.set_text(std::to_string(seed_)); if (!canRender()) { showError(missingText()); return; } requestRender(); } // Whether the effect chain has everything it needs. Only the first stage is // ever handed the sources, so an effect that does not want them (blur) renders // happily with none loaded. bool EffectorWindow::canRender() const { if (stages_.empty()) return false; return !sources_->empty() || !chainNeedsSources(stages_); } std::string EffectorWindow::missingText() const { if (stages_.empty()) return "Add an effect to start."; return "Add source images to start."; } void EffectorWindow::showPlaceholder() { result_ = Image(); saveButton_.set_sensitive(false); placeholder_.set_text(missingText()); previewStack_.set_visible_child("placeholder"); status_.set_text(""); } void EffectorWindow::requestRender() { if (suspendRender_) return; if (!canRender()) { showPlaceholder(); return; } // Spin buttons fire on every step while they are held down; coalesce those // into one render instead of queueing a job per tick. debounce_.disconnect(); debounce_ = Glib::signal_timeout().connect( [this] { startRender(); return false; }, 120); } void EffectorWindow::startRender() { if (!canRender()) return; if (rendering_) { staleResult_ = true; // a fresh render follows this one return; } rendering_ = true; const auto sources = sources_; const auto stages = stages_; // a snapshot, like the sources const int width = static_cast(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, width, height, seed] { Image image; std::string error; const auto start = std::chrono::steady_clock::now(); try { Rng rng(seed); image = renderChain(stages, *sources, width, height, rng); } catch (const std::exception& exception) { error = exception.what(); } const auto millis = std::chrono::duration( 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()); } // -------------------------------------------------------------- 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.width = static_cast(widthSpin_.get_value()); project.height = static_cast(heightSpin_.get_value()); project.seed = seed_; return project; } void EffectorWindow::applyProject(const Project& project, const std::string& path) { // One render at the end rather than one per piece of the project. suspendRender_ = true; clearSources(); stages_ = project.stages; selectedStage_ = 0; rebuildChainRows(); buildParamWidgets(); widthSpin_.set_value(project.width); heightSpin_.set_value(project.height); seed_ = project.seed.value_or(Rng::randomSeed()); seedEntry_.set_text(std::to_string(seed_)); projectPath_ = path; updateTitle(); clearError(); suspendRender_ = false; if (project.sources.empty()) { requestRender(); // a chain that needs no sources still has a picture } else { addPaths(project.sources); // renders once the images are decoded } } void EffectorWindow::clearSources() { while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row); updateSourcesLabel(); mutateSources([](SourceList& list) { list.clear(); }); loadQueue_.clear(); if (loading_) dropLoadedImages_ = true; result_ = Image(); saveButton_.set_sensitive(false); previewStack_.set_visible_child("placeholder"); } void EffectorWindow::onNewProject() { Project fresh; fresh.stages.push_back({effects_.front(), effects_.front()->defaults()}); applyProject(fresh, ""); } void EffectorWindow::onOpenProject() { auto dialog = Gtk::FileDialog::create(); dialog->set_title("Open project"); dialog->set_filters(projectFilters()); dialog->open(*this, [this, dialog](const Glib::RefPtr& 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); } 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()); } // --------------------------------------------------------------- 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