From 431fcf9c589d95ca7e4aad317887e0e387c68549 Mon Sep 17 00:00:00 2001 From: Si Date: Thu, 24 Sep 2026 01:07:08 +0200 Subject: [PATCH] List recently opened projects under Open Recent Projects opened or saved go into GTK's shared recent list, signed with the effector application name, and the project menu lists the newest ten of them. The GUI tests point XDG_DATA_HOME at a temporary folder so they leave the user's own list alone. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 ++- src/gui/window.cpp | 77 +++++++++++++++++++++++++++++++++++++++++++++ src/gui/window.hpp | 7 +++++ tests/gui_tests.cpp | 77 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 164 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 027388f..7a5b652 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,7 +148,10 @@ can be moved or shared whole. The interface opens and saves projects from the header-bar menu (New, Open, Save, Save As, with the usual Ctrl+N/O/S accelerators); the window title shows -the project name. Opening one replaces the sources, so a decode still in flight +the project name. Open Recent lists the projects opened or saved here, kept in +GTK's shared `RecentManager` list and told apart from other programs' files by +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_`). ### Two link targets, deliberately diff --git a/src/gui/window.cpp b/src/gui/window.cpp index b4d664d..60cd42a 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -21,6 +21,11 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "* "*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG", "*.JPG", "*.JPEG", "*.WEBP"}; +// How this program signs the entries it adds to the shared recent list, so +// Open Recent lists its projects and nothing else. +const char* const kRecentApp = "effector"; +const std::size_t kRecentCount = 10; + Glib::RefPtr> projectFilters() { auto filter = Gtk::FileFilter::create(); filter->set_name("Effector projects"); @@ -108,6 +113,7 @@ EffectorWindow::EffectorWindow(const Options& initial) { EffectorWindow::~EffectorWindow() { debounce_.disconnect(); chainSplitFit_.disconnect(); + recentChanged_.disconnect(); loadWindowDelay_.disconnect(); loadCancel_.store(true, std::memory_order_relaxed); if (loader_.joinable()) loader_.join(); @@ -1877,11 +1883,26 @@ Gtk::Widget& EffectorWindow::buildMainMenu() { 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)); + // The entry carries the file's URI rather than its path: a target must be + // UTF-8, and a file name need not be. + add_action_with_parameter("open-recent", Glib::VARIANT_TYPE_STRING, + [this](const Glib::VariantBase& target) { + const auto uri = + Glib::VariantBase::cast_dynamic>(target).get(); + openProject(Glib::filename_from_uri(uri)); + }); + // Never enabled: it only stands in for an empty list. + add_action("no-recent")->set_enabled(false); auto menu = Gio::Menu::create(); auto open = Gio::Menu::create(); open->append("New Project", "win.new-project"); open->append("Open Project…", "win.open-project"); + recentMenu_ = Gio::Menu::create(); + open->append_submenu("Open Recent", recentMenu_); + rebuildRecentMenu(); + recentChanged_ = Gtk::RecentManager::get_default()->signal_changed().connect( + sigc::mem_fun(*this, &EffectorWindow::rebuildRecentMenu)); menu->append_section(open); auto save = Gio::Menu::create(); save->append("Save Project", "win.save-project"); @@ -1988,11 +2009,66 @@ void EffectorWindow::onProjectChosen(const Glib::RefPtr& resul return; // cancelled } + openProject(path); +} + +void EffectorWindow::openProject(const std::string& path) { try { applyProject(loadProject(path), path); } catch (const std::exception& error) { showError(std::string("Could not open the project: ") + error.what()); + return; } + rememberProject(path); +} + +void EffectorWindow::rememberProject(const std::string& path) { + Gtk::RecentManager::Data data; + data.app_name = kRecentApp; + data.app_exec = "effector"; + data.mime_type = "application/json"; + try { + Gtk::RecentManager::get_default()->add_item( + Glib::filename_to_uri(std::filesystem::absolute(path).string()), data); + } catch (const Glib::Error&) { + // Not a path a URI can name; the project is open all the same. + } +} + +void EffectorWindow::rebuildRecentMenu() { + std::vector> recent; + for (const auto& info : Gtk::RecentManager::get_default()->get_items()) { + if (info->has_application(kRecentApp) && info->exists()) recent.push_back(info); + } + std::sort(recent.begin(), recent.end(), [](const auto& a, const auto& b) { + return a->get_modified().compare(b->get_modified()) > 0; + }); + if (recent.size() > kRecentCount) recent.resize(kRecentCount); + + // A project is listed by its name, and by its folder too only where two + // of the same name would otherwise read alike. + std::vector paths; + for (const auto& info : recent) paths.emplace_back(Glib::filename_from_uri(info->get_uri())); + recentMenu_->remove_all(); + for (std::size_t i = 0; i < recent.size(); ++i) { + std::string label = paths[i].stem().string(); + const auto sameName = std::count_if(paths.begin(), paths.end(), [&](const auto& other) { + return other.stem() == paths[i].stem(); + }); + if (sameName > 1) label += " (" + paths[i].parent_path().filename().string() + ")"; + // The menu reads an underscore as a mnemonic. + const std::string display = Glib::filename_display_name(label); + std::string escaped; + for (const char c : display) { + escaped += c; + if (c == '_') escaped += '_'; + } + auto item = Gio::MenuItem::create(escaped, ""); + item->set_action_and_target("win.open-recent", + Glib::Variant::create(recent[i]->get_uri())); + recentMenu_->append_item(item); + } + if (recent.empty()) recentMenu_->append("No Recent Projects", "win.no-recent"); } void EffectorWindow::onSaveProject() { @@ -2039,6 +2115,7 @@ void EffectorWindow::writeProject(const std::string& path) { clearError(); projectPath_ = path; updateTitle(); + rememberProject(path); status_.set_text("Saved " + path); status_.set_tooltip_text("Saved " + path); } diff --git a/src/gui/window.hpp b/src/gui/window.hpp index dbabdae..8f64b43 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -113,6 +113,11 @@ private: void clearSources(); void onNewProject(); void onOpenProject(); + void openProject(const std::string& path); + // Open Recent: the projects this program opened or saved, newest first, + // kept in the desktop's shared list of recently used files. + void rememberProject(const std::string& path); + void rebuildRecentMenu(); void onProjectChosen(const Glib::RefPtr& result, const Glib::RefPtr& dialog); void onSaveProject(); // falls back to Save As while the project is untitled @@ -167,6 +172,8 @@ private: Image result_; std::uint64_t seed_ = 0; std::string projectPath_; // empty until the project has been saved once + Glib::RefPtr recentMenu_; + sigc::connection recentChanged_; // the recent list outlives the window bool suspendRender_ = true; // Source loading. Decoding a batch of large photographs takes seconds, so diff --git a/tests/gui_tests.cpp b/tests/gui_tests.cpp index 47f2bd8..42e1724 100644 --- a/tests/gui_tests.cpp +++ b/tests/gui_tests.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -13,6 +14,7 @@ #include #include "cli/options.hpp" +#include "core/project.hpp" #include "core/registry.hpp" #include "gui/window.hpp" @@ -332,6 +334,72 @@ void runTests(Gtk::Window& window) { pump(); } +// The labels of the entries under the "Open Recent" submenu of `model`, or +// nothing when there is no such submenu. +void recentLabels(GMenuModel* model, std::vector& out, bool& found) { + for (int i = 0; i < g_menu_model_get_n_items(model); ++i) { + char* label = nullptr; + g_menu_model_get_item_attribute(model, i, G_MENU_ATTRIBUTE_LABEL, "s", &label); + const std::string text = label != nullptr ? label : ""; + g_free(label); + if (GMenuModel* section = g_menu_model_get_item_link(model, i, G_MENU_LINK_SECTION)) { + recentLabels(section, out, found); + g_object_unref(section); + } + if (GMenuModel* submenu = g_menu_model_get_item_link(model, i, G_MENU_LINK_SUBMENU)) { + if (text == "Open Recent") { + found = true; + for (int j = 0; j < g_menu_model_get_n_items(submenu); ++j) { + char* entry = nullptr; + g_menu_model_get_item_attribute(submenu, j, G_MENU_ATTRIBUTE_LABEL, "s", &entry); + out.emplace_back(entry != nullptr ? entry : ""); + g_free(entry); + } + } + g_object_unref(submenu); + } + } +} + +std::vector recentEntries(Gtk::Window& window) { + std::vector buttons; + collect(window, [](Gtk::MenuButton& b) { return b.get_tooltip_text() == "Project menu"; }, + buttons); + std::vector labels; + bool found = false; + if (buttons.empty()) return labels; + auto* popover = dynamic_cast(buttons.front()->get_popover()); + if (popover == nullptr || !popover->get_menu_model()) return labels; + recentLabels(popover->get_menu_model()->gobj(), labels, found); + if (!found) labels.emplace_back(""); + return labels; +} + +void testRecentProjects(Gtk::Window& window, const std::filesystem::path& dir) { + std::cout << "recent projects\n"; + check(recentEntries(window) == std::vector{"No Recent Projects"}, + "Open Recent starts out empty"); + + // Two projects of the same name in different folders, and a third. + std::filesystem::create_directories(dir / "first"); + std::filesystem::create_directories(dir / "second"); + const std::filesystem::path paths[] = {dir / "first" / "my_look.json", dir / "second" / "my_look.json", + dir / "other.json"}; + effector::Project project; + project.stages.push_back({effector::effectByName("blur"), {}}); // renders with no sources + for (const auto& path : paths) { + effector::saveProject(path.string(), project); + window.activate_action("win.open-recent", + Glib::Variant::create(Glib::filename_to_uri(path.string()))); + pump(50); + } + pump(500); // the recent list is written, and announced, from an idle + check(window.get_title() == "other — Effector", "an entry opens its project"); + const auto labels = recentEntries(window); + check(labels == std::vector{"other", "my__look (second)", "my__look (first)"}, + "opened projects are listed newest first, a shared name with its folder"); +} + } // namespace int main(int argc, char** argv) { @@ -344,17 +412,24 @@ int main(int argc, char** argv) { return 77; } + // Keep the projects the tests open out of the user's own recent list. + std::string dataTemplate = (std::filesystem::temp_directory_path() / "effector-gui-XXXXXX").string(); + const std::filesystem::path dataDir = g_mkdtemp(dataTemplate.data()); + g_setenv("XDG_DATA_HOME", dataDir.c_str(), TRUE); + auto app = Gtk::Application::create("org.effector.GuiTests", Gio::Application::Flags::NON_UNIQUE); - app->signal_activate().connect([&app] { + app->signal_activate().connect([&app, &dataDir] { auto window = std::make_unique(effector::Options{}); app->add_window(*window); window->present(); pump(300); runTests(*window); + testRecentProjects(*window, dataDir / "projects"); app->remove_window(*window); }); const int status = app->run(argc > 0 ? 1 : 0, argv); + std::filesystem::remove_all(dataDir); if (status != 0) return status; std::cout << (failures == 0 ? "all passed\n" : std::to_string(failures) + " failed\n"); return failures;