diff --git a/CLAUDE.md b/CLAUDE.md index 2f2f7d9..7222430 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,11 +14,11 @@ Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way. The test suite is a single hand-rolled binary (no framework), so there is no per-test filter: run `./build/tests/effector_tests` directly to see the section -headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `batch loading`) and the `ok`/`FAIL` -lines. To iterate on one area, comment out the other blocks in -`tests/core_tests.cpp` or add a new `std::cout << "section\n"` block — checks are -plain `check(condition, "description")` calls and `main()` returns the failure -count. +headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `project`, `image +io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment +out the other blocks in `tests/core_tests.cpp` or add a new +`std::cout << "section\n"` block — checks are plain +`check(condition, "description")` calls and `main()` returns the failure count. Headless render (no display needed, the fastest end-to-end smoke test): @@ -69,6 +69,22 @@ CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur (`src/cli/options.cpp`) hands each flag to the stage whose effect declares it — two effects must not declare the same param key. +### Projects are plain JSON + +`src/core/project.cpp` reads and writes the whole setup — source paths, the +chain with its params, canvas and seed — as a small JSON file, with its own +minimal reader rather than a JSON dependency. Writing is driven by the effects' +`ParamSpec`s, not by whatever happens to sit in a stage's `ParamValues`, so a +key an effect does not declare is never written and never read back: an old +project keeps the defaults for params that did not exist when it was saved. +Source paths under the project's own folder are written relative, so a folder +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 +is discarded rather than mixed into the new project (`dropLoadedImages_`). + ### Two link targets, deliberately `effector_core` (static) holds image IO, params, effects, rng and links only diff --git a/CMakeLists.txt b/CMakeLists.txt index 75fd45e..57540ef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(effector_core STATIC src/core/effect.cpp src/core/image.cpp src/core/params.cpp + src/core/project.cpp src/core/registry.cpp src/core/rng.cpp src/effects/blur.cpp diff --git a/src/core/project.cpp b/src/core/project.cpp new file mode 100644 index 0000000..d0de8c7 --- /dev/null +++ b/src/core/project.cpp @@ -0,0 +1,443 @@ +#include "core/project.hpp" + +#include +#include +#include +#include +#include +#include + +#include "core/effect.hpp" +#include "core/registry.hpp" + +namespace effector { +namespace { + +// A project file is small and its shape is fixed, so it carries its own +// reader rather than a JSON dependency: enough of the grammar to read back +// what projectToJson writes, and to give a line number when a hand-edited +// file is wrong. +struct Json { + enum class Kind { Null, Bool, Number, String, Array, Object }; + + Kind kind = Kind::Null; + bool boolean = false; + double number = 0.0; + bool integral = true; // written without a fraction or exponent + std::string text; + std::vector items; + std::vector> members; + + const Json* member(const std::string& key) const { + for (const auto& entry : members) { + if (entry.first == key) return &entry.second; + } + return nullptr; + } +}; + +class JsonParser { +public: + explicit JsonParser(const std::string& text) : text_(text) {} + + Json parse() { + skipSpace(); + Json value = parseValue(); + skipSpace(); + if (pos_ != text_.size()) fail("trailing text after the end of the document"); + return value; + } + +private: + [[noreturn]] void fail(const std::string& what) const { + int line = 1; + for (std::size_t i = 0; i < pos_ && i < text_.size(); ++i) { + if (text_[i] == '\n') ++line; + } + throw EffectError("line " + std::to_string(line) + ": " + what); + } + + char peek() const { return pos_ < text_.size() ? text_[pos_] : '\0'; } + + void skipSpace() { + while (pos_ < text_.size()) { + const char c = text_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + ++pos_; + } else { + break; + } + } + } + + void expect(char c) { + if (peek() != c) fail(std::string("expected '") + c + "'"); + ++pos_; + } + + bool literal(const char* word) { + const std::size_t length = std::char_traits::length(word); + if (text_.compare(pos_, length, word) != 0) return false; + pos_ += length; + return true; + } + + Json parseValue() { + switch (peek()) { + case '{': return parseObject(); + case '[': return parseArray(); + case '"': { + Json value; + value.kind = Json::Kind::String; + value.text = parseString(); + return value; + } + default: break; + } + if (literal("true")) return boolean(true); + if (literal("false")) return boolean(false); + if (literal("null")) return Json(); + return parseNumber(); + } + + static Json boolean(bool state) { + Json value; + value.kind = Json::Kind::Bool; + value.boolean = state; + return value; + } + + Json parseObject() { + Json value; + value.kind = Json::Kind::Object; + expect('{'); + skipSpace(); + if (peek() == '}') { + ++pos_; + return value; + } + while (true) { + skipSpace(); + std::string key = parseString(); + skipSpace(); + expect(':'); + skipSpace(); + value.members.emplace_back(std::move(key), parseValue()); + skipSpace(); + if (peek() == ',') { + ++pos_; + continue; + } + expect('}'); + return value; + } + } + + Json parseArray() { + Json value; + value.kind = Json::Kind::Array; + expect('['); + skipSpace(); + if (peek() == ']') { + ++pos_; + return value; + } + while (true) { + skipSpace(); + value.items.push_back(parseValue()); + skipSpace(); + if (peek() == ',') { + ++pos_; + continue; + } + expect(']'); + return value; + } + } + + std::string parseString() { + expect('"'); + std::string out; + while (true) { + if (pos_ >= text_.size()) fail("unterminated string"); + const char c = text_[pos_++]; + if (c == '"') return out; + if (c != '\\') { + out.push_back(c); + continue; + } + if (pos_ >= text_.size()) fail("unterminated escape"); + switch (text_[pos_++]) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + // Only the basic plane, which is all a path or a choice + // name can need; surrogate pairs are passed through as is. + if (pos_ + 4 > text_.size()) fail("short \\u escape"); + const unsigned code = static_cast( + std::strtoul(text_.substr(pos_, 4).c_str(), nullptr, 16)); + pos_ += 4; + appendUtf8(out, code); + break; + } + default: fail("unknown escape"); + } + } + } + + static void appendUtf8(std::string& out, unsigned code) { + if (code < 0x80) { + out.push_back(static_cast(code)); + } else if (code < 0x800) { + out.push_back(static_cast(0xC0 | (code >> 6))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } else { + out.push_back(static_cast(0xE0 | (code >> 12))); + out.push_back(static_cast(0x80 | ((code >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } + } + + Json parseNumber() { + const std::size_t start = pos_; + if (peek() == '-' || peek() == '+') ++pos_; + bool integral = true; + while (pos_ < text_.size()) { + const char c = text_[pos_]; + if (c >= '0' && c <= '9') { + ++pos_; + } else if (c == '.' || c == 'e' || c == 'E') { + integral = false; + ++pos_; + } else if (c == '-' || c == '+') { // an exponent's sign + ++pos_; + } else { + break; + } + } + if (pos_ == start) fail("expected a value"); + + Json value; + value.kind = Json::Kind::Number; + value.integral = integral; + try { + value.number = std::stod(text_.substr(start, pos_ - start)); + } catch (const std::exception&) { + fail("'" + text_.substr(start, pos_ - start) + "' is not a number"); + } + return value; + } + + const std::string& text_; + std::size_t pos_ = 0; +}; + +std::string quote(const std::string& text) { + std::string out = "\""; + for (const char c : text) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buffer[7]; + std::snprintf(buffer, sizeof(buffer), "\\u%04x", + static_cast(static_cast(c))); + out += buffer; + } else { + out.push_back(c); + } + } + } + return out + "\""; +} + +std::string number(double value, int digits) { + std::ostringstream out; + out.setf(std::ios::fixed); + out.precision(digits); + out << value; + std::string text = out.str(); + // 5.00 reads better as 5.0 and 0.250 as 0.25. + while (text.size() > 1 && text.back() == '0' && text[text.size() - 2] != '.') text.pop_back(); + return text; +} + +// Relative when the image lives under the project folder, absolute otherwise: +// a folder holding the project and its images stays movable. +std::string relativeTo(const std::string& path, const std::string& directory) { + if (directory.empty()) return path; + std::error_code error; + const auto relative = std::filesystem::relative(path, directory, error); + if (error || relative.empty()) return path; + const auto text = relative.generic_string(); + return text.rfind("..", 0) == 0 ? path : text; +} + +std::string resolve(const std::string& path, const std::string& directory) { + std::filesystem::path file(path); + if (file.is_absolute() || directory.empty()) return path; + return (std::filesystem::path(directory) / file).lexically_normal().string(); +} + +} // namespace + +std::string projectToJson(const Project& project, const std::string& directory) { + std::ostringstream out; + out << "{\n"; + out << " \"effector_project\": 1,\n"; + out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height + << "},\n"; + if (project.seed) out << " \"seed\": " << *project.seed << ",\n"; + + out << " \"sources\": ["; + for (std::size_t i = 0; i < project.sources.size(); ++i) { + out << (i == 0 ? "\n" : ",\n") << " " << quote(relativeTo(project.sources[i], directory)); + } + out << (project.sources.empty() ? "" : "\n ") << "],\n"; + + out << " \"chain\": ["; + for (std::size_t i = 0; i < project.stages.size(); ++i) { + const auto& stage = project.stages[i]; + if (!stage.effect) continue; + out << (i == 0 ? "\n" : ",\n"); + out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n"; + out << " \"params\": {"; + + const auto& specs = stage.effect->params(); + bool first = true; + for (const auto& spec : specs) { + out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": "; + first = false; + switch (spec.type) { + case ParamSpec::Type::Bool: + out << (paramBool(stage.values, spec.key, spec.defaultValue != 0.0) ? "true" + : "false"); + break; + case ParamSpec::Type::Int: + out << paramInt(stage.values, spec.key, static_cast(spec.defaultValue)); + break; + case ParamSpec::Type::Double: + out << number(paramDouble(stage.values, spec.key, spec.defaultValue), + spec.digits); + break; + case ParamSpec::Type::Choice: + out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice)); + break; + } + } + out << (specs.empty() ? "}" : "\n }") << "\n }"; + } + out << (project.stages.empty() ? "" : "\n ") << "]\n"; + out << "}\n"; + return out.str(); +} + +Project projectFromJson(const std::string& text, const std::string& directory) { + JsonParser parser(text); + const Json root = parser.parse(); + if (root.kind != Json::Kind::Object) throw EffectError("a project must be a JSON object"); + + Project project; + + if (const auto* canvas = root.member("canvas")) { + if (const auto* width = canvas->member("width")) project.width = static_cast(width->number); + if (const auto* height = canvas->member("height")) { + project.height = static_cast(height->number); + } + } + if (project.width <= 0 || project.height <= 0) throw EffectError("the canvas size is not positive"); + + if (const auto* seed = root.member("seed")) { + if (seed->kind == Json::Kind::Number) { + project.seed = static_cast(seed->number); + } + } + + if (const auto* sources = root.member("sources")) { + for (const auto& item : sources->items) { + if (item.kind == Json::Kind::String) { + project.sources.push_back(resolve(item.text, directory)); + } + } + } + + const auto* chain = root.member("chain"); + if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) { + throw EffectError("the project has no effect chain"); + } + for (const auto& item : chain->items) { + const auto* name = item.member("effect"); + if (name == nullptr || name->kind != Json::Kind::String) { + throw EffectError("a chain stage has no effect name"); + } + + ChainStage stage; + stage.effect = effectByName(name->text); // throws on an unknown effect + stage.values = stage.effect->defaults(); + + // Driven by the specs, not by the file: a key the effect does not + // declare is dropped, and an old project simply keeps the defaults for + // params that did not exist when it was written. + if (const auto* params = item.member("params")) { + for (const auto& spec : stage.effect->params()) { + const auto* value = params->member(spec.key); + if (value == nullptr) continue; + switch (spec.type) { + case ParamSpec::Type::Bool: + if (value->kind == Json::Kind::Bool) stage.values[spec.key] = value->boolean; + break; + case ParamSpec::Type::Int: + if (value->kind == Json::Kind::Number) { + stage.values[spec.key] = static_cast(std::llround(value->number)); + } + break; + case ParamSpec::Type::Double: + if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number; + break; + case ParamSpec::Type::Choice: + for (const auto& choice : spec.choices) { + if (value->kind == Json::Kind::String && value->text == choice) { + stage.values[spec.key] = choice; + } + } + break; + } + } + } + project.stages.push_back(std::move(stage)); + } + + return project; +} + +void saveProject(const std::string& path, const Project& project) { + const auto directory = std::filesystem::path(path).parent_path().string(); + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file) throw EffectError("cannot write " + path); + file << projectToJson(project, directory); + if (!file) throw EffectError("could not write " + path); +} + +Project loadProject(const std::string& path) { + std::ifstream file(path, std::ios::binary); + if (!file) throw EffectError("cannot read " + path); + std::ostringstream buffer; + buffer << file.rdbuf(); + try { + return projectFromJson(buffer.str(), std::filesystem::path(path).parent_path().string()); + } catch (const EffectError& error) { + throw EffectError(std::filesystem::path(path).filename().string() + ": " + error.what()); + } +} + +} // namespace effector diff --git a/src/core/project.hpp b/src/core/project.hpp new file mode 100644 index 0000000..4f6bcdf --- /dev/null +++ b/src/core/project.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +#include "core/chain.hpp" +#include "core/params.hpp" + +namespace effector { + +// Everything needed to recreate a render: which images went in, the cascade +// with its settings, the canvas and the seed. Deliberately no pixels — a +// project is a small text file that points at the sources on disk. +struct Project { + std::vector sources; // absolute paths + std::vector stages; + int width = 1920; + int height = 1080; + std::optional seed; +}; + +// JSON, written for humans: two-space indent, one source path per line, and +// the params of a stage spelled with the same keys the effect declares. +// Source paths are written relative to `directory` when they live under it, so +// a project folder can be moved or shared whole. +std::string projectToJson(const Project& project, const std::string& directory = {}); + +// Throws EffectError on malformed JSON, an unknown effect or a missing chain. +// Relative source paths are resolved against `directory`; params the effect +// does not declare are dropped, and missing ones keep the effect's default. +Project projectFromJson(const std::string& text, const std::string& directory = {}); + +// Both throw EffectError: the file is the user's, so failures are reported +// rather than silently swallowed. +void saveProject(const std::string& path, const Project& project); +Project loadProject(const std::string& path); + +} // namespace effector diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 56d3e7d..5e56157 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -6,6 +6,7 @@ #include #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> projectFilters() { + auto filter = Gtk::FileFilter::create(); + filter->set_name("Effector projects"); + filter->add_pattern("*.json"); + auto filters = Gio::ListStore::create(); + filters->append(filter); + return filters; +} + Glib::RefPtr 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(); set_titlebar(*header); + menuButton_.set_icon_name("open-menu-symbolic"); + menuButton_.set_tooltip_text("Project menu"); + menuButton_.set_popover(static_cast(buildMainMenu())); + header->pack_end(menuButton_); + auto* addButton = Gtk::make_managed(); 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[] = {{"n", "win.new-project"}, + {"o", "win.open-project"}, + {"s", "win.save-project"}, + {"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(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(widthSpin_.get_value()); + project.height = static_cast(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& result) { + onProjectChosen(result, dialog); + }); +} + +void EffectorWindow::onProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& 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& result) { + onSaveProjectChosen(result, dialog); + }); +} + +void EffectorWindow::onSaveProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& 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() { diff --git a/src/gui/window.hpp b/src/gui/window.hpp index c34c979..ec0f653 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -15,6 +15,7 @@ #include "core/chain.hpp" #include "core/effect.hpp" #include "core/image.hpp" +#include "core/project.hpp" namespace effector { @@ -54,6 +55,21 @@ private: void rebuildChainRows(); void selectStage(int index); void onStageSelected(); + Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project + Project currentProject() const; + void applyProject(const Project& project, const std::string& path); + void clearSources(); + void onNewProject(); + void onOpenProject(); + void onProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& dialog); + void onSaveProject(); // falls back to Save As while the project is untitled + void onSaveProjectAs(); + void onSaveProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& dialog); + void writeProject(const std::string& path); + void updateTitle(); + void onParamChanged(); void onSeedEntered(); void onRecreate(); @@ -82,6 +98,7 @@ private: bool suspendStageSelect_ = false; // guards the rebuild of the chain rows Image result_; std::uint64_t seed_ = 0; + std::string projectPath_; // empty until the project has been saved once bool suspendRender_ = true; // Source loading. Decoding a batch of large photographs takes seconds, so @@ -98,6 +115,7 @@ private: std::vector loadFailures_; std::vector loadQueue_; // chosen while another batch was loading bool loading_ = false; + bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project sigc::connection loadWindowDelay_; // Render job @@ -126,6 +144,7 @@ private: Gtk::Entry seedEntry_; Gtk::ListBox chainList_; Gtk::MenuButton addEffectButton_; + Gtk::MenuButton menuButton_; Gtk::Button recreateButton_{"Recreate"}; Gtk::Button saveButton_{"Save As…"}; Gtk::Picture picture_; diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 0c07cd1..d9b47f3 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -4,12 +4,14 @@ #include #include #include +#include #include #include #include #include #include "core/chain.hpp" +#include "core/project.hpp" #include "core/registry.hpp" #include "core/rng.hpp" #include "effects/blur.hpp" @@ -241,6 +243,86 @@ int main() { check(threw, "an empty chain throws"); } + std::cout << "project\n"; + { + const auto dir = std::filesystem::temp_directory_path() / "effector_test_project"; + std::filesystem::create_directories(dir); + const auto source = dir / "source.png"; + gradient(64, 48, 7).save(source.string()); + + Project project; + project.sources = {source.string(), "/elsewhere/other.png"}; + project.stages.push_back({effectByName("scanlines"), {{"strip_height", 9L}}}); + project.stages.push_back( + {effectByName("blur"), {{"radius", 3L}, {"direction", std::string("vertical")}}}); + project.width = 640; + project.height = 400; + project.seed = 1234567890123ULL; + + const auto file = dir / "test.json"; + saveProject(file.string(), project); + const auto reloaded = loadProject(file.string()); + + check(reloaded.width == 640 && reloaded.height == 400, "the canvas survives a round trip"); + check(reloaded.seed && *reloaded.seed == 1234567890123ULL, "the seed survives a round trip"); + check(reloaded.stages.size() == 2 && reloaded.stages[0].effect->name() == "scanlines" && + reloaded.stages[1].effect->name() == "blur", + "the chain keeps its effects and their order"); + check(paramInt(reloaded.stages[0].values, "strip_height", 0) == 9 && + paramInt(reloaded.stages[1].values, "radius", 0) == 3 && + paramChoice(reloaded.stages[1].values, "direction", "") == "vertical", + "the stage params survive a round trip"); + check(paramInt(reloaded.stages[0].values, "jitter", -1) == + paramInt(effectByName("scanlines")->defaults(), "jitter", -2), + "params the file leaves out keep their default"); + + // A source next to the project is written relative, so the folder can + // be moved; anything outside it stays absolute. + std::ifstream stream(file.string(), std::ios::binary); + const std::string text((std::istreambuf_iterator(stream)), + std::istreambuf_iterator()); + check(text.find("\"source.png\"") != std::string::npos, "a nearby source is written relative"); + check(text.find("\"/elsewhere/other.png\"") != std::string::npos, + "a source elsewhere stays absolute"); + check(reloaded.sources.size() == 2 && reloaded.sources[0] == source.string(), + "a relative source is resolved against the project"); + + // The point of a project: the same picture comes back out of the file. + std::vector sources = {gradient(300, 200, 4)}; + Rng before(*project.seed); + Rng after(*reloaded.seed); + const auto original = renderChain(project.stages, sources, project.width, project.height, before); + const auto restored = + renderChain(reloaded.stages, sources, reloaded.width, reloaded.height, after); + check(sameBytes(original, restored), "a reloaded project renders the same image"); + + // Params the effects do not declare are dropped rather than carried. + const auto stripped = projectFromJson(projectToJson(reloaded)); + check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(), + "a stage only keeps the params its effect declares"); + + int refusals = 0; + for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}", + "{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) { + try { + projectFromJson(bad); + } catch (const EffectError&) { + ++refusals; + } + } + check(refusals == 4, "a broken project is refused"); + + bool threw = false; + try { + loadProject((dir / "missing.json").string()); + } catch (const EffectError&) { + threw = true; + } + check(threw, "a missing project file throws"); + + std::filesystem::remove_all(dir); + } + std::cout << "image io\n"; { const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";