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