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;