From f61161aef0366695baa66adb6f010296d0b44f8c Mon Sep 17 00:00:00 2001 From: Si Date: Sat, 19 Sep 2026 20:59:56 +0200 Subject: [PATCH 1/3] Open on an empty chain, and show the sources only when they are used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window used to hand the user a Scanlines stage before they had asked for anything, which also meant the first thing on screen was "Add source images to start." — an instruction about an effect nobody chose. It now opens on "No effects yet" and waits: the chain is the subject of the interface, so the first stage is the user's to pick. The default effect was injected by the option parser, which would have put Scanlines straight back on every launch. It moves to renderHeadless, where a chain really is mandatory — a headless run must render something, while the interface has no such need. `-o out.png` with no -e is unchanged. The sources section follows the same question the render gate already asks, chainNeedsSources(): only the first stage is ever handed the sources, so a chain led by blur or text makes the whole section beside the point and it is hidden rather than left there to be filled in vain. Today that shows it for scanlines and tiles, but no effect is named in the GUI — a new one declaring needsSources() gets this for free. rebuildChainRows() is the one funnel every chain change goes through, so that is where the visibility is set. New in the project menu now starts empty too, matching what the window opens with. Co-Authored-By: Claude Opus 5 --- src/cli/options.cpp | 4 ++-- src/gui/window.cpp | 17 +++++++++++++---- src/gui/window.hpp | 2 ++ src/main.cpp | 9 ++++++++- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/cli/options.cpp b/src/cli/options.cpp index ac8437f..582e2ec 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -160,7 +160,6 @@ Options parseOptions(int argc, char** argv) { if (const auto* format = canvasFormatByKey(options.format)) { canvasFormatSize(*format, options.dpi, options.width, options.height); } - if (options.effects.empty()) options.effects.push_back(defaultEffectName()); return options; } @@ -169,7 +168,8 @@ std::string usage(const char* programName) { out << "Usage: " << programName << " [options] [image ...]\n\n" << "Image effects app. Runs the GTK interface unless --output is given.\n\n" << "Options:\n" - << " -e, --effect NAME effect to apply, repeat to cascade (default: " << defaultEffectName() << ")\n" + << " -e, --effect NAME effect to apply, repeat to cascade (--output" + " defaults to " << defaultEffectName() << ")\n" << " -o, --output FILE render headlessly to this file and exit\n" << " -s, --size WxH canvas size, each side " << kMinCanvasSide << "-" << kMaxCanvasSide << " (default: 1920x1080)\n" diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 59a53f3..355413d 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -49,7 +49,8 @@ EffectorWindow::EffectorWindow(const Options& initial) { set_default_size(1180, 760); effects_ = effects(); - stages_.push_back({effects_.front(), effects_.front()->defaults()}); + // The chain starts empty: the interface opens on "No effects yet" and the + // user picks the first stage, rather than being handed one at random. seed_ = Rng::randomSeed(); renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished)); @@ -191,6 +192,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() { return *sidebar; } +// Only the first stage is ever handed the sources, so whether the chain wants +// them at all is `chainNeedsSources`. An effect that starts from nothing (blur, +// text) makes the whole sources section beside the point, so it is hidden +// rather than left there to be filled in vain. This is the same declarative +// answer the render gate uses -- no effect is named here. +void EffectorWindow::updateSourcesVisible() { + sourcesExpander_.set_visible(chainNeedsSources(stages_)); +} + // The expander is collapsed by default, so its label carries the count that // used to be plain to see in the list. void EffectorWindow::updateSourcesLabel() { @@ -952,6 +962,7 @@ void EffectorWindow::rebuildChainRows() { selectStage(selectedStage_); suspendStageSelect_ = false; + updateSourcesVisible(); } void EffectorWindow::selectStage(int index) { @@ -1242,9 +1253,7 @@ void EffectorWindow::clearSources() { } void EffectorWindow::onNewProject() { - Project fresh; - fresh.stages.push_back({effects_.front(), effects_.front()->defaults()}); - applyProject(fresh, ""); + applyProject(Project(), ""); // same empty chain the window opens with } void EffectorWindow::onOpenProject() { diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 1deb21e..1a5cee9 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -49,6 +49,8 @@ private: void cancelLoad(); void appendSourceRow(const std::string& path); void updateSourcesLabel(); // the collapsed expander shows the count + // The whole sources section only shows while the chain asks for sources. + void updateSourcesVisible(); Gtk::Widget& buildCanvasSection(); // "" while "Custom" is picked, otherwise the format's key. diff --git a/src/main.cpp b/src/main.cpp index 79fe47c..fe5aa36 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,7 +18,14 @@ namespace { int renderHeadless(const effector::Options& options) { - const auto stages = effector::buildChain(options); + auto stages = effector::buildChain(options); + // A headless run always renders something, so with no -e the default + // effect stands in. The interface has no such need: it opens on an empty + // chain and waits for the user to add the first stage. + if (stages.empty()) { + const auto effect = effector::effectByName(effector::defaultEffectName()); + stages.push_back({effect, effect->defaults()}); + } std::vector failures; From eb417bb41ad952e083cacab73507f0ed870bef1d Mon Sep 17 00:00:00 2001 From: Si Date: Sat, 19 Sep 2026 21:11:17 +0200 Subject: [PATCH 2/3] Let a chain stage be switched off without taking it apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trying a stage out meant removing it and building it again from scratch, settings and all, to see what the picture looked like without it. A stage now carries an `enabled` flag and can simply be switched off: it keeps its place in the chain and everything it was set to. `enabledStages` is the one place that reads the flag, and renderChain, chainNeedsSources, chainTitle and chainSlug all go through it. A disabled stage is skipped whole — handed no image and drawing nothing from the shared rng — so switching one off renders byte for byte what the chain rendered before that stage was added, which is the only thing that makes the switch worth anything while comparing. Whichever stage runs first is the one handed the sources, so disabling a leading scanlines lets blur lead and the chain needs no images at all. The status line and the generated file name follow too: they name what was rendered, not what sits in the sidebar. A chain with every stage disabled throws rather than quietly rendering nothing. In the interface it is a check on the chain row: the row dims, the params heading says "(off)" and the settings stay editable — a stage being worked on is not the same as one being deleted. On the command line it is `--disable NAME`, which picks its stage by effect name exactly as the params do. A project writes `"enabled": false` only for a stage that is off, so existing project files are unchanged and an ordinary one stays uncluttered. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 19 +++++++++++--- src/cli/options.cpp | 8 ++++++ src/cli/options.hpp | 7 +++++- src/core/chain.cpp | 38 +++++++++++++++++++--------- src/core/chain.hpp | 27 ++++++++++++++------ src/core/project.cpp | 8 ++++++ src/gui/window.cpp | 45 ++++++++++++++++++++++++++++++--- src/gui/window.hpp | 2 ++ tests/core_tests.cpp | 59 ++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 185 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ab47bf4..b35d896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,10 +73,20 @@ the whole of the cascade — an effect neither knows nor cares whether its input came from disk. All stages share one `Rng` and run strictly in sequence, so a seed still reproduces the cascade byte for byte. +A stage carries a `ChainStage::enabled` flag, and `enabledStages` +(`src/core/chain.cpp`) is the one place that reads it: a disabled stage is +skipped whole — handed no image and drawing nothing from the shared rng — so +switching one off renders exactly what the chain rendered before it was added. +`renderChain`, `chainNeedsSources`, `chainTitle` and `chainSlug` all go through +it, which is why the status line and the file name never mention a stage that +did not run. A chain with every stage disabled throws rather than rendering +nothing. + The interface builds the chain in the sidebar ("Effect chain": add from the -menu, reorder, remove, one stage selected at a time with its params below), the -CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur ---radius 5`. Flags share one namespace across all effects, so `buildChain` +menu, reorder, remove, switch off with the check on the row, one stage selected +at a time with its params below), the CLI builds it from repeated `-e`: +`-e scanlines --strip-height 6 -e blur --radius 5`, with `--disable NAME` +leaving a stage in place but skipping it. Flags share one namespace across all effects, so `buildChain` (`src/cli/options.cpp`) hands each flag to the stage whose effect declares it — two effects must not declare the same param key. @@ -94,7 +104,8 @@ pixels, so a project saved as A4 reopens as A4. ### 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 +chain with its params (and its `"enabled": false`, written only for a stage +that is switched off), 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 diff --git a/src/cli/options.cpp b/src/cli/options.cpp index 582e2ec..24e952d 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -1,5 +1,6 @@ #include "cli/options.hpp" +#include #include #include #include @@ -89,6 +90,10 @@ Options parseOptions(int argc, char** argv) { std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi)); } options.dpi = static_cast(dpi); + } else if (arg == "--disable") { + const std::string name = requireValue(argc, argv, i, arg); + effectByName(name); // validate now, exactly as -e does + options.disabled.push_back(name); } else if (arg == "--seed") { const std::string value = requireValue(argc, argv, i, arg); long seed = 0; @@ -176,6 +181,7 @@ std::string usage(const char* programName) { << " --format NAME canvas format, overrides --size (see below)\n" << " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi << " (default: " << kDefaultDpi << ")\n" + << " --disable NAME keep this effect in the chain but skip it\n" << " --seed N random seed (default: random)\n" << " --list-effects list available effects and exit\n" << " -h, --help show this help and exit\n"; @@ -207,6 +213,8 @@ std::vector buildChain(const Options& options) { ChainStage stage; stage.effect = effectByName(name); stage.values = stage.effect->defaults(); + stage.enabled = std::find(options.disabled.begin(), options.disabled.end(), name) == + options.disabled.end(); for (const auto& spec : stage.effect->params()) { const auto it = options.params.find(spec.key); if (it != options.params.end()) stage.values[spec.key] = it->second; diff --git a/src/cli/options.hpp b/src/cli/options.hpp index e46e2f9..6cb4d2b 100644 --- a/src/cli/options.hpp +++ b/src/cli/options.hpp @@ -14,6 +14,10 @@ namespace effector { struct Options { std::vector images; std::vector effects; // one per cascade stage, in order + // The effects named by --disable: their stages stay in the chain, in + // place, but are skipped. Named rather than numbered, exactly as the + // params are, so two stages of one effect are disabled together. + std::vector disabled; std::string output; // empty -> launch the interface int width = 1920; int height = 1080; @@ -33,7 +37,8 @@ std::string usage(const char* programName); // Turns the parsed names and flags into cascade stages: every flag goes to the // stage whose effect declares it, so --strip-height and --radius can be given -// together for `-e scanlines -e blur`. +// together for `-e scanlines -e blur`. A stage named by --disable is built +// with all its settings and marked to be skipped. std::vector buildChain(const Options& options); } // namespace effector diff --git a/src/core/chain.cpp b/src/core/chain.cpp index bb52ec8..d7a5051 100644 --- a/src/core/chain.cpp +++ b/src/core/chain.cpp @@ -2,14 +2,31 @@ namespace effector { +std::vector enabledStages(const std::vector& stages) { + std::vector running; + running.reserve(stages.size()); + for (const auto& stage : stages) { + if (stage.enabled && stage.effect) running.push_back(&stage); + } + return running; +} + Image renderChain(const std::vector& stages, const std::vector& sources, int width, int height, Rng& rng) { if (stages.empty()) throw EffectError("The effect chain is empty."); + for (const auto& stage : stages) { + if (!stage.effect) throw EffectError("The effect chain has an empty stage."); + } + + // A disabled stage never reaches the render at all: it is not handed an + // image and draws nothing from the shared rng, so switching one off gives + // back exactly the picture the chain made before it was added. + const auto running = enabledStages(stages); + if (running.empty()) throw EffectError("Every effect in the chain is disabled."); Image image; - for (std::size_t i = 0; i < stages.size(); ++i) { - const auto& stage = stages[i]; - if (!stage.effect) throw EffectError("The effect chain has an empty stage."); + for (std::size_t i = 0; i < running.size(); ++i) { + const auto& stage = *running[i]; auto values = stage.effect->defaults(); for (const auto& [key, value] : stage.values) values[key] = value; @@ -27,26 +44,25 @@ Image renderChain(const std::vector& stages, const std::vector& stages) { - if (stages.empty() || !stages.front().effect) return false; - return stages.front().effect->needsSources(); + const auto running = enabledStages(stages); + if (running.empty()) return false; + return running.front()->effect->needsSources(); } std::string chainTitle(const std::vector& stages) { std::string text; - for (const auto& stage : stages) { - if (!stage.effect) continue; + for (const auto* stage : enabledStages(stages)) { if (!text.empty()) text += " → "; - text += stage.effect->title(); + text += stage->effect->title(); } return text; } std::string chainSlug(const std::vector& stages) { std::string text; - for (const auto& stage : stages) { - if (!stage.effect) continue; + for (const auto* stage : enabledStages(stages)) { if (!text.empty()) text += "-"; - text += stage.effect->name(); + text += stage->effect->name(); } return text; } diff --git a/src/core/chain.hpp b/src/core/chain.hpp index 420470b..2b712d6 100644 --- a/src/core/chain.hpp +++ b/src/core/chain.hpp @@ -21,23 +21,34 @@ inline constexpr int kMaxCanvasSide = 20000; struct ChainStage { EffectPtr effect; ParamValues values; // missing keys fall back to the effect's defaults + // A disabled stage is skipped whole, keeping its settings and its place in + // the chain, so a stage can be switched off and back on while trying + // things out without being taken apart and rebuilt. + bool enabled = true; }; -// Renders the stages in order. The first one sees the loaded sources; every -// later one sees the previous stage's output as its only source, so blur after -// scanlines softens the scanlines rather than the photographs. +// The stages that actually run, in order, as pointers into `stages`. A +// disabled stage draws nothing from the rng and is handed no image, so a chain +// renders exactly as it would if that stage had never been added -- this is +// the one place the rest of the code asks what "disabled" means. +std::vector enabledStages(const std::vector& stages); + +// Renders the enabled stages in order. The first one sees the loaded sources; +// every later one sees the previous stage's output as its only source, so blur +// after scanlines softens the scanlines rather than the photographs. // // All stages share one Rng and run strictly in sequence, so a seed still -// reproduces the cascade byte for byte. +// reproduces the cascade byte for byte. Throws when no stage is left to run. Image renderChain(const std::vector& stages, const std::vector& sources, int width, int height, Rng& rng); -// Whether the chain has to be given source images. Only the first stage ever -// sees them — every later one works on the stage before it — so this is simply -// whether the first effect declares that it needs them. +// Whether the chain has to be given source images. Only the first stage that +// runs ever sees them — every later one works on the stage before it — so this +// is simply whether that effect declares that it needs them. bool chainNeedsSources(const std::vector& stages); -// "Scanlines → Blur", for window titles and status lines. +// "Scanlines → Blur", for window titles and status lines. Both of these name +// what was rendered, so a disabled stage is left out of them. std::string chainTitle(const std::vector& stages); // "scanlines-blur", for file names. diff --git a/src/core/project.cpp b/src/core/project.cpp index 1d86f1e..1a2eb3a 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -359,6 +359,10 @@ std::string projectToJson(const Project& project, const std::string& directory) if (!stage.effect) continue; out << (i == 0 ? "\n" : ",\n"); out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n"; + // Only written when it is false: a stage is enabled unless the file + // says otherwise, so an ordinary project stays as uncluttered as it + // was before a stage could be switched off at all. + if (!stage.enabled) out << " \"enabled\": false,\n"; out << " \"params\": {"; const auto& specs = stage.effect->params(); @@ -466,6 +470,10 @@ Project projectFromJson(const std::string& text, const std::string& directory) { ChainStage stage; stage.effect = effectByName(name->text); // throws on an unknown effect stage.values = stage.effect->defaults(); + if (const auto* enabled = item.member("enabled"); + enabled != nullptr && enabled->kind == Json::Kind::Bool) { + stage.enabled = enabled->boolean; + } // 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 diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 355413d..d90e4cf 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -366,8 +366,10 @@ void EffectorWindow::buildParamWidgets() { return; } const auto& stage = stages_[static_cast(selectedStage_)]; + // The settings of a stage that is switched off stay editable: it is still + // being worked on, it just is not in the picture for the moment. paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() + - " settings"); + (stage.enabled ? " settings" : " settings (off)")); int row = 0; for (const auto& spec : stage.effect->params()) { @@ -913,6 +915,12 @@ void EffectorWindow::rebuildChainRows() { // removing it would leave the empty list blank. while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row); + // The first stage that actually runs is the one handed the sources, which + // is not stage 1 once stage 1 is switched off. + const auto running = enabledStages(stages_); + const int firstRunning = + running.empty() ? -1 : static_cast(running.front() - stages_.data()); + for (std::size_t i = 0; i < stages_.size(); ++i) { const int index = static_cast(i); auto* row = Gtk::make_managed(); @@ -922,13 +930,28 @@ void EffectorWindow::rebuildChainRows() { box->set_margin_start(8); box->set_margin_end(4); + // The check is what switches a stage off: it keeps its settings and + // its place in the chain, and the render simply skips it. + auto* check = Gtk::make_managed(); + check->set_active(stages_[i].enabled); + check->set_tooltip_text("Apply this effect"); + check->set_valign(Gtk::Align::CENTER); + // Connected after set_active, so filling in the rebuilt rows does not + // look like the user reaching for the check. + check->signal_toggled().connect([this, index, check] { + setStageEnabled(index, check->get_active()); + }); + box->append(*check); + auto* label = Gtk::make_managed(std::to_string(index + 1) + ". " + stages_[i].effect->title()); label->set_xalign(0.0F); label->set_hexpand(true); label->set_ellipsize(Pango::EllipsizeMode::END); - label->set_tooltip_text(index == 0 ? "Applied to the source images" - : "Applied to the output of the stage above"); + if (!stages_[i].enabled) label->add_css_class("dim-label"); + label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off" + : index == firstRunning ? "Applied to the source images" + : "Applied to the output so far"); box->append(*label); struct Action { @@ -965,6 +988,19 @@ void EffectorWindow::rebuildChainRows() { updateSourcesVisible(); } +// Switching a stage off leaves everything about it alone -- its settings, its +// place in the chain, the stage the params below are showing -- so the only +// visible change is the dimmed row and the picture the render comes back with. +void EffectorWindow::setStageEnabled(int index, bool enabled) { + if (index < 0 || static_cast(index) >= stages_.size()) return; + auto& stage = stages_[static_cast(index)]; + if (stage.enabled == enabled) return; + stage.enabled = enabled; + rebuildChainRows(); // the row dims, and another stage may now lead the chain + if (index == selectedStage_) buildParamWidgets(); // the heading says "off" + requestRender(); +} + void EffectorWindow::selectStage(int index) { if (auto* row = chainList_.get_row_at_index(index)) chainList_.select_row(*row); } @@ -1039,12 +1075,13 @@ void EffectorWindow::onRecreate() { // ever handed the sources, so an effect that does not want them (blur) renders // happily with none loaded. bool EffectorWindow::canRender() const { - if (stages_.empty()) return false; + if (enabledStages(stages_).empty()) return false; return !sources_->empty() || !chainNeedsSources(stages_); } std::string EffectorWindow::missingText() const { if (stages_.empty()) return "Add an effect to start."; + if (enabledStages(stages_).empty()) return "Every effect is switched off."; return "Add source images to start."; } diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 1a5cee9..1ee4a0a 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -66,6 +66,8 @@ private: void appendStage(const EffectPtr& effect); void removeStage(int index); void moveStage(int index, int delta); + // The check on a chain row: the stage stays where it is, and is skipped. + void setStageEnabled(int index, bool enabled); void rebuildChainRows(); void selectStage(int index); void onStageSelected(); diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 0872dac..adee19c 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -889,6 +889,50 @@ int main() { scanlinesThrew = true; } check(scanlinesThrew, "scanlines still refuses to run without sources"); + + // A stage switched off is skipped whole: it is handed no image and + // draws nothing from the shared rng, so the cascade comes out exactly + // as it would have without that stage in it at all. + ChainStage blurOff = blur; + blurOff.enabled = false; + ChainStage scanlinesOff = scanlines; + scanlinesOff.enabled = false; + + Rng skipping(5); + const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping); + check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it"); + check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran"); + check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug"); + + // Whoever leads the chain is handed the sources, and that is the first + // stage still switched on. + check(!chainNeedsSources({scanlinesOff, blur}), + "a disabled leading stage leaves the question to the stage behind it"); + Rng leading(5); + const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading); + check(sameBytes(ledByBlur, sourceless), "the stage behind it leads instead"); + + bool allOffThrew = false; + try { + Rng rng(1); + renderChain({scanlinesOff, blurOff}, sources, 100, 100, rng); + } catch (const EffectError&) { + allOffThrew = true; + } + check(allOffThrew, "a chain with every stage disabled throws"); + + // The command line switches a stage off by effect name, the same way + // the params pick out the stage they belong to. + const auto cli = + parsedOptions({"-e", "scanlines", "-e", "blur", "--disable", "blur", "--radius", "4"}); + check(cli.has_value(), "--disable is accepted"); + const auto cliStages = buildChain(*cli); + check(cliStages.size() == 2 && cliStages[0].enabled && !cliStages[1].enabled, + "--disable keeps the stage in the chain and switches it off"); + check(paramInt(cliStages[1].values, "radius", 0) == 4, + "a switched-off stage still takes its flags"); + check(!parsedOptions({"-e", "blur", "--disable", "nope"}).has_value(), + "--disable refuses an unknown effect"); } std::cout << "project\n"; @@ -949,6 +993,21 @@ int main() { check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(), "a stage only keeps the params its effect declares"); + // A stage switched off keeps its place and its settings in the file and + // comes back switched off; nothing is written for the ordinary case. + Project toggled = project; + toggled.stages[1].enabled = false; + const auto offFile = dir / "disabled.json"; + saveProject(offFile.string(), toggled); + const auto offBack = loadProject(offFile.string()); + check(offBack.stages.size() == 2 && offBack.stages[0].enabled && !offBack.stages[1].enabled, + "a switched-off stage comes back switched off"); + check(paramInt(offBack.stages[1].values, "radius", 0) == 3, + "and with the settings it was switched off with"); + check(projectToJson(toggled).find("\"enabled\"") != std::string::npos && + projectToJson(project).find("\"enabled\"") == std::string::npos, + "only a switched-off stage is written out as one"); + int refusals = 0; for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}", "{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) { From 3aade6f361aba930c7a3dff270270b3ccac22069 Mon Sep 17 00:00:00 2001 From: Si Date: Sat, 19 Sep 2026 21:55:55 +0200 Subject: [PATCH 3/3] Let several text stages share one named look A picture with a title, a caption and a credit meant typing the same font, size and colour into every text stage, and changing the look meant typing them all again. A project now holds named styles: a stage follows one, and editing it moves every stage that follows it at once. The front-ends learn nothing effect-specific. A ParamSpec can be marked .styled(), saying a style carries it rather than the stage, and Effect::styleKind() names the pool it comes from -- so styleSpecs(kind) is to a style what params() is to a stage, and a second text-ish effect would slot into the styles that already exist. The look is what a style carries; the words and the placement stay on the stage, which is what lets two stages share a typography in two different corners. stageValues() resolves one in a single place, and a stage keeps a full set of values of its own beside the style, so switching it to Custom, dropping the style, or opening the project in a build that does not know it renders exactly the same picture. A style moves stages together; it never holds the only copy of anything. The command line builds no styles and passes none, so it renders what it always did. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 32 ++++- CMakeLists.txt | 1 + src/core/chain.cpp | 23 ++- src/core/chain.hpp | 15 +- src/core/effect.cpp | 20 +-- src/core/effect.hpp | 6 + src/core/params.cpp | 23 +++ src/core/params.hpp | 12 ++ src/core/project.cpp | 230 ++++++++++++++++++----------- src/core/project.hpp | 9 +- src/core/style.cpp | 39 +++++ src/core/style.hpp | 39 +++++ src/effects/text.cpp | 31 ++-- src/effects/text.hpp | 1 + src/gui/window.cpp | 336 +++++++++++++++++++++++++++++++++++++++++-- src/gui/window.hpp | 34 ++++- tests/core_tests.cpp | 170 ++++++++++++++++++++++ 17 files changed, 890 insertions(+), 131 deletions(-) create mode 100644 src/core/style.cpp create mode 100644 src/core/style.hpp diff --git a/CLAUDE.md b/CLAUDE.md index b35d896..3c5b70f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,17 @@ travel as `ParamValues` (a `map>`), read back with `paramInt`/`paramDouble`/`paramBool`/`paramChoice`. +A param can also be marked `.styled()`, which says a *named style* carries it +rather than the stage it sits on: the font and the colour of a text stage, but +never its words or its placement. The pool a style comes from is named by +`Effect::styleKind()` (empty by default — the effect takes no style; `"text"` +for the text effect), and effects answering the same kind share one pool, so a +second text-ish effect would slot into the styles that already exist. The +front-ends know nothing more than that: the interface builds the style row and +the project file writes a style's params from `styleSpecs(kind)` +(`src/core/style.cpp`), which is to a style exactly what `params()` is to a +stage. + Whether an effect works from the loaded photographs is part of the same contract: `Effect::needsSources()` (default true, false for blur). Only the first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`) @@ -82,6 +93,18 @@ it, which is why the status line and the file name never mention a stage that did not run. A chain with every stage disabled throws rather than rendering nothing. +### A style is a look several stages share + +`ChainStage::style` names a style, and `stageValues` (`src/core/chain.cpp`) is +the one place that resolves one: an effect's defaults, then the stage's own +values, then the style's values for the params it carries. A stage always keeps +a full set of values of its own beside the style, so switching it to "Custom", +dropping the style, or opening the project in a build that does not know the +style renders exactly the same picture — a style moves stages together, it +never holds the only copy of anything. The interface writes an edited param to +both the style and the stage for that reason. `renderChain` takes the pool as a +trailing argument; the command line builds no styles and passes none. + The interface builds the chain in the sidebar ("Effect chain": add from the menu, reorder, remove, switch off with the check on the row, one stage selected at a time with its params below), the CLI builds it from repeated `-e`: @@ -104,12 +127,15 @@ pixels, so a project saved as A4 reopens as A4. ### Projects are plain JSON `src/core/project.cpp` reads and writes the whole setup — source paths, the -chain with its params (and its `"enabled": false`, written only for a stage -that is switched off), canvas and seed — as a small JSON file, with its own +named styles, the chain with its params (and its `"enabled": false` and +`"style"`, each written only when there is something to say), 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. +project keeps the defaults for params that did not exist when it was saved. The +same leniency covers styles: one of a kind this build does not know, one with +no name or a repeated one, and a stage naming a style the file does not hold +are each dropped on their own rather than refusing the project. Source paths under the project's own folder are written relative, so a folder can be moved or shared whole. diff --git a/CMakeLists.txt b/CMakeLists.txt index 5f6e3c5..a6f3eec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ add_library(effector_core STATIC src/core/project.cpp src/core/registry.cpp src/core/rng.cpp + src/core/style.cpp src/effects/blur.cpp src/effects/grain.cpp src/effects/monochrome.cpp diff --git a/src/core/chain.cpp b/src/core/chain.cpp index d7a5051..33e2d0c 100644 --- a/src/core/chain.cpp +++ b/src/core/chain.cpp @@ -11,8 +11,26 @@ std::vector enabledStages(const std::vector& stag return running; } +ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) { + ParamValues values = stage.effect->defaults(); + for (const auto& [key, value] : stage.values) values[key] = value; + + const Style* style = findStyle(styles, stage.effect->styleKind(), stage.style); + if (style != nullptr) { + // Only the params the effect marked styled(): the words and the + // placement stay the stage's own, which is what lets two stages share + // one look without sharing a position. + for (const auto& spec : stage.effect->params()) { + if (!spec.inStyle) continue; + const auto found = style->values.find(spec.key); + if (found != style->values.end()) values[spec.key] = found->second; + } + } + return values; +} + Image renderChain(const std::vector& stages, const std::vector& sources, - int width, int height, Rng& rng) { + int width, int height, Rng& rng, const StyleSet& styles) { if (stages.empty()) throw EffectError("The effect chain is empty."); for (const auto& stage : stages) { if (!stage.effect) throw EffectError("The effect chain has an empty stage."); @@ -28,8 +46,7 @@ Image renderChain(const std::vector& stages, const std::vectordefaults(); - for (const auto& [key, value] : stage.values) values[key] = value; + const auto values = stageValues(stage, styles); if (i == 0) { image = stage.effect->render(sources, width, height, rng, values); diff --git a/src/core/chain.hpp b/src/core/chain.hpp index 2b712d6..d779998 100644 --- a/src/core/chain.hpp +++ b/src/core/chain.hpp @@ -7,6 +7,7 @@ #include "core/image.hpp" #include "core/params.hpp" #include "core/rng.hpp" +#include "core/style.hpp" namespace effector { @@ -25,8 +26,18 @@ struct ChainStage { // the chain, so a stage can be switched off and back on while trying // things out without being taken apart and rebuilt. bool enabled = true; + // The style this stage follows, by name, in the pool its effect names with + // styleKind(); empty when the stage stands on its own. A stage keeps a + // full set of values either way, so a style that is dropped or renamed + // away leaves the stage rendering exactly what it rendered before. + std::string style = {}; }; +// The values a stage actually runs with: its effect's defaults, then the +// stage's own values, then the values of the style it follows for the params +// that style carries. The one place a style is resolved. +ParamValues stageValues(const ChainStage& stage, const StyleSet& styles); + // The stages that actually run, in order, as pointers into `stages`. A // disabled stage draws nothing from the rng and is handed no image, so a chain // renders exactly as it would if that stage had never been added -- this is @@ -39,8 +50,10 @@ std::vector enabledStages(const std::vector& stag // // All stages share one Rng and run strictly in sequence, so a seed still // reproduces the cascade byte for byte. Throws when no stage is left to run. +// `styles` is the project's pool; a chain built without one -- the command +// line's -- passes none and renders from the stages alone. Image renderChain(const std::vector& stages, const std::vector& sources, - int width, int height, Rng& rng); + int width, int height, Rng& rng, const StyleSet& styles = {}); // Whether the chain has to be given source images. Only the first stage that // runs ever sees them — every later one works on the stage before it — so this diff --git a/src/core/effect.cpp b/src/core/effect.cpp index d6590a0..e28ef8b 100644 --- a/src/core/effect.cpp +++ b/src/core/effect.cpp @@ -4,25 +4,7 @@ namespace effector { ParamValues Effect::defaults() const { ParamValues values; - for (const auto& spec : params()) { - switch (spec.type) { - case ParamSpec::Type::Int: - values[spec.key] = static_cast(spec.defaultValue); - break; - case ParamSpec::Type::Double: - values[spec.key] = spec.defaultValue; - break; - case ParamSpec::Type::Bool: - values[spec.key] = spec.defaultValue != 0.0; - break; - case ParamSpec::Type::Choice: - case ParamSpec::Type::Path: - case ParamSpec::Type::Text: - case ParamSpec::Type::Color: - values[spec.key] = spec.defaultChoice; - break; - } - } + for (const auto& spec : params()) values[spec.key] = paramDefault(spec); return values; } diff --git a/src/core/effect.hpp b/src/core/effect.hpp index af60c9a..1becff7 100644 --- a/src/core/effect.hpp +++ b/src/core/effect.hpp @@ -32,6 +32,12 @@ public: // chain that starts with one of them needs no sources at all. virtual bool needsSources() const { return true; } + // The pool of named styles this effect's styled params are drawn from, or + // empty when it takes no style. Effects that answer the same kind share + // one pool, so a stage can be pointed at a look its neighbours already + // use. An effect with no kind must declare no styled params. + virtual std::string styleKind() const { return {}; } + virtual Image render(const std::vector& sources, int width, int height, Rng& rng, const ParamValues& values) const = 0; diff --git a/src/core/params.cpp b/src/core/params.cpp index 76e2f19..fc95c52 100644 --- a/src/core/params.cpp +++ b/src/core/params.cpp @@ -10,6 +10,12 @@ std::string ParamSpec::flag() const { return flag; } +ParamSpec ParamSpec::styled() const { + ParamSpec copy = *this; + copy.inStyle = true; + return copy; +} + ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum, long maximum, std::string help, long step) { ParamSpec spec; @@ -103,6 +109,23 @@ const ParamValue* find(const ParamValues& values, const std::string& key) { } // namespace +ParamValue paramDefault(const ParamSpec& spec) { + switch (spec.type) { + case ParamSpec::Type::Int: + return static_cast(spec.defaultValue); + case ParamSpec::Type::Double: + return spec.defaultValue; + case ParamSpec::Type::Bool: + return spec.defaultValue != 0.0; + case ParamSpec::Type::Choice: + case ParamSpec::Type::Path: + case ParamSpec::Type::Text: + case ParamSpec::Type::Color: + break; + } + return spec.defaultChoice; +} + long paramInt(const ParamValues& values, const std::string& key, long fallback) { if (const auto* value = find(values, key)) { if (const auto* v = std::get_if(value)) return *v; diff --git a/src/core/params.hpp b/src/core/params.hpp index eba8421..a834cc5 100644 --- a/src/core/params.hpp +++ b/src/core/params.hpp @@ -24,9 +24,17 @@ struct ParamSpec { int digits = 2; std::string defaultChoice; std::vector choices; + // Whether a named style carries this param rather than the stage it sits + // on: see core/style.hpp. + bool inStyle = false; std::string flag() const; // "strip_height" -> "--strip-height" + // A copy of this spec marked as carried by a style, so that a declaration + // reads makeDouble("font_size", ...).styled() and the factories keep the + // signatures they have. + ParamSpec styled() const; + static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum, long maximum, std::string help = {}, long step = 1); static ParamSpec makeDouble(std::string key, std::string label, double defaultValue, @@ -53,6 +61,10 @@ struct ParamSpec { using ParamValue = std::variant; using ParamValues = std::map; +// The value a spec stands for when nothing has been set: one place, so that +// an effect's defaults and a style's are filled the same way. +ParamValue paramDefault(const ParamSpec& spec); + long paramInt(const ParamValues& values, const std::string& key, long fallback); double paramDouble(const ParamValues& values, const std::string& key, double fallback); bool paramBool(const ParamValues& values, const std::string& key, bool fallback); diff --git a/src/core/project.cpp b/src/core/project.cpp index 1a2eb3a..71d7b46 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -325,6 +325,96 @@ std::string resolve(const std::string& path, const std::string& directory) { return (std::filesystem::path(directory) / file).lexically_normal().string(); } +// The params of a stage or of a style, written from the specs rather than from +// whatever the values happen to hold: a key the effect does not declare is +// never written, so it can never be read back either. +void writeParams(std::ostream& out, const std::vector& specs, const ParamValues& values, + const std::string& directory) { + out << "{"; + 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(values, spec.key, spec.defaultValue != 0.0) ? "true" : "false"); + break; + case ParamSpec::Type::Int: + out << paramInt(values, spec.key, static_cast(spec.defaultValue)); + break; + case ParamSpec::Type::Double: + out << number(paramDouble(values, spec.key, spec.defaultValue), spec.digits); + break; + case ParamSpec::Type::Choice: + case ParamSpec::Type::Text: + out << quote(paramText(values, spec.key, spec.defaultChoice)); + break; + case ParamSpec::Type::Color: { + std::uint8_t rgb[3]; + paramColor(values, spec.key, spec.defaultChoice, rgb); + out << quote(formatColor(rgb)); + break; + } + case ParamSpec::Type::Path: { + // Written relative to the project folder, exactly as the + // sources are, so a folder still opens once it is moved. + const auto path = paramText(values, spec.key, ""); + out << quote(path.empty() ? path : relativeTo(path, directory)); + break; + } + } + } + out << (specs.empty() ? "}" : "\n }"); +} + +// The mirror image: only declared keys are read, each behind the gate its type +// asks for, so anything else in the file leaves the default standing. +void readParams(const std::vector& specs, const Json& params, + const std::string& directory, ParamValues& values) { + for (const auto& spec : specs) { + const auto* value = params.member(spec.key); + if (value == nullptr) continue; + switch (spec.type) { + case ParamSpec::Type::Bool: + if (value->kind == Json::Kind::Bool) values[spec.key] = value->boolean; + break; + case ParamSpec::Type::Int: + if (value->kind == Json::Kind::Number) { + values[spec.key] = static_cast(std::llround(value->number)); + } + break; + case ParamSpec::Type::Double: + if (value->kind == Json::Kind::Number) values[spec.key] = value->number; + break; + case ParamSpec::Type::Path: + if (value->kind == Json::Kind::String) { + values[spec.key] = + value->text.empty() ? value->text : resolve(value->text, directory); + } + break; + case ParamSpec::Type::Text: + if (value->kind == Json::Kind::String) values[spec.key] = value->text; + break; + case ParamSpec::Type::Color: { + // A colour that does not parse keeps the default, exactly as + // an unknown choice does. + std::uint8_t rgb[3]; + if (value->kind == Json::Kind::String && parseColor(value->text, rgb)) { + values[spec.key] = formatColor(rgb); + } + break; + } + case ParamSpec::Type::Choice: + for (const auto& choice : spec.choices) { + if (value->kind == Json::Kind::String && value->text == choice) { + values[spec.key] = choice; + } + } + break; + } + } +} + } // namespace std::string projectToJson(const Project& project, const std::string& directory) { @@ -353,6 +443,21 @@ std::string projectToJson(const Project& project, const std::string& directory) } out << (project.sources.empty() ? "" : "\n ") << "],\n"; + // Before the chain, because a stage's "style" is a name from this list. + if (!project.styles.empty()) { + out << " \"styles\": ["; + for (std::size_t i = 0; i < project.styles.size(); ++i) { + const auto& style = project.styles[i]; + out << (i == 0 ? "\n" : ",\n"); + out << " {\n \"kind\": " << quote(style.kind) << ",\n"; + out << " \"name\": " << quote(style.name) << ",\n"; + out << " \"params\": "; + writeParams(out, styleSpecs(style.kind), style.values, directory); + out << "\n }"; + } + out << "\n ],\n"; + } + out << " \"chain\": ["; for (std::size_t i = 0; i < project.stages.size(); ++i) { const auto& stage = project.stages[i]; @@ -363,45 +468,12 @@ std::string projectToJson(const Project& project, const std::string& directory) // says otherwise, so an ordinary project stays as uncluttered as it // was before a stage could be switched off at all. if (!stage.enabled) out << " \"enabled\": false,\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: - case ParamSpec::Type::Text: - out << quote(paramText(stage.values, spec.key, spec.defaultChoice)); - break; - case ParamSpec::Type::Color: { - std::uint8_t rgb[3]; - paramColor(stage.values, spec.key, spec.defaultChoice, rgb); - out << quote(formatColor(rgb)); - break; - } - case ParamSpec::Type::Path: { - // Written relative to the project folder, exactly as the - // sources are, so a folder still opens once it is moved. - const auto path = paramText(stage.values, spec.key, ""); - out << quote(path.empty() ? path : relativeTo(path, directory)); - break; - } - } - } - out << (specs.empty() ? "}" : "\n }") << "\n }"; + // Likewise only when there is one: a stage that stands on its own + // values says nothing about styles at all. + if (!stage.style.empty()) out << " \"style\": " << quote(stage.style) << ",\n"; + out << " \"params\": "; + writeParams(out, stage.effect->params(), stage.values, directory); + out << "\n }"; } out << (project.stages.empty() ? "" : "\n ") << "]\n"; out << "}\n"; @@ -457,6 +529,33 @@ Project projectFromJson(const std::string& text, const std::string& directory) { } } + // Read before the chain, so that a stage's "style" can be checked against + // the pool as it is read. + if (const auto* styles = root.member("styles")) { + for (const auto& item : styles->items) { + const auto* kind = item.member("kind"); + const auto* name = item.member("name"); + if (kind == nullptr || kind->kind != Json::Kind::String) continue; + if (name == nullptr || name->kind != Json::Kind::String || name->text.empty()) continue; + + // A kind no effect in this build claims carries no params, so + // there would be nothing to keep; a repeated name would make + // "style": "Title" ambiguous, so the first one wins. + const auto specs = styleSpecs(kind->text); + if (specs.empty()) continue; + if (findStyle(project.styles, kind->text, name->text) != nullptr) continue; + + Style style; + style.kind = kind->text; + style.name = name->text; + style.values = styleDefaults(style.kind); + if (const auto* params = item.member("params")) { + readParams(specs, *params, directory, style.values); + } + project.styles.push_back(std::move(style)); + } + } + 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"); @@ -479,48 +578,17 @@ Project projectFromJson(const std::string& text, const std::string& directory) { // 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::Path: - if (value->kind == Json::Kind::String) { - stage.values[spec.key] = - value->text.empty() ? value->text : resolve(value->text, directory); - } - break; - case ParamSpec::Type::Text: - if (value->kind == Json::Kind::String) stage.values[spec.key] = value->text; - break; - case ParamSpec::Type::Color: { - // A colour that does not parse keeps the default, - // exactly as an unknown choice does. - std::uint8_t rgb[3]; - if (value->kind == Json::Kind::String && parseColor(value->text, rgb)) { - stage.values[spec.key] = formatColor(rgb); - } - 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; - } - } + readParams(stage.effect->params(), *params, directory, stage.values); + } + + // A style is kept only when the file actually holds one of that name + // for this effect's pool: a stage pointing at a style that is not + // there falls back to the values it wrote beside it, which are the + // ones it last rendered with. + if (const auto* style = item.member("style"); + style != nullptr && style->kind == Json::Kind::String && + findStyle(project.styles, stage.effect->styleKind(), style->text) != nullptr) { + stage.style = style->text; } project.stages.push_back(std::move(stage)); } diff --git a/src/core/project.hpp b/src/core/project.hpp index 5034a45..ffb1749 100644 --- a/src/core/project.hpp +++ b/src/core/project.hpp @@ -8,6 +8,7 @@ #include "core/canvas.hpp" #include "core/chain.hpp" #include "core/params.hpp" +#include "core/style.hpp" namespace effector { @@ -17,6 +18,10 @@ namespace effector { struct Project { std::vector sources; // absolute paths std::vector stages; + // The named looks the stages draw their formatting from. Kept beside the + // chain rather than inside it because that is the whole point of a style: + // several stages share one, and editing it moves all of them at once. + StyleSet styles; int width = 1920; int height = 1080; // The named format the canvas came from, empty for a custom pixel size. @@ -36,7 +41,9 @@ 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. +// does not declare are dropped, and missing ones keep the effect's default. A +// style of an unknown kind, or a stage naming a style the file does not hold, +// is dropped the same way: the stage keeps its own values and still renders. Project projectFromJson(const std::string& text, const std::string& directory = {}); // Both throw EffectError: the file is the user's, so failures are reported diff --git a/src/core/style.cpp b/src/core/style.cpp new file mode 100644 index 0000000..3380ffa --- /dev/null +++ b/src/core/style.cpp @@ -0,0 +1,39 @@ +#include "core/style.hpp" + +#include + +#include "core/registry.hpp" + +namespace effector { + +std::vector styleSpecs(const std::string& kind) { + std::vector specs; + if (kind.empty()) return specs; + for (const auto& effect : effects()) { + if (effect->styleKind() != kind) continue; + for (const auto& spec : effect->params()) { + if (!spec.inStyle) continue; + const bool known = std::any_of(specs.begin(), specs.end(), [&](const ParamSpec& seen) { + return seen.key == spec.key; + }); + if (!known) specs.push_back(spec); + } + } + return specs; +} + +ParamValues styleDefaults(const std::string& kind) { + ParamValues values; + for (const auto& spec : styleSpecs(kind)) values[spec.key] = paramDefault(spec); + return values; +} + +const Style* findStyle(const StyleSet& styles, const std::string& kind, const std::string& name) { + if (kind.empty() || name.empty()) return nullptr; + for (const auto& style : styles) { + if (style.kind == kind && style.name == name) return &style; + } + return nullptr; +} + +} // namespace effector diff --git a/src/core/style.hpp b/src/core/style.hpp new file mode 100644 index 0000000..cb9eabe --- /dev/null +++ b/src/core/style.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include "core/params.hpp" + +namespace effector { + +// A named set of formatting values, shared by every stage that follows it. +// +// A style carries only the params an effect marked styled() -- the look, never +// the placement -- so several text stages can share one typography while each +// keeps its own words and its own corner of the canvas. Changing the style is +// the point: it changes every stage pointing at it at once. +struct Style { + std::string kind; // the pool, as Effect::styleKind() names it + std::string name; // unique within a kind, never empty + ParamValues values; // the styled params only +}; + +using StyleSet = std::vector