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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user