Save and open projects as JSON
A project is the whole setup as a small text file: the source paths, the effect chain with its params, the canvas and the seed. Reading and writing are driven by the effects' ParamSpecs rather than by whatever sits in a stage's values, so a new effect is saveable without touching this code, a key an effect does not declare is never written or read back, and an old project keeps the defaults for params that did not exist when it was saved. Paths under the project's own folder are written relative, so a folder of images and their project can be moved whole. src/core/project.cpp carries its own small JSON reader instead of a dependency, and names the line when a hand-edited file is wrong. The interface gets a header-bar menu: New, Open, Save, Save As, with Ctrl+N/O/S. The title shows the project name. Opening a project replaces the sources, discarding a decode still in flight rather than mixing it into the new project. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
#include <sstream>
|
||||
|
||||
#include "core/chain.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
@@ -16,6 +17,15 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*
|
||||
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
|
||||
"*.JPG", "*.JPEG", "*.WEBP"};
|
||||
|
||||
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
|
||||
auto filter = Gtk::FileFilter::create();
|
||||
filter->set_name("Effector projects");
|
||||
filter->add_pattern("*.json");
|
||||
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
|
||||
filters->append(filter);
|
||||
return filters;
|
||||
}
|
||||
|
||||
Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) {
|
||||
auto bytes = Glib::Bytes::create(image.data(), image.byteSize());
|
||||
return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8,
|
||||
@@ -64,6 +74,11 @@ void EffectorWindow::buildUi() {
|
||||
auto* header = Gtk::make_managed<Gtk::HeaderBar>();
|
||||
set_titlebar(*header);
|
||||
|
||||
menuButton_.set_icon_name("open-menu-symbolic");
|
||||
menuButton_.set_tooltip_text("Project menu");
|
||||
menuButton_.set_popover(static_cast<Gtk::PopoverMenu&>(buildMainMenu()));
|
||||
header->pack_end(menuButton_);
|
||||
|
||||
auto* addButton = Gtk::make_managed<Gtk::Button>();
|
||||
addButton->set_icon_name("list-add-symbolic");
|
||||
addButton->set_tooltip_text("Add images");
|
||||
@@ -418,10 +433,19 @@ void EffectorWindow::onLoadFinished() {
|
||||
if (loadCancel_.load(std::memory_order_relaxed)) {
|
||||
// A cancelled batch is incomplete, so none of it is taken.
|
||||
loadQueue_.clear();
|
||||
dropLoadedImages_ = false;
|
||||
showError("Loading cancelled.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropLoadedImages_) {
|
||||
// A project was opened while this batch was decoding: its images belong
|
||||
// to the previous source list, and the project's own are queued behind.
|
||||
dropLoadedImages_ = false;
|
||||
images.clear();
|
||||
failures.clear();
|
||||
}
|
||||
|
||||
for (const auto& image : images) appendSourceRow(image.path());
|
||||
mutateSources([&images](SourceList& list) {
|
||||
for (auto& image : images) list.push_back(std::move(image));
|
||||
@@ -875,6 +899,183 @@ void EffectorWindow::onRenderFinished() {
|
||||
status_.set_text(status.str());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- project ---
|
||||
|
||||
// The project menu lives in the header bar, next to the buttons that act on
|
||||
// the current render.
|
||||
Gtk::Widget& EffectorWindow::buildMainMenu() {
|
||||
add_action("new-project", sigc::mem_fun(*this, &EffectorWindow::onNewProject));
|
||||
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));
|
||||
|
||||
auto menu = Gio::Menu::create();
|
||||
auto open = Gio::Menu::create();
|
||||
open->append("New Project", "win.new-project");
|
||||
open->append("Open Project…", "win.open-project");
|
||||
menu->append_section(open);
|
||||
auto save = Gio::Menu::create();
|
||||
save->append("Save Project", "win.save-project");
|
||||
save->append("Save Project As…", "win.save-project-as");
|
||||
menu->append_section(save);
|
||||
|
||||
// The shortcuts hang off the window rather than the application, which the
|
||||
// window does not belong to yet while it is being built.
|
||||
auto shortcuts = Gtk::ShortcutController::create();
|
||||
shortcuts->set_scope(Gtk::ShortcutScope::GLOBAL);
|
||||
const struct Accel {
|
||||
const char* keys;
|
||||
const char* action;
|
||||
} accels[] = {{"<Control>n", "win.new-project"},
|
||||
{"<Control>o", "win.open-project"},
|
||||
{"<Control>s", "win.save-project"},
|
||||
{"<Control><Shift>s", "win.save-project-as"}};
|
||||
for (const Accel& accel : accels) {
|
||||
shortcuts->add_shortcut(Gtk::Shortcut::create(
|
||||
Gtk::ShortcutTrigger::parse_string(accel.keys), Gtk::NamedAction::create(accel.action)));
|
||||
}
|
||||
add_controller(shortcuts);
|
||||
|
||||
return *Gtk::make_managed<Gtk::PopoverMenu>(menu);
|
||||
}
|
||||
|
||||
Project EffectorWindow::currentProject() const {
|
||||
Project project;
|
||||
for (const auto& image : *sources_) project.sources.push_back(image.path());
|
||||
project.stages = stages_;
|
||||
project.width = static_cast<int>(widthSpin_.get_value());
|
||||
project.height = static_cast<int>(heightSpin_.get_value());
|
||||
project.seed = seed_;
|
||||
return project;
|
||||
}
|
||||
|
||||
void EffectorWindow::applyProject(const Project& project, const std::string& path) {
|
||||
// One render at the end rather than one per piece of the project.
|
||||
suspendRender_ = true;
|
||||
clearSources();
|
||||
|
||||
stages_ = project.stages;
|
||||
selectedStage_ = 0;
|
||||
rebuildChainRows();
|
||||
buildParamWidgets();
|
||||
|
||||
widthSpin_.set_value(project.width);
|
||||
heightSpin_.set_value(project.height);
|
||||
seed_ = project.seed.value_or(Rng::randomSeed());
|
||||
seedEntry_.set_text(std::to_string(seed_));
|
||||
|
||||
projectPath_ = path;
|
||||
updateTitle();
|
||||
clearError();
|
||||
suspendRender_ = false;
|
||||
|
||||
if (project.sources.empty()) {
|
||||
placeholder_.set_text("Add source images to start.");
|
||||
status_.set_text("");
|
||||
} else {
|
||||
addPaths(project.sources); // renders once the images are decoded
|
||||
}
|
||||
}
|
||||
|
||||
void EffectorWindow::clearSources() {
|
||||
while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row);
|
||||
mutateSources([](SourceList& list) { list.clear(); });
|
||||
loadQueue_.clear();
|
||||
if (loading_) dropLoadedImages_ = true;
|
||||
|
||||
result_ = Image();
|
||||
saveButton_.set_sensitive(false);
|
||||
previewStack_.set_visible_child("placeholder");
|
||||
}
|
||||
|
||||
void EffectorWindow::onNewProject() {
|
||||
Project fresh;
|
||||
fresh.stages.push_back({effects_.front(), effects_.front()->defaults()});
|
||||
applyProject(fresh, "");
|
||||
}
|
||||
|
||||
void EffectorWindow::onOpenProject() {
|
||||
auto dialog = Gtk::FileDialog::create();
|
||||
dialog->set_title("Open project");
|
||||
dialog->set_filters(projectFilters());
|
||||
dialog->open(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||
onProjectChosen(result, dialog);
|
||||
});
|
||||
}
|
||||
|
||||
void EffectorWindow::onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
|
||||
std::string path;
|
||||
try {
|
||||
const auto file = dialog->open_finish(result);
|
||||
if (!file) return;
|
||||
path = file->get_path();
|
||||
} catch (const Glib::Error&) {
|
||||
return; // cancelled
|
||||
}
|
||||
|
||||
try {
|
||||
applyProject(loadProject(path), path);
|
||||
} catch (const std::exception& error) {
|
||||
showError(std::string("Could not open the project: ") + error.what());
|
||||
}
|
||||
}
|
||||
|
||||
void EffectorWindow::onSaveProject() {
|
||||
if (projectPath_.empty()) {
|
||||
onSaveProjectAs();
|
||||
return;
|
||||
}
|
||||
writeProject(projectPath_);
|
||||
}
|
||||
|
||||
void EffectorWindow::onSaveProjectAs() {
|
||||
auto dialog = Gtk::FileDialog::create();
|
||||
dialog->set_title("Save project");
|
||||
dialog->set_filters(projectFilters());
|
||||
dialog->set_initial_name(projectPath_.empty()
|
||||
? "effector-" + chainSlug(stages_) + ".json"
|
||||
: std::filesystem::path(projectPath_).filename().string());
|
||||
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||
onSaveProjectChosen(result, dialog);
|
||||
});
|
||||
}
|
||||
|
||||
void EffectorWindow::onSaveProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
|
||||
std::filesystem::path path;
|
||||
try {
|
||||
const auto file = dialog->save_finish(result);
|
||||
if (!file) return;
|
||||
path = file->get_path();
|
||||
} catch (const Glib::Error&) {
|
||||
return; // cancelled
|
||||
}
|
||||
if (path.extension().empty()) path.replace_extension(".json");
|
||||
writeProject(path.string());
|
||||
}
|
||||
|
||||
void EffectorWindow::writeProject(const std::string& path) {
|
||||
try {
|
||||
saveProject(path, currentProject());
|
||||
} catch (const std::exception& error) {
|
||||
showError(std::string("Could not save the project: ") + error.what());
|
||||
return;
|
||||
}
|
||||
clearError();
|
||||
projectPath_ = path;
|
||||
updateTitle();
|
||||
status_.set_text("Saved " + path);
|
||||
}
|
||||
|
||||
void EffectorWindow::updateTitle() {
|
||||
if (projectPath_.empty()) {
|
||||
set_title("Effector");
|
||||
return;
|
||||
}
|
||||
set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector");
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- saving ---
|
||||
|
||||
void EffectorWindow::onSave() {
|
||||
|
||||
Reference in New Issue
Block a user