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 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-24 01:07:08 +02:00
co-authored by Claude Opus 5.5
parent 9fb4428a3b
commit 431fcf9c58
4 changed files with 164 additions and 2 deletions
+4 -1
View File
@@ -148,7 +148,10 @@ can be moved or shared whole.
The interface opens and saves projects from the header-bar menu (New, Open, 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 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_`). is discarded rather than mixed into the new project (`dropLoadedImages_`).
### Two link targets, deliberately ### Two link targets, deliberately
+77
View File
@@ -21,6 +21,11 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG", "*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
"*.JPG", "*.JPEG", "*.WEBP"}; "*.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<Gio::ListStore<Gtk::FileFilter>> projectFilters() { Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
auto filter = Gtk::FileFilter::create(); auto filter = Gtk::FileFilter::create();
filter->set_name("Effector projects"); filter->set_name("Effector projects");
@@ -108,6 +113,7 @@ EffectorWindow::EffectorWindow(const Options& initial) {
EffectorWindow::~EffectorWindow() { EffectorWindow::~EffectorWindow() {
debounce_.disconnect(); debounce_.disconnect();
chainSplitFit_.disconnect(); chainSplitFit_.disconnect();
recentChanged_.disconnect();
loadWindowDelay_.disconnect(); loadWindowDelay_.disconnect();
loadCancel_.store(true, std::memory_order_relaxed); loadCancel_.store(true, std::memory_order_relaxed);
if (loader_.joinable()) loader_.join(); 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("open-project", sigc::mem_fun(*this, &EffectorWindow::onOpenProject));
add_action("save-project", sigc::mem_fun(*this, &EffectorWindow::onSaveProject)); add_action("save-project", sigc::mem_fun(*this, &EffectorWindow::onSaveProject));
add_action("save-project-as", sigc::mem_fun(*this, &EffectorWindow::onSaveProjectAs)); 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<Glib::Variant<Glib::ustring>>(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 menu = Gio::Menu::create();
auto open = Gio::Menu::create(); auto open = Gio::Menu::create();
open->append("New Project", "win.new-project"); open->append("New Project", "win.new-project");
open->append("Open Project…", "win.open-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); menu->append_section(open);
auto save = Gio::Menu::create(); auto save = Gio::Menu::create();
save->append("Save Project", "win.save-project"); save->append("Save Project", "win.save-project");
@@ -1988,11 +2009,66 @@ void EffectorWindow::onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& resul
return; // cancelled return; // cancelled
} }
openProject(path);
}
void EffectorWindow::openProject(const std::string& path) {
try { try {
applyProject(loadProject(path), path); applyProject(loadProject(path), path);
} catch (const std::exception& error) { } catch (const std::exception& error) {
showError(std::string("Could not open the project: ") + error.what()); 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<Glib::RefPtr<Gtk::RecentInfo>> 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<std::filesystem::path> 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<Glib::ustring>::create(recent[i]->get_uri()));
recentMenu_->append_item(item);
}
if (recent.empty()) recentMenu_->append("No Recent Projects", "win.no-recent");
} }
void EffectorWindow::onSaveProject() { void EffectorWindow::onSaveProject() {
@@ -2039,6 +2115,7 @@ void EffectorWindow::writeProject(const std::string& path) {
clearError(); clearError();
projectPath_ = path; projectPath_ = path;
updateTitle(); updateTitle();
rememberProject(path);
status_.set_text("Saved " + path); status_.set_text("Saved " + path);
status_.set_tooltip_text("Saved " + path); status_.set_tooltip_text("Saved " + path);
} }
+7
View File
@@ -113,6 +113,11 @@ private:
void clearSources(); void clearSources();
void onNewProject(); void onNewProject();
void onOpenProject(); 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<Gio::AsyncResult>& result, void onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog); const Glib::RefPtr<Gtk::FileDialog>& dialog);
void onSaveProject(); // falls back to Save As while the project is untitled void onSaveProject(); // falls back to Save As while the project is untitled
@@ -167,6 +172,8 @@ private:
Image result_; Image result_;
std::uint64_t seed_ = 0; std::uint64_t seed_ = 0;
std::string projectPath_; // empty until the project has been saved once std::string projectPath_; // empty until the project has been saved once
Glib::RefPtr<Gio::Menu> recentMenu_;
sigc::connection recentChanged_; // the recent list outlives the window
bool suspendRender_ = true; bool suspendRender_ = true;
// Source loading. Decoding a batch of large photographs takes seconds, so // Source loading. Decoding a batch of large photographs takes seconds, so
+76 -1
View File
@@ -6,6 +6,7 @@
#include <chrono> #include <chrono>
#include <cstdlib> #include <cstdlib>
#include <filesystem>
#include <iostream> #include <iostream>
#include <memory> #include <memory>
#include <string> #include <string>
@@ -13,6 +14,7 @@
#include <vector> #include <vector>
#include "cli/options.hpp" #include "cli/options.hpp"
#include "core/project.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
#include "gui/window.hpp" #include "gui/window.hpp"
@@ -332,6 +334,72 @@ void runTests(Gtk::Window& window) {
pump(); 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<std::string>& 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<std::string> recentEntries(Gtk::Window& window) {
std::vector<Gtk::MenuButton*> buttons;
collect<Gtk::MenuButton>(window, [](Gtk::MenuButton& b) { return b.get_tooltip_text() == "Project menu"; },
buttons);
std::vector<std::string> labels;
bool found = false;
if (buttons.empty()) return labels;
auto* popover = dynamic_cast<Gtk::PopoverMenu*>(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("<no Open Recent submenu>");
return labels;
}
void testRecentProjects(Gtk::Window& window, const std::filesystem::path& dir) {
std::cout << "recent projects\n";
check(recentEntries(window) == std::vector<std::string>{"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<Glib::ustring>::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<std::string>{"other", "my__look (second)", "my__look (first)"},
"opened projects are listed newest first, a shared name with its folder");
}
} // namespace } // namespace
int main(int argc, char** argv) { int main(int argc, char** argv) {
@@ -344,17 +412,24 @@ int main(int argc, char** argv) {
return 77; 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", auto app = Gtk::Application::create("org.effector.GuiTests",
Gio::Application::Flags::NON_UNIQUE); Gio::Application::Flags::NON_UNIQUE);
app->signal_activate().connect([&app] { app->signal_activate().connect([&app, &dataDir] {
auto window = std::make_unique<effector::EffectorWindow>(effector::Options{}); auto window = std::make_unique<effector::EffectorWindow>(effector::Options{});
app->add_window(*window); app->add_window(*window);
window->present(); window->present();
pump(300); pump(300);
runTests(*window); runTests(*window);
testRecentProjects(*window, dataDir / "projects");
app->remove_window(*window); app->remove_window(*window);
}); });
const int status = app->run(argc > 0 ? 1 : 0, argv); const int status = app->run(argc > 0 ? 1 : 0, argv);
std::filesystem::remove_all(dataDir);
if (status != 0) return status; if (status != 0) return status;
std::cout << (failures == 0 ? "all passed\n" : std::to_string(failures) + " failed\n"); std::cout << (failures == 0 ? "all passed\n" : std::to_string(failures) + " failed\n");
return failures; return failures;