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:
@@ -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
|
||||
|
||||
@@ -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<Gio::ListStore<Gtk::FileFilter>> 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<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 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<Gio::AsyncResult>& 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<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() {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Gio::AsyncResult>& result,
|
||||
const Glib::RefPtr<Gtk::FileDialog>& 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<Gio::Menu> recentMenu_;
|
||||
sigc::connection recentChanged_; // the recent list outlives the window
|
||||
bool suspendRender_ = true;
|
||||
|
||||
// Source loading. Decoding a batch of large photographs takes seconds, so
|
||||
|
||||
+76
-1
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -13,6 +14,7 @@
|
||||
#include <vector>
|
||||
|
||||
#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<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
|
||||
|
||||
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::EffectorWindow>(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;
|
||||
|
||||
Reference in New Issue
Block a user