Add undo and redo to the interface

Ctrl+Z undoes, Ctrl+Y and Ctrl+Shift+Z redo, caught in the capture
phase so a focused entry or spin never undoes only its own text; both
are in the header-bar menu too. The history keeps whole snapshots of
the project (currentProject() plus the immutable source list itself,
so stepping back never decodes again), compared by their JSON.
requestRender() notes every edit, and a step is committed once the
controls have been still for half a second. New and Open start the
history over once the project's images are in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-26 23:15:03 +02:00
co-authored by Claude Opus 5.5
parent f9fe6e0fa5
commit afe56e783a
5 changed files with 331 additions and 1 deletions
+4
View File
@@ -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
+10
View File
@@ -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,
+164 -1
View File
@@ -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<Gio::ListStore<Gtk::FileFilter>> 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<Gtk::Label>("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[] = {{"<Control>z", "win.undo"},
{"<Control>y", "win.redo"},
{"<Control><Shift>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<Gtk::PopoverMenu>(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<int>(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() {
+31
View File
@@ -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<const std::vector<Image>> sources;
std::vector<std::string> 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<HistoryState> undo_;
std::vector<HistoryState> 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<Gio::SimpleAction> undoAction_;
Glib::RefPtr<Gio::SimpleAction> 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_;
+122
View File
@@ -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<Gtk::ApplicationWindow&>(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<Gtk::ListBox>(window, "chain-list");
auto* add = named<Gtk::MenuButton>(window, "add-effect");
if (chainList == nullptr || add == nullptr) return;
check(capturesShortcut(window, "<Control>z", "win.undo") &&
capturesShortcut(window, "<Control>y", "win.redo") &&
capturesShortcut(window, "<Shift><Control>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<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(1000);
auto* sources = named<Gtk::Box>(window, "sources");
auto lists = sources != nullptr ? all<Gtk::ListBox>(*sources) : std::vector<Gtk::ListBox*>{};
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<Gtk::Stack>(window).front();
stack->set_visible_child("setup");
pump();
const auto removes = all<Gtk::Button>(*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);
});