diff --git a/CHANGELOG.md b/CHANGELOG.md index f77e195..b2adca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ commit that made it. The interface shows this file under Changelog in its menu. - The Font of a text stage (and of a text style) is picked from the fonts installed on this machine with a font chooser, rather than typed by name. The command line still takes any family, as `--font-family "DejaVu Serif"`. +- Undo and redo: Ctrl+Z steps back through the edits to the project (the + chain, params, styles, canvas, video settings, seed and images), and Ctrl+Y + or Ctrl+Shift+Z steps forward again; both are in the menu as well. A burst + of edits, like a spin held down or a word typed, is one step. ## [0.8.0] - 2026-09-26 diff --git a/CLAUDE.md b/CLAUDE.md index 2c8f568..a77ce9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,6 +175,16 @@ the `effector` application name on each entry; the GUI tests point `XDG_DATA_HOME` at a temporary folder so they never touch the user's list. Opening one replaces the sources, so a decode still in flight is discarded rather than mixed into the new project (`dropLoadedImages_`). +Undo and redo (Ctrl+Z, Ctrl+Y / Ctrl+Shift+Z, caught in the capture phase so +an entry never undoes its own text instead) keep whole snapshots of the +project: `captureState` is `currentProject()` plus the `sources_` list itself, +so stepping back never decodes again, compared by `projectToJson`. There is +nothing to call per edit: `requestRender()` calls `noteChange()`, which commits +a step once the controls have been still for `kHistoryPause`; an edit that +renders nothing (renaming a style) calls `noteChange()` itself. New and Open +start the history over, after the project's images are in +(`historyAfterLoad_`). + ### Video projects A project is a still or a clip: `VideoSettings` (`src/core/video.hpp`: enabled, diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 0e805e4..d63c4e5 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -29,6 +29,11 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "* const char* const kRecentApp = "effector"; const std::size_t kRecentCount = 10; +// Undo keeps this many steps, and an edit becomes one once the controls have +// been still this long (ms): a spin held down or a word typed is one step. +const std::size_t kHistoryDepth = 100; +constexpr unsigned kHistoryPause = 500; + Glib::RefPtr> projectFilters() { auto filter = Gtk::FileFilter::create(); filter->set_name("Effector projects"); @@ -116,13 +121,21 @@ EffectorWindow::EffectorWindow(const Options& initial) { renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished)); loadProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadProgress)); - loadFinished_.connect(sigc::mem_fun(*this, &EffectorWindow::onLoadFinished)); + loadFinished_.connect([this] { + onLoadFinished(); + if (historyAfterLoad_ && !loading_) { + historyAfterLoad_ = false; + resetHistory(); + } + }); clipProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onClipProgress)); exportProgress_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportProgress)); exportDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onExportFinished)); buildUi(); applyInitial(initial); + historyAfterLoad_ = loading_; + resetHistory(); suspendRender_ = false; if (loading_) { @@ -136,6 +149,7 @@ EffectorWindow::EffectorWindow(const Options& initial) { EffectorWindow::~EffectorWindow() { debounce_.disconnect(); + historyCommit_.disconnect(); chainSplitFit_.disconnect(); recentChanged_.disconnect(); loadWindowDelay_.disconnect(); @@ -594,6 +608,11 @@ Gtk::Widget& EffectorWindow::buildVideoSection() { videoQualityDrop_.set_selected(standard); videoQualityDrop_.set_hexpand(true); videoQualityDrop_.set_tooltip_text(qualityTooltip); + // Only the export hears of the quality, so it asks for no render; it is + // still part of the project, and of its history. + videoQualityDrop_.property_selected().signal_changed().connect([this] { + if (!suspendVideoSync_) noteChange(); + }); auto* qualityLabel = Gtk::make_managed("Quality"); qualityLabel->set_xalign(0.0F); grid->attach(*qualityLabel, 0, 2); @@ -1245,6 +1264,7 @@ void EffectorWindow::onRenameStyle() { if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name; } buildParamWidgets(); + noteChange(); // the picture is the same, so no render asks for it }); } @@ -2214,6 +2234,7 @@ void EffectorWindow::onRecreate() { seed_ = Rng::randomSeed(); seedEntry_.set_text(std::to_string(seed_)); if (!canRender()) { + noteChange(); showError(missingText()); return; } @@ -2250,6 +2271,9 @@ void EffectorWindow::showPlaceholder() { } void EffectorWindow::requestRender() { + // Every edit of the project asks for a render, so this is where the + // history hears of them. + noteChange(); if (suspendRender_) return; if (!canRender()) { showPlaceholder(); @@ -2437,10 +2461,16 @@ Gtk::Widget& EffectorWindow::buildMainMenu() { }); // Never enabled: it only stands in for an empty list. add_action("no-recent")->set_enabled(false); + undoAction_ = add_action("undo", sigc::mem_fun(*this, &EffectorWindow::undo)); + redoAction_ = add_action("redo", sigc::mem_fun(*this, &EffectorWindow::redo)); add_action("show-changelog", sigc::mem_fun(*this, &EffectorWindow::showChangelog)); add_action("about", sigc::mem_fun(*this, &EffectorWindow::showAbout)); auto menu = Gio::Menu::create(); + auto edit = Gio::Menu::create(); + edit->append("Undo", "win.undo"); + edit->append("Redo", "win.redo"); + menu->append_section(edit); auto open = Gio::Menu::create(); open->append("New Project", "win.new-project"); open->append("Open Project…", "win.open-project"); @@ -2476,6 +2506,20 @@ Gtk::Widget& EffectorWindow::buildMainMenu() { } add_controller(shortcuts); + // Undo and redo are caught before the focused widget sees them: an entry + // would otherwise undo its own text, and a spin button only the digits + // typed into it, rather than the step the project took. + auto history = Gtk::ShortcutController::create(); + history->set_propagation_phase(Gtk::PropagationPhase::CAPTURE); + const Accel historyAccels[] = {{"z", "win.undo"}, + {"y", "win.redo"}, + {"z", "win.redo"}}; + for (const Accel& accel : historyAccels) { + history->add_shortcut(Gtk::Shortcut::create( + Gtk::ShortcutTrigger::parse_string(accel.keys), Gtk::NamedAction::create(accel.action))); + } + add_controller(history); + return *Gtk::make_managed(menu); } @@ -2525,6 +2569,8 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat } else { addPaths(project.sources); // renders once the images are decoded } + historyAfterLoad_ = loading_; + resetHistory(); } void EffectorWindow::clearSources() { @@ -2833,6 +2879,123 @@ void EffectorWindow::updateTitle() { set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector"); } +// -------------------------------------------------------------- history --- + +EffectorWindow::HistoryState EffectorWindow::captureState() const { + HistoryState state; + state.project = currentProject(); + state.json = projectToJson(state.project); + state.sources = sources_; + state.refusedSources = refusedSources_; + state.selectedStage = selectedStage_; + return state; +} + +// Puts a step back the way a project is opened, but with the images already +// in hand: the source list is the very one the step held. +void EffectorWindow::restoreState(const HistoryState& state) { + suspendHistory_ = true; + suspendRender_ = true; + closeStyleWindow(); // it may name a style the step does not hold + const bool wasVideo = isVideo(); + const Project& project = state.project; + + stages_ = project.stages; + styles_ = project.styles; + selectedStage_ = + stages_.empty() ? 0 : std::clamp(state.selectedStage, 0, static_cast(stages_.size()) - 1); + widthSpin_.set_value(project.width); + heightSpin_.set_value(project.height); + setCanvasFormat(project.format, project.dpi); + setVideoSettings(project.video); // rebuilds the chain rows and the params + seed_ = project.seed.value_or(seed_); + seedEntry_.set_text(std::to_string(seed_)); + + if (state.sources != sources_) { + while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row); + sources_ = state.sources; + for (const auto& image : *sources_) appendSourceRow(image.path()); + updateSourcesLabel(); + } + refusedSources_ = state.refusedSources; + + if (isVideo() != wasVideo) { + // What is on screen is the other kind of result, as in onVideoToggled. + result_ = Image(); + saveButton_.set_sensitive(false); + } + clearError(); + suspendRender_ = false; + requestRender(); + suspendHistory_ = false; +} + +void EffectorWindow::noteChange() { + if (suspendHistory_) return; + // Something is waiting to become a step, so there is something to undo + // even before the timer runs. + if (undoAction_) undoAction_->set_enabled(true); + historyCommit_.disconnect(); + historyCommit_ = Glib::signal_timeout().connect( + [this] { + commitHistory(); + return false; + }, + kHistoryPause); +} + +void EffectorWindow::commitHistory() { + historyCommit_.disconnect(); + HistoryState now = captureState(); + if (now.json != committed_.json) { + // The step remembers the stage being worked on as it was left, so + // undoing it lands on that stage again. + committed_.selectedStage = selectedStage_; + undo_.push_back(std::move(committed_)); + if (undo_.size() > kHistoryDepth) undo_.erase(undo_.begin()); + redo_.clear(); + } + committed_ = std::move(now); + updateHistoryActions(); +} + +void EffectorWindow::resetHistory() { + historyCommit_.disconnect(); + undo_.clear(); + redo_.clear(); + committed_ = captureState(); + updateHistoryActions(); +} + +void EffectorWindow::undo() { + commitHistory(); // an edit still waiting on the timer is the step undone + if (undo_.empty()) return; + committed_.selectedStage = selectedStage_; + redo_.push_back(std::move(committed_)); + const HistoryState previous = std::move(undo_.back()); + undo_.pop_back(); + restoreState(previous); + committed_ = captureState(); + updateHistoryActions(); +} + +void EffectorWindow::redo() { + commitHistory(); // a fresh edit clears the redo list, as it should + if (redo_.empty()) return; + committed_.selectedStage = selectedStage_; + undo_.push_back(std::move(committed_)); + const HistoryState next = std::move(redo_.back()); + redo_.pop_back(); + restoreState(next); + committed_ = captureState(); + updateHistoryActions(); +} + +void EffectorWindow::updateHistoryActions() { + if (undoAction_) undoAction_->set_enabled(!undo_.empty()); + if (redoAction_) redoAction_->set_enabled(!redo_.empty()); +} + // --------------------------------------------------------------- saving --- void EffectorWindow::onSave() { diff --git a/src/gui/window.hpp b/src/gui/window.hpp index e17b8b0..5533f3d 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -161,6 +161,26 @@ private: void writeProject(const std::string& path); void updateTitle(); + // Undo and redo. The history is a list of whole snapshots of the project, + // taken a moment after the edits stop, so a spin held down or a word typed + // is one step rather than one per tick. The sources ride along as the + // immutable list they already are, so stepping back never decodes again. + struct HistoryState { + std::string json; // what the step is compared by + Project project; + std::shared_ptr> sources; + std::vector refusedSources; + int selectedStage = 0; + }; + HistoryState captureState() const; + void restoreState(const HistoryState& state); + void noteChange(); // coalesced: restarts the short history timer + void commitHistory(); // the current project becomes a step, if it changed + void resetHistory(); // a new or opened project starts with none + void undo(); + void redo(); + void updateHistoryActions(); + void onParamChanged(); // The chooser behind a Path param. The chosen file goes straight into the // stage's values rather than into the widget, which may well have been @@ -213,6 +233,17 @@ private: sigc::connection recentChanged_; // the recent list outlives the window bool suspendRender_ = true; + std::vector undo_; + std::vector redo_; + HistoryState committed_; // the project as the last step left it + sigc::connection historyCommit_; + bool suspendHistory_ = false; // set while a step is being put back + // The sources of a project being opened are not a step of their own: the + // history starts over once they are in. + bool historyAfterLoad_ = false; + Glib::RefPtr undoAction_; + Glib::RefPtr redoAction_; + // Source loading. Decoding a batch of large photographs takes seconds, so // it runs off the main thread and reports into a small progress window. std::thread loader_; diff --git a/tests/gui_tests.cpp b/tests/gui_tests.cpp index a085e65..2ce9c48 100644 --- a/tests/gui_tests.cpp +++ b/tests/gui_tests.cpp @@ -633,6 +633,127 @@ void testFont(Gtk::Window& window, const std::filesystem::path& dir) { "a family chosen there is saved with the project"); } +bool actionEnabled(Gtk::Window& window, const std::string& name) { + auto action = dynamic_cast(window).lookup_action(name); + return action && action->get_enabled(); +} + +// Whether the window catches `keys` for `action` before the focused widget. +bool capturesShortcut(Gtk::Window& window, const std::string& keys, const std::string& action) { + auto controllers = window.observe_controllers(); + for (guint i = 0; i < controllers->get_n_items(); ++i) { + auto* controller = GTK_EVENT_CONTROLLER(g_list_model_get_item(controllers->gobj(), i)); + const bool capture = gtk_event_controller_get_propagation_phase(controller) == GTK_PHASE_CAPTURE; + if (GTK_IS_SHORTCUT_CONTROLLER(controller) && capture) { + auto* list = G_LIST_MODEL(controller); + for (guint j = 0; j < g_list_model_get_n_items(list); ++j) { + auto* shortcut = GTK_SHORTCUT(g_list_model_get_item(list, j)); + char* trigger = gtk_shortcut_trigger_to_string(gtk_shortcut_get_trigger(shortcut)); + auto* named = gtk_shortcut_get_action(shortcut); + const bool match = trigger == keys && GTK_IS_NAMED_ACTION(named) && + action == gtk_named_action_get_action_name(GTK_NAMED_ACTION(named)); + g_free(trigger); + g_object_unref(shortcut); + if (match) { + g_object_unref(controller); + return true; + } + } + } + g_object_unref(controller); + } + return false; +} + +void testHistory(Gtk::Window& window, const std::filesystem::path& dir) { + std::cout << "undo and redo\n"; + auto* chainList = named(window, "chain-list"); + auto* add = named(window, "add-effect"); + if (chainList == nullptr || add == nullptr) return; + + check(capturesShortcut(window, "z", "win.undo") && + capturesShortcut(window, "y", "win.redo") && + capturesShortcut(window, "z", "win.redo"), + "Ctrl+Z undoes, Ctrl+Y and Ctrl+Shift+Z redo, ahead of the focused widget"); + + window.activate_action("win.new-project"); + pump(700); + check(!actionEnabled(window, "undo") && !actionEnabled(window, "redo"), + "a new project has nothing to undo or redo"); + + addFromMenu(*add, "Blur"); + pump(700); + addFromMenu(*add, "Scanlines"); + pump(700); + check(rowCount(*chainList) == 2 && actionEnabled(window, "undo"), "two stages added"); + + window.activate_action("win.undo"); + pump(); + check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur", + "undo takes the last stage off"); + check(actionEnabled(window, "redo"), "and it can be redone"); + window.activate_action("win.undo"); + pump(); + check(rowCount(*chainList) == 0 && !actionEnabled(window, "undo"), "undo again empties the chain"); + + window.activate_action("win.redo"); + pump(); + window.activate_action("win.redo"); + pump(); + check(rowCount(*chainList) == 2 && rowTitle(*chainList, 1) == "2. Scanlines" && + !actionEnabled(window, "redo"), + "redo puts both back"); + + // Undone straight away, before the edit has settled into a step. + addFromMenu(*add, "Blur"); + check(actionEnabled(window, "undo"), "an edit can be undone at once"); + window.activate_action("win.undo"); + pump(); + check(rowCount(*chainList) == 2, "and undoing it takes that edit back"); + + window.activate_action("win.undo"); + pump(); + addFromMenu(*add, "Blur"); + pump(700); + check(!actionEnabled(window, "redo"), "a new edit after an undo drops what could be redone"); + + // The images of an opened project arrive after it, from the decoder, and + // are part of it rather than a step to undo. + std::filesystem::create_directories(dir); + const auto source = dir / "source.png"; + effector::Image(64, 64).save(source.string()); + effector::Project project; + project.sources.push_back(source.string()); + project.stages.push_back({effector::effectByName("scanlines"), {}}); + const auto path = dir / "history.json"; + effector::saveProject(path.string(), project); + window.activate_action("win.open-recent", + Glib::Variant::create(Glib::filename_to_uri(path.string()))); + pump(1000); + auto* sources = named(window, "sources"); + auto lists = sources != nullptr ? all(*sources) : std::vector{}; + auto* sourceList = lists.empty() ? nullptr : lists.front(); + check(sourceList != nullptr && rowCount(*sourceList) == 1 && !actionEnabled(window, "undo"), + "opening a project with images leaves nothing to undo"); + if (sourceList == nullptr || rowCount(*sourceList) != 1) return; + + // Removing an image is undone with the image still in hand. + auto* stack = all(window).front(); + stack->set_visible_child("setup"); + pump(); + const auto removes = all(*sourceList->get_row_at_index(0)); + if (!removes.empty()) press(*removes.front()); + pump(700); + check(rowCount(*sourceList) == 0, "an image removed"); + window.activate_action("win.undo"); + pump(); + check(rowCount(*sourceList) == 1, "comes back with undo"); + stack->set_visible_child("chain"); + + window.activate_action("win.new-project"); + pump(700); +} + // A project whose images will not fit is refused before any is decoded, and // saving it then must not quietly drop them from the file. void testMemoryGuard(Gtk::Window& window, const std::filesystem::path& dir) { @@ -701,6 +822,7 @@ int main(int argc, char** argv) { testRecentProjects(*window, dataDir / "projects"); testVideo(*window, dataDir / "video"); testFont(*window, dataDir / "font"); + testHistory(*window, dataDir / "history"); testMemoryGuard(*window, dataDir / "memory"); app->remove_window(*window); });