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 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-19 21:55:55 +02:00
co-authored by Claude Opus 5
parent eb417bb41a
commit 3aade6f361
17 changed files with 890 additions and 131 deletions
+29 -3
View File
@@ -53,6 +53,17 @@ travel as `ParamValues` (a
`map<string, variant<long,double,bool,string>>`), read back with `map<string, variant<long,double,bool,string>>`), read back with
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`. `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 Whether an effect works from the loaded photographs is part of the same
contract: `Effect::needsSources()` (default true, false for blur). Only the contract: `Effect::needsSources()` (default true, false for blur). Only the
first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`) 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 did not run. A chain with every stage disabled throws rather than rendering
nothing. 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 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 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`: 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 ### Projects are plain JSON
`src/core/project.cpp` reads and writes the whole setup — source paths, the `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 named styles, the chain with its params (and its `"enabled": false` and
that is switched off), canvas and seed — as a small JSON file, with its own `"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' 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 `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 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 Source paths under the project's own folder are written relative, so a folder
can be moved or shared whole. can be moved or shared whole.
+1
View File
@@ -31,6 +31,7 @@ add_library(effector_core STATIC
src/core/project.cpp src/core/project.cpp
src/core/registry.cpp src/core/registry.cpp
src/core/rng.cpp src/core/rng.cpp
src/core/style.cpp
src/effects/blur.cpp src/effects/blur.cpp
src/effects/grain.cpp src/effects/grain.cpp
src/effects/monochrome.cpp src/effects/monochrome.cpp
+20 -3
View File
@@ -11,8 +11,26 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
return running; 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<ChainStage>& stages, const std::vector<Image>& sources, Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& 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."); if (stages.empty()) throw EffectError("The effect chain is empty.");
for (const auto& stage : stages) { for (const auto& stage : stages) {
if (!stage.effect) throw EffectError("The effect chain has an empty stage."); if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
@@ -28,8 +46,7 @@ Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image
for (std::size_t i = 0; i < running.size(); ++i) { for (std::size_t i = 0; i < running.size(); ++i) {
const auto& stage = *running[i]; const auto& stage = *running[i];
auto values = stage.effect->defaults(); const auto values = stageValues(stage, styles);
for (const auto& [key, value] : stage.values) values[key] = value;
if (i == 0) { if (i == 0) {
image = stage.effect->render(sources, width, height, rng, values); image = stage.effect->render(sources, width, height, rng, values);
+14 -1
View File
@@ -7,6 +7,7 @@
#include "core/image.hpp" #include "core/image.hpp"
#include "core/params.hpp" #include "core/params.hpp"
#include "core/rng.hpp" #include "core/rng.hpp"
#include "core/style.hpp"
namespace effector { namespace effector {
@@ -25,8 +26,18 @@ struct ChainStage {
// the chain, so a stage can be switched off and back on while trying // the chain, so a stage can be switched off and back on while trying
// things out without being taken apart and rebuilt. // things out without being taken apart and rebuilt.
bool enabled = true; 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 // 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 // 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 // renders exactly as it would if that stage had never been added -- this is
@@ -39,8 +50,10 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
// //
// All stages share one Rng and run strictly in sequence, so a seed still // 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. // 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<ChainStage>& stages, const std::vector<Image>& sources, Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& 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 // 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 // runs ever sees them — every later one works on the stage before it — so this
+1 -19
View File
@@ -4,25 +4,7 @@ namespace effector {
ParamValues Effect::defaults() const { ParamValues Effect::defaults() const {
ParamValues values; ParamValues values;
for (const auto& spec : params()) { for (const auto& spec : params()) values[spec.key] = paramDefault(spec);
switch (spec.type) {
case ParamSpec::Type::Int:
values[spec.key] = static_cast<long>(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;
}
}
return values; return values;
} }
+6
View File
@@ -32,6 +32,12 @@ public:
// chain that starts with one of them needs no sources at all. // chain that starts with one of them needs no sources at all.
virtual bool needsSources() const { return true; } 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<Image>& sources, int width, int height, Rng& rng, virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0; const ParamValues& values) const = 0;
+23
View File
@@ -10,6 +10,12 @@ std::string ParamSpec::flag() const {
return flag; 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, ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help, long step) { long maximum, std::string help, long step) {
ParamSpec spec; ParamSpec spec;
@@ -103,6 +109,23 @@ const ParamValue* find(const ParamValues& values, const std::string& key) {
} // namespace } // namespace
ParamValue paramDefault(const ParamSpec& spec) {
switch (spec.type) {
case ParamSpec::Type::Int:
return static_cast<long>(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) { long paramInt(const ParamValues& values, const std::string& key, long fallback) {
if (const auto* value = find(values, key)) { if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<long>(value)) return *v; if (const auto* v = std::get_if<long>(value)) return *v;
+12
View File
@@ -24,9 +24,17 @@ struct ParamSpec {
int digits = 2; int digits = 2;
std::string defaultChoice; std::string defaultChoice;
std::vector<std::string> choices; std::vector<std::string> 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" 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, static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help = {}, long step = 1); long maximum, std::string help = {}, long step = 1);
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue, static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
@@ -53,6 +61,10 @@ struct ParamSpec {
using ParamValue = std::variant<long, double, bool, std::string>; using ParamValue = std::variant<long, double, bool, std::string>;
using ParamValues = std::map<std::string, ParamValue>; using ParamValues = std::map<std::string, ParamValue>;
// 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); long paramInt(const ParamValues& values, const std::string& key, long fallback);
double paramDouble(const ParamValues& values, const std::string& key, double fallback); double paramDouble(const ParamValues& values, const std::string& key, double fallback);
bool paramBool(const ParamValues& values, const std::string& key, bool fallback); bool paramBool(const ParamValues& values, const std::string& key, bool fallback);
+148 -80
View File
@@ -325,6 +325,96 @@ std::string resolve(const std::string& path, const std::string& directory) {
return (std::filesystem::path(directory) / file).lexically_normal().string(); 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<ParamSpec>& 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<long>(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<ParamSpec>& 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<long>(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 } // namespace
std::string projectToJson(const Project& project, const std::string& directory) { 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"; 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\": ["; out << " \"chain\": [";
for (std::size_t i = 0; i < project.stages.size(); ++i) { for (std::size_t i = 0; i < project.stages.size(); ++i) {
const auto& stage = project.stages[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 // says otherwise, so an ordinary project stays as uncluttered as it
// was before a stage could be switched off at all. // was before a stage could be switched off at all.
if (!stage.enabled) out << " \"enabled\": false,\n"; if (!stage.enabled) out << " \"enabled\": false,\n";
out << " \"params\": {"; // Likewise only when there is one: a stage that stands on its own
// values says nothing about styles at all.
const auto& specs = stage.effect->params(); if (!stage.style.empty()) out << " \"style\": " << quote(stage.style) << ",\n";
bool first = true; out << " \"params\": ";
for (const auto& spec : specs) { writeParams(out, stage.effect->params(), stage.values, directory);
out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": "; out << "\n }";
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<long>(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 }";
} }
out << (project.stages.empty() ? "" : "\n ") << "]\n"; out << (project.stages.empty() ? "" : "\n ") << "]\n";
out << "}\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"); const auto* chain = root.member("chain");
if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) { if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) {
throw EffectError("the project has no effect chain"); 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 // declare is dropped, and an old project simply keeps the defaults for
// params that did not exist when it was written. // params that did not exist when it was written.
if (const auto* params = item.member("params")) { if (const auto* params = item.member("params")) {
for (const auto& spec : stage.effect->params()) { readParams(stage.effect->params(), *params, directory, stage.values);
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<long>(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;
}
} }
// 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)); project.stages.push_back(std::move(stage));
} }
+8 -1
View File
@@ -8,6 +8,7 @@
#include "core/canvas.hpp" #include "core/canvas.hpp"
#include "core/chain.hpp" #include "core/chain.hpp"
#include "core/params.hpp" #include "core/params.hpp"
#include "core/style.hpp"
namespace effector { namespace effector {
@@ -17,6 +18,10 @@ namespace effector {
struct Project { struct Project {
std::vector<std::string> sources; // absolute paths std::vector<std::string> sources; // absolute paths
std::vector<ChainStage> stages; std::vector<ChainStage> 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 width = 1920;
int height = 1080; int height = 1080;
// The named format the canvas came from, empty for a custom pixel size. // 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. // Throws EffectError on malformed JSON, an unknown effect or a missing chain.
// Relative source paths are resolved against `directory`; params the effect // 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 = {}); Project projectFromJson(const std::string& text, const std::string& directory = {});
// Both throw EffectError: the file is the user's, so failures are reported // Both throw EffectError: the file is the user's, so failures are reported
+39
View File
@@ -0,0 +1,39 @@
#include "core/style.hpp"
#include <algorithm>
#include "core/registry.hpp"
namespace effector {
std::vector<ParamSpec> styleSpecs(const std::string& kind) {
std::vector<ParamSpec> 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
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <string>
#include <vector>
#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<Style>;
// The styled specs of a kind: every registered effect answering that kind
// contributes its styled params, the first declaration of a key winning. This
// is to a style what Effect::params() is to a stage -- the only thing the
// interface and the project file know about what a style holds -- so an empty
// or unknown kind gives an empty list.
std::vector<ParamSpec> styleSpecs(const std::string& kind);
// A fresh set of values for a style of that kind: every styled param at the
// default its effect declares, the way Effect::defaults() fills a stage.
ParamValues styleDefaults(const std::string& kind);
// The style of that name in that pool, or nullptr. A stage naming a style that
// is not there simply stands on its own values.
const Style* findStyle(const StyleSet& styles, const std::string& kind, const std::string& name);
} // namespace effector
+20 -11
View File
@@ -17,31 +17,39 @@ const std::vector<ParamSpec>& TextEffect::params() const {
"What to write; a line break starts a new line."), "What to write; a line break starts a new line."),
ParamSpec::makeText("font_family", "Font", "Sans", ParamSpec::makeText("font_family", "Font", "Sans",
"Font family, as it is named on this machine: Sans, Serif, Monospace, " "Font family, as it is named on this machine: Sans, Serif, Monospace, "
"\"DejaVu Serif\", ..."), "\"DejaVu Serif\", ...")
.styled(),
ParamSpec::makeChoice("font_weight", "Weight", "normal", ParamSpec::makeChoice("font_weight", "Weight", "normal",
{"thin", "light", "normal", "medium", "semibold", "bold", "heavy"}, {"thin", "light", "normal", "medium", "semibold", "bold", "heavy"},
"How bold the glyphs are; a family without the chosen weight is " "How bold the glyphs are; a family without the chosen weight is "
"rendered in the nearest one it has."), "rendered in the nearest one it has.")
ParamSpec::makeBool("font_italic", "Italic", false, "Slants the glyphs."), .styled(),
ParamSpec::makeBool("font_italic", "Italic", false, "Slants the glyphs.").styled(),
ParamSpec::makeDouble("font_size", "Size", 96.0, 4.0, 2000.0, ParamSpec::makeDouble("font_size", "Size", 96.0, 4.0, 2000.0,
"Height of the glyphs in pixels.", 1.0, 1), "Height of the glyphs in pixels.", 1.0, 1)
ParamSpec::makeColor("text_color", "Colour", "#ffffff", "The colour of the glyphs."), .styled(),
ParamSpec::makeColor("text_color", "Colour", "#ffffff", "The colour of the glyphs.").styled(),
ParamSpec::makeChoice("text_align", "Alignment", "center", {"left", "center", "right"}, ParamSpec::makeChoice("text_align", "Alignment", "center", {"left", "center", "right"},
"How several lines line up against each other."), "How several lines line up against each other.")
.styled(),
ParamSpec::makeInt("text_wrap", "Wrap width", 0, 0, 20000, ParamSpec::makeInt("text_wrap", "Wrap width", 0, 0, 20000,
"Breaks the text into lines no wider than this many pixels; 0 writes " "Breaks the text into lines no wider than this many pixels; 0 writes "
"it as given."), "it as given."),
ParamSpec::makeChoice("text_box", "Box", "none", {"none", "colour", "image"}, ParamSpec::makeChoice("text_box", "Box", "none", {"none", "colour", "image"},
"What the text sits on: nothing, a filled rectangle, or a picture " "What the text sits on: nothing, a filled rectangle, or a picture "
"from disk cropped to the rectangle."), "from disk cropped to the rectangle.")
.styled(),
ParamSpec::makeColor("box_color", "Box colour", "#000000", ParamSpec::makeColor("box_color", "Box colour", "#000000",
"The colour the box is filled with."), "The colour the box is filled with.")
.styled(),
ParamSpec::makePath("box_image", "Box picture", ParamSpec::makePath("box_image", "Box picture",
"The picture the box is filled with, scaled to cover it."), "The picture the box is filled with, scaled to cover it."),
ParamSpec::makeInt("box_padding", "Box padding", 24, 0, 2000, ParamSpec::makeInt("box_padding", "Box padding", 24, 0, 2000,
"How far the box reaches past the text on every side."), "How far the box reaches past the text on every side.")
.styled(),
ParamSpec::makeDouble("box_opacity", "Box opacity", 1.0, 0.0, 1.0, ParamSpec::makeDouble("box_opacity", "Box opacity", 1.0, 0.0, 1.0,
"How strongly the box is mixed over the input."), "How strongly the box is mixed over the input.")
.styled(),
ParamSpec::makeBool("text_center", "Centre", true, ParamSpec::makeBool("text_center", "Centre", true,
"Places the text in the middle of the canvas rather than at its top " "Places the text in the middle of the canvas rather than at its top "
"left corner; the offset is counted from there either way."), "left corner; the offset is counted from there either way."),
@@ -50,7 +58,8 @@ const std::vector<ParamSpec>& TextEffect::params() const {
ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000, ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000,
"Moves the text down by this many pixels (up when negative)."), "Moves the text down by this many pixels (up when negative)."),
ParamSpec::makeDouble("text_opacity", "Text opacity", 1.0, 0.0, 1.0, ParamSpec::makeDouble("text_opacity", "Text opacity", 1.0, 0.0, 1.0,
"How strongly the glyphs are mixed over what is behind them."), "How strongly the glyphs are mixed over what is behind them.")
.styled(),
}; };
return specs; return specs;
} }
+1
View File
@@ -28,6 +28,7 @@ public:
"box filled with a colour or with a picture."; "box filled with a colour or with a picture.";
} }
const std::vector<ParamSpec>& params() const override; const std::vector<ParamSpec>& params() const override;
std::string styleKind() const override { return "text"; }
bool needsSources() const override { return false; } bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng, Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
+325 -11
View File
@@ -6,6 +6,7 @@
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
#include <sstream> #include <sstream>
#include <utility>
#include "core/canvas.hpp" #include "core/canvas.hpp"
#include "core/chain.hpp" #include "core/chain.hpp"
@@ -360,6 +361,7 @@ void EffectorWindow::buildParamWidgets() {
while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child); while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child);
paramWidgets_.clear(); paramWidgets_.clear();
ownedWidgets_.clear(); ownedWidgets_.clear();
styleDrop_ = nullptr;
if (stages_.empty()) { if (stages_.empty()) {
paramHeading_.set_text("No effects"); paramHeading_.set_text("No effects");
@@ -372,17 +374,49 @@ void EffectorWindow::buildParamWidgets() {
(stage.enabled ? " settings" : " settings (off)")); (stage.enabled ? " settings" : " settings (off)"));
int row = 0; int row = 0;
// The style the stage follows, when its effect takes one at all: the
// params it carries are then shown and edited on the style rather than on
// the stage, which is what makes one change move every stage using it.
const Style* style = selectedStyle();
if (!stage.effect->styleKind().empty()) {
auto label = std::make_unique<Gtk::Label>("Style");
label->set_xalign(0.0F);
label->set_tooltip_text(
"A named set of formatting shared by several stages: the font, the colour and the "
"box, but never the words or the placement.");
auto widget = buildStyleRow(stage);
widget->set_hexpand(true);
paramGrid_.attach(*label, 0, row);
paramGrid_.attach(*widget, 1, row);
ownedWidgets_.push_back(std::move(label));
ownedWidgets_.push_back(std::move(widget));
++row;
}
for (const auto& spec : stage.effect->params()) { for (const auto& spec : stage.effect->params()) {
// A styled param is read from the style while one is followed, so the
// widgets show what the stage will actually render with.
const bool shared = style != nullptr && spec.inStyle;
const ParamValues& values = shared ? style->values : stage.values;
auto label = std::make_unique<Gtk::Label>(spec.label); auto label = std::make_unique<Gtk::Label>(spec.label);
label->set_xalign(0.0F); label->set_xalign(0.0F);
if (!spec.help.empty()) label->set_tooltip_text(spec.help); std::string help = spec.help;
if (shared) {
label->set_markup(Glib::Markup::escape_text(spec.label) +
" <span alpha='55%'>(style)</span>");
if (!help.empty()) help += "\n\n";
help += "Part of the style \"" + style->name +
"\": changing it changes every stage that follows it.";
}
if (!help.empty()) label->set_tooltip_text(help);
std::unique_ptr<Gtk::Widget> widget; std::unique_ptr<Gtk::Widget> widget;
switch (spec.type) { switch (spec.type) {
case ParamSpec::Type::Bool: { case ParamSpec::Type::Bool: {
auto toggle = std::make_unique<Gtk::Switch>(); auto toggle = std::make_unique<Gtk::Switch>();
toggle->set_halign(Gtk::Align::START); toggle->set_halign(Gtk::Align::START);
toggle->set_active(paramBool(stage.values, spec.key, spec.defaultValue != 0.0)); toggle->set_active(paramBool(values, spec.key, spec.defaultValue != 0.0));
toggle->property_active().signal_changed().connect( toggle->property_active().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(toggle); widget = std::move(toggle);
@@ -395,9 +429,9 @@ void EffectorWindow::buildParamWidgets() {
spin->set_increments(spec.step, spec.step * 10); spin->set_increments(spec.step, spec.step * 10);
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits); spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
spin->set_value(spec.type == ParamSpec::Type::Int spin->set_value(spec.type == ParamSpec::Type::Int
? static_cast<double>(paramInt(stage.values, spec.key, ? static_cast<double>(paramInt(values, spec.key,
static_cast<long>(spec.defaultValue))) static_cast<long>(spec.defaultValue)))
: paramDouble(stage.values, spec.key, spec.defaultValue)); : paramDouble(values, spec.key, spec.defaultValue));
spin->signal_value_changed().connect( spin->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(spin); widget = std::move(spin);
@@ -409,7 +443,7 @@ void EffectorWindow::buildParamWidgets() {
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6); auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
auto* entry = Gtk::make_managed<Gtk::Entry>(); auto* entry = Gtk::make_managed<Gtk::Entry>();
entry->set_hexpand(true); entry->set_hexpand(true);
entry->set_text(paramText(stage.values, spec.key, "")); entry->set_text(paramText(values, spec.key, ""));
entry->set_placeholder_text("No image chosen"); entry->set_placeholder_text("No image chosen");
entry->signal_activate().connect( entry->signal_activate().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
@@ -427,7 +461,7 @@ void EffectorWindow::buildParamWidgets() {
} }
case ParamSpec::Type::Text: { case ParamSpec::Type::Text: {
auto entry = std::make_unique<Gtk::Entry>(); auto entry = std::make_unique<Gtk::Entry>();
entry->set_text(paramText(stage.values, spec.key, spec.defaultChoice)); entry->set_text(paramText(values, spec.key, spec.defaultChoice));
// Re-rendering on every keystroke would start a render per // Re-rendering on every keystroke would start a render per
// letter typed, so the text is taken when the entry is left or // letter typed, so the text is taken when the entry is left or
// Enter is pressed. // Enter is pressed.
@@ -444,7 +478,7 @@ void EffectorWindow::buildParamWidgets() {
auto button = std::make_unique<Gtk::ColorDialogButton>(Gtk::ColorDialog::create()); auto button = std::make_unique<Gtk::ColorDialogButton>(Gtk::ColorDialog::create());
button->set_halign(Gtk::Align::START); button->set_halign(Gtk::Align::START);
std::uint8_t rgb[3]; std::uint8_t rgb[3];
paramColor(stage.values, spec.key, spec.defaultChoice, rgb); paramColor(values, spec.key, spec.defaultChoice, rgb);
Gdk::RGBA colour; Gdk::RGBA colour;
colour.set_rgba(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0); colour.set_rgba(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0);
button->set_rgba(colour); button->set_rgba(colour);
@@ -457,7 +491,7 @@ void EffectorWindow::buildParamWidgets() {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end()); std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>(); auto dropdown = std::make_unique<Gtk::DropDown>();
dropdown->set_model(Gtk::StringList::create(choices)); dropdown->set_model(Gtk::StringList::create(choices));
const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice); const auto current = paramChoice(values, spec.key, spec.defaultChoice);
for (std::size_t i = 0; i < spec.choices.size(); ++i) { for (std::size_t i = 0; i < spec.choices.size(); ++i) {
if (spec.choices[i] == current) dropdown->set_selected(i); if (spec.choices[i] == current) dropdown->set_selected(i);
} }
@@ -523,7 +557,282 @@ ParamValues EffectorWindow::paramValues() const {
// whenever they change; the other stages keep the settings they were given. // whenever they change; the other stages keep the settings they were given.
void EffectorWindow::storeParams() { void EffectorWindow::storeParams() {
if (stages_.empty()) return; if (stages_.empty()) return;
stages_[static_cast<std::size_t>(selectedStage_)].values = paramValues(); auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const ParamValues values = paramValues();
stage.values = values;
// A param the style carries is written to the style too -- that is how the
// look of every stage following it is changed from here. The stage keeps
// its own copy of the same value, so leaving the style, or losing it, does
// not move the picture.
if (Style* style = selectedStyle()) {
for (const auto& spec : stage.effect->params()) {
if (!spec.inStyle) continue;
const auto found = values.find(spec.key);
if (found != values.end()) style->values[spec.key] = found->second;
}
}
}
// ---------------------------------------------------------- text styles ---
std::vector<std::string> EffectorWindow::styleNames(const std::string& kind) const {
std::vector<std::string> names;
for (const auto& style : styles_) {
if (style.kind == kind) names.push_back(style.name);
}
return names;
}
Style* EffectorWindow::selectedStyle() {
return const_cast<Style*>(std::as_const(*this).selectedStyle());
}
const Style* EffectorWindow::selectedStyle() const {
if (stages_.empty()) return nullptr;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
return findStyle(styles_, stage.effect->styleKind(), stage.style);
}
bool EffectorWindow::styleNameTaken(const std::string& kind, const std::string& name) const {
return findStyle(styles_, kind, name) != nullptr;
}
std::unique_ptr<Gtk::Widget> EffectorWindow::buildStyleRow(const ChainStage& stage) {
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
const auto names = styleNames(stage.effect->styleKind());
// "Custom" first so that the stage standing on its own is the plain case,
// and "New style…" last so that the names in between keep their indices.
std::vector<Glib::ustring> entries{"Custom"};
for (const auto& name : names) entries.emplace_back(name);
entries.emplace_back("New style…");
auto* drop = Gtk::make_managed<Gtk::DropDown>();
drop->set_model(Gtk::StringList::create(entries));
drop->set_hexpand(true);
suspendStyleSelect_ = true;
drop->set_selected(0);
for (std::size_t i = 0; i < names.size(); ++i) {
if (names[i] == stage.style) drop->set_selected(i + 1);
}
suspendStyleSelect_ = false;
drop->property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onStyleSelected));
box->append(*drop);
styleDrop_ = drop;
// Renaming and removing only make sense for a style that is being followed.
auto* rename = Gtk::make_managed<Gtk::Button>("Rename…");
rename->set_sensitive(!stage.style.empty());
rename->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRenameStyle));
box->append(*rename);
auto* remove = Gtk::make_managed<Gtk::Button>("Remove");
remove->set_sensitive(!stage.style.empty());
remove->set_tooltip_text(
"Drops the style. Every stage following it keeps the look it has and goes back to its own "
"settings.");
remove->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRemoveStyle));
box->append(*remove);
return box;
}
void EffectorWindow::onStyleSelected() {
if (suspendStyleSelect_ || styleDrop_ == nullptr || stages_.empty()) return;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const auto names = styleNames(stage.effect->styleKind());
const auto index = styleDrop_->get_selected();
if (index == 0) {
adoptStyle("");
} else if (index <= names.size()) {
adoptStyle(names[index - 1]);
} else {
onNewStyle(); // the last entry
}
}
// Pointing the stage at a style, or at nothing. The style's values are copied
// onto the stage as well, so the project file always says what the stage would
// render if the style went away -- which is the same picture.
void EffectorWindow::adoptStyle(const std::string& name) {
if (stages_.empty()) return;
auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
stage.style = name;
if (const Style* style = selectedStyle()) {
for (const auto& spec : stage.effect->params()) {
if (!spec.inStyle) continue;
const auto found = style->values.find(spec.key);
if (found != style->values.end()) stage.values[spec.key] = found->second;
}
}
buildParamWidgets();
requestRender();
}
void EffectorWindow::onNewStyle() {
if (stages_.empty()) return;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const std::string kind = stage.effect->styleKind();
if (kind.empty()) return;
askStyleName("New style", "Style " + std::to_string(styleNames(kind).size() + 1),
[this, kind](const std::string& name) {
// Seeded from the stage it was made on, so the style
// starts as the look that is already on screen.
Style style;
style.kind = kind;
style.name = name;
style.values = styleDefaults(kind);
const auto& current = stages_[static_cast<std::size_t>(selectedStage_)];
for (const auto& spec : current.effect->params()) {
if (!spec.inStyle) continue;
const auto found = current.values.find(spec.key);
if (found != current.values.end()) style.values[spec.key] = found->second;
}
styles_.push_back(std::move(style));
adoptStyle(name);
});
}
void EffectorWindow::onRenameStyle() {
const Style* style = selectedStyle();
if (style == nullptr) return;
const std::string kind = style->kind;
const std::string was = style->name;
askStyleName("Rename style", was, [this, kind, was](const std::string& name) {
if (name == was) return;
for (auto& candidate : styles_) {
if (candidate.kind == kind && candidate.name == was) candidate.name = name;
}
// Every stage that followed the old name follows the new one: a rename
// must not quietly cut a stage loose from its look.
for (auto& stage : stages_) {
if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name;
}
buildParamWidgets();
});
}
void EffectorWindow::onRemoveStyle() {
const Style* style = selectedStyle();
if (style == nullptr) return;
const std::string kind = style->kind;
const std::string name = style->name;
// The stages keep the values they were rendering with, so dropping a style
// changes nothing in the picture -- it only stops the stages moving
// together.
for (auto& stage : stages_) {
if (stage.effect->styleKind() == kind && stage.style == name) stage.style.clear();
}
styles_.erase(std::remove_if(styles_.begin(), styles_.end(),
[&](const Style& candidate) {
return candidate.kind == kind && candidate.name == name;
}),
styles_.end());
buildParamWidgets();
requestRender();
}
// Built from scratch each time it is needed, like the progress window, so the
// entry it holds is a raw pointer that closeStyleWindow() nulls.
void EffectorWindow::askStyleName(const std::string& title, const std::string& initial,
const std::function<void(const std::string&)>& accept) {
closeStyleWindow();
if (stages_.empty()) return;
const std::string kind = stages_[static_cast<std::size_t>(selectedStage_)].effect->styleKind();
styleWindow_ = std::make_shared<Gtk::Window>();
styleWindow_->set_title(title);
styleWindow_->set_transient_for(*this);
styleWindow_->set_modal(true);
styleWindow_->set_resizable(false);
styleWindow_->set_default_size(360, -1);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
box->set_margin(18);
auto* entry = Gtk::make_managed<Gtk::Entry>();
entry->set_text(initial);
entry->set_activates_default(false);
box->append(*entry);
styleNameEntry_ = entry;
auto* error = Gtk::make_managed<Gtk::Label>("");
error->set_xalign(0.0F);
error->add_css_class("dim-label");
error->set_visible(false);
box->append(*error);
styleNameError_ = error;
auto* buttons = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
buttons->set_halign(Gtk::Align::END);
auto* cancel = Gtk::make_managed<Gtk::Button>("Cancel");
cancel->signal_clicked().connect([this] {
closeStyleWindow();
// The dropdown may be sitting on "New style…"; put it back on what the
// stage actually follows.
buildParamWidgets();
});
buttons->append(*cancel);
auto* confirm = Gtk::make_managed<Gtk::Button>("OK");
confirm->add_css_class("suggested-action");
// A name that is empty or already in the pool is refused in place rather
// than silently changed, so two styles can never share a name.
const auto submit = [this, kind, accept] {
if (styleNameEntry_ == nullptr) return;
const std::string name = styleNameEntry_->get_text();
const char* complaint = nullptr;
if (name.empty()) {
complaint = "A style needs a name.";
} else if (styleNameTaken(kind, name)) {
complaint = "There is already a style with that name.";
}
if (complaint != nullptr) {
if (styleNameError_ != nullptr) {
styleNameError_->set_text(complaint);
styleNameError_->set_visible(true);
}
return;
}
closeStyleWindow();
accept(name);
};
confirm->signal_clicked().connect(submit);
entry->signal_activate().connect(submit);
buttons->append(*confirm);
box->append(*buttons);
styleWindow_->signal_close_request().connect(
[this] {
closeStyleWindow();
buildParamWidgets();
return true;
},
false);
styleWindow_->set_child(*box);
styleWindow_->present();
entry->grab_focus();
}
void EffectorWindow::closeStyleWindow() {
styleNameEntry_ = nullptr;
styleNameError_ = nullptr;
if (!styleWindow_) return;
styleWindow_->set_hide_on_close(false);
styleWindow_->hide();
// Closing is asked for by the window's own buttons and by its close
// request, so it is torn down once that signal has finished running rather
// than underneath it.
Glib::signal_idle().connect_once([window = styleWindow_] {});
styleWindow_.reset();
} }
void EffectorWindow::applyInitial(const Options& initial) { void EffectorWindow::applyInitial(const Options& initial) {
@@ -1120,6 +1429,7 @@ void EffectorWindow::startRender() {
const auto sources = sources_; const auto sources = sources_;
const auto stages = stages_; // a snapshot, like the sources const auto stages = stages_; // a snapshot, like the sources
const auto styles = styles_; // the same, so the worker reads nothing live
const int width = static_cast<int>(widthSpin_.get_value()); const int width = static_cast<int>(widthSpin_.get_value());
const int height = static_cast<int>(heightSpin_.get_value()); const int height = static_cast<int>(heightSpin_.get_value());
const std::uint64_t seed = seed_; const std::uint64_t seed = seed_;
@@ -1127,13 +1437,13 @@ void EffectorWindow::startRender() {
spinner_.start(); spinner_.start();
recreateButton_.set_sensitive(false); recreateButton_.set_sensitive(false);
worker_ = std::thread([this, sources, stages, width, height, seed] { worker_ = std::thread([this, sources, stages, styles, width, height, seed] {
Image image; Image image;
std::string error; std::string error;
const auto start = std::chrono::steady_clock::now(); const auto start = std::chrono::steady_clock::now();
try { try {
Rng rng(seed); Rng rng(seed);
image = renderChain(stages, *sources, width, height, rng); image = renderChain(stages, *sources, width, height, rng, styles);
} catch (const std::exception& exception) { } catch (const std::exception& exception) {
error = exception.what(); error = exception.what();
} }
@@ -1241,6 +1551,7 @@ Project EffectorWindow::currentProject() const {
Project project; Project project;
for (const auto& image : *sources_) project.sources.push_back(image.path()); for (const auto& image : *sources_) project.sources.push_back(image.path());
project.stages = stages_; project.stages = stages_;
project.styles = styles_;
project.width = static_cast<int>(widthSpin_.get_value()); project.width = static_cast<int>(widthSpin_.get_value());
project.height = static_cast<int>(heightSpin_.get_value()); project.height = static_cast<int>(heightSpin_.get_value());
project.format = canvasFormatKey(); project.format = canvasFormatKey();
@@ -1252,9 +1563,12 @@ Project EffectorWindow::currentProject() const {
void EffectorWindow::applyProject(const Project& project, const std::string& path) { void EffectorWindow::applyProject(const Project& project, const std::string& path) {
// One render at the end rather than one per piece of the project. // One render at the end rather than one per piece of the project.
suspendRender_ = true; suspendRender_ = true;
closeStyleWindow(); // it names a style of the project being replaced
clearSources(); clearSources();
stages_ = project.stages; stages_ = project.stages;
// Before the rows and the params: both ask which style a stage follows.
styles_ = project.styles;
selectedStage_ = 0; selectedStage_ = 0;
rebuildChainRows(); rebuildChainRows();
buildParamWidgets(); buildParamWidgets();
+33 -1
View File
@@ -7,6 +7,7 @@
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <optional> #include <optional>
#include <functional>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector> #include <vector>
@@ -16,6 +17,7 @@
#include "core/effect.hpp" #include "core/effect.hpp"
#include "core/image.hpp" #include "core/image.hpp"
#include "core/project.hpp" #include "core/project.hpp"
#include "core/style.hpp"
namespace effector { namespace effector {
@@ -30,7 +32,29 @@ private:
Gtk::Widget& buildPreview(); Gtk::Widget& buildPreview();
void buildParamWidgets(); void buildParamWidgets();
ParamValues paramValues() const; ParamValues paramValues() const;
void storeParams(); // widgets -> the selected stage's values void storeParams(); // widgets -> the selected stage, and its style
// The style row above the params of a stage whose effect takes one.
std::unique_ptr<Gtk::Widget> buildStyleRow(const ChainStage& stage);
// The names of the pool the selected stage draws from, in the order the
// dropdown shows them.
std::vector<std::string> styleNames(const std::string& kind) const;
// The style the selected stage follows, or nullptr when it stands alone.
Style* selectedStyle();
const Style* selectedStyle() const;
void onStyleSelected(); // the dropdown: adopt, drop, or make a new one
void adoptStyle(const std::string& name);
void onNewStyle();
void onRenameStyle();
void onRemoveStyle();
// The little modal that asks for a style name, used by both New and
// Rename. Built from scratch each time, like the progress window.
void askStyleName(const std::string& title, const std::string& initial,
const std::function<void(const std::string&)>& accept);
void closeStyleWindow();
// A name that is empty, or already used in the pool, is refused in place.
bool styleNameTaken(const std::string& kind, const std::string& name) const;
void applyInitial(const Options& initial); void applyInitial(const Options& initial);
bool canRender() const; // a chain, and sources if the chain needs them bool canRender() const; // a chain, and sources if the chain needs them
@@ -114,6 +138,9 @@ private:
// The cascade: stage 0 works on the sources, every later stage on the // The cascade: stage 0 works on the sources, every later stage on the
// output of the one before it. // output of the one before it.
std::vector<ChainStage> stages_; std::vector<ChainStage> stages_;
// The named looks the stages share. Edited through the params of whichever
// stage follows one, and saved with the project.
StyleSet styles_;
int selectedStage_ = 0; int selectedStage_ = 0;
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
Image result_; Image result_;
@@ -166,6 +193,11 @@ private:
// Set while a format fills the spins, so their own handler does not queue // Set while a format fills the spins, so their own handler does not queue
// a second render on top of the one the format change asks for. // a second render on top of the one the format change asks for.
bool suspendCanvasSync_ = false; bool suspendCanvasSync_ = false;
Gtk::DropDown* styleDrop_ = nullptr; // null while the stage takes no style
bool suspendStyleSelect_ = false; // guards filling the dropdown
std::shared_ptr<Gtk::Window> styleWindow_;
Gtk::Entry* styleNameEntry_ = nullptr; // nulled with the window
Gtk::Label* styleNameError_ = nullptr;
Gtk::Grid paramGrid_; Gtk::Grid paramGrid_;
Gtk::Label paramHeading_; Gtk::Label paramHeading_;
Gtk::Entry seedEntry_; Gtk::Entry seedEntry_;
+170
View File
@@ -1,5 +1,6 @@
// Small self-contained checks for the pieces that have no GUI dependency. // Small self-contained checks for the pieces that have no GUI dependency.
#include <algorithm>
#include <array> #include <array>
#include <atomic> #include <atomic>
#include <clocale> #include <clocale>
@@ -21,6 +22,7 @@
#include "core/project.hpp" #include "core/project.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
#include "core/rng.hpp" #include "core/rng.hpp"
#include "core/style.hpp"
#include "effects/blur.hpp" #include "effects/blur.hpp"
#include "effects/grain.hpp" #include "effects/grain.hpp"
#include "effects/monochrome.hpp" #include "effects/monochrome.hpp"
@@ -828,6 +830,109 @@ int main() {
std::filesystem::remove_all(dir); std::filesystem::remove_all(dir);
} }
std::cout << "text styles\n";
{
const std::vector<Image> blue = {solid(200, 120, 0, 0, 255)};
const auto countRed = [](const Image& image) {
int found = 0;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
if (isRed(pixel(image, x, y))) ++found;
}
}
return found;
};
const auto specs = styleSpecs("text");
const auto carries = [&specs](const std::string& key) {
return std::any_of(specs.begin(), specs.end(),
[&key](const ParamSpec& spec) { return spec.key == key; });
};
check(specs.size() == 11, "a text style carries eleven params");
check(carries("font_family") && carries("font_weight") && carries("font_italic") &&
carries("font_size") && carries("text_color") && carries("text_align") &&
carries("text_opacity") && carries("text_box") && carries("box_color") &&
carries("box_padding") && carries("box_opacity"),
"the look is what it carries");
check(!carries("text") && !carries("text_wrap") && !carries("box_image") &&
!carries("text_center") && !carries("text_offset_x") && !carries("text_offset_y"),
"the words and the placement stay on the stage");
check(styleSpecs("").empty() && styleSpecs("nosuchkind").empty(),
"a kind no effect claims carries nothing");
check(paramDouble(styleDefaults("text"), "font_size", 0.0) == 96.0 &&
styleDefaults("text").size() == specs.size(),
"a fresh style starts at the defaults the effect declares");
StyleSet styles = {{"text", "Title", styleDefaults("text")}};
styles[0].values["font_size"] = 40.0;
styles[0].values["text_color"] = std::string("#ff0000");
check(findStyle(styles, "text", "Title") != nullptr &&
findStyle(styles, "text", "Caption") == nullptr &&
findStyle(styles, "other", "Title") == nullptr,
"a style is found by its kind and its name");
const ChainStage own{effectByName("text"),
{{"text", std::string("Hi")},
{"font_size", 12.0},
{"text_color", std::string("#ff0000")}},
true,
""};
ChainStage follows = own;
follows.style = "Title";
check(paramDouble(stageValues(own, styles), "font_size", 0.0) == 12.0,
"a stage on its own keeps the settings it was given");
check(paramDouble(stageValues(follows, styles), "font_size", 0.0) == 40.0,
"a stage following a style takes the style's");
check(paramText(stageValues(follows, styles), "text", "") == "Hi",
"and keeps its own words either way");
Rng ownRng(3);
Rng followRng(3);
const auto small = renderChain({own}, blue, 200, 120, ownRng, styles);
const auto large = renderChain({follows}, blue, 200, 120, followRng, styles);
check(countRed(small) > 0 && countRed(large) > countRed(small),
"the style is what actually reaches the render");
// The whole point: two stages, one style, one look in two places.
ChainStage left = follows;
left.values["text_offset_x"] = -40L;
ChainStage right = follows;
right.values["text_offset_x"] = 40L;
Rng leftRng(3);
Rng rightRng(3);
const auto leftImage = renderChain({left}, blue, 200, 120, leftRng, styles);
const auto rightImage = renderChain({right}, blue, 200, 120, rightRng, styles);
check(countRed(leftImage) == countRed(large) && countRed(rightImage) == countRed(large),
"two stages following one style ink the same glyphs");
check(!sameBytes(leftImage, rightImage), "each in its own place");
StyleSet edited = styles;
edited[0].values["font_size"] = 20.0;
Rng editedLeft(3);
Rng editedRight(3);
const auto afterLeft = renderChain({left}, blue, 200, 120, editedLeft, edited);
const auto afterRight = renderChain({right}, blue, 200, 120, editedRight, edited);
check(countRed(afterLeft) < countRed(leftImage) &&
countRed(afterRight) < countRed(rightImage) &&
countRed(afterLeft) == countRed(afterRight),
"editing the style moves every stage that follows it");
ChainStage missing = follows;
missing.style = "Nowhere";
Rng missingRng(3);
Rng bareRng(3);
check(sameBytes(renderChain({missing}, blue, 200, 120, missingRng, styles),
renderChain({own}, blue, 200, 120, bareRng)),
"naming a style that is not there renders as standing alone");
Rng noStyleRng(3);
Rng noPoolRng(3);
check(sameBytes(renderChain({follows}, blue, 200, 120, noStyleRng, {}),
renderChain({own}, blue, 200, 120, noPoolRng)),
"and so does a chain handed no styles at all");
}
std::cout << "chain\n"; std::cout << "chain\n";
{ {
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
@@ -1008,6 +1113,71 @@ int main() {
projectToJson(project).find("\"enabled\"") == std::string::npos, projectToJson(project).find("\"enabled\"") == std::string::npos,
"only a switched-off stage is written out as one"); "only a switched-off stage is written out as one");
// Styles ride along with the chain, and the stages come back pointing
// at the ones they followed.
Project stylish;
stylish.styles = {{"text", "Title", styleDefaults("text")},
{"text", "Caption", styleDefaults("text")}};
stylish.styles[0].values["font_size"] = 120.0;
stylish.styles[0].values["text_color"] = std::string("#ff8800");
stylish.styles[1].values["font_family"] = std::string("Serif");
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("One")}}, true, "Title"});
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("Two")}}, true, "Title"});
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("Three")}}, true, ""});
const auto styleFile = dir / "styles.json";
saveProject(styleFile.string(), stylish);
const auto styleBack = loadProject(styleFile.string());
check(styleBack.styles.size() == 2 && styleBack.styles[0].name == "Title" &&
styleBack.styles[1].name == "Caption" && styleBack.styles[0].kind == "text",
"the styles survive a round trip");
check(paramDouble(styleBack.styles[0].values, "font_size", 0.0) == 120.0 &&
paramText(styleBack.styles[0].values, "text_color", "") == "#ff8800" &&
paramText(styleBack.styles[1].values, "font_family", "") == "Serif",
"and so do the values they carry");
check(styleBack.stages[0].style == "Title" && styleBack.stages[1].style == "Title" &&
styleBack.stages[2].style.empty(),
"and which stage follows which");
check(paramDouble(stageValues(styleBack.stages[0], styleBack.styles), "font_size", 0.0) ==
120.0 &&
paramDouble(stageValues(styleBack.stages[2], styleBack.styles), "font_size",
0.0) == 96.0,
"a reloaded project resolves its styles as it did");
check(projectToJson(stylish).find("\"styles\"") != std::string::npos &&
projectToJson(project).find("\"styles\"") == std::string::npos &&
projectToJson(project).find("\"style\"") == std::string::npos,
"a project with no styles writes nothing about them");
// Everything that can be wrong with a style is dropped on its own,
// leaving a project that still opens and still renders.
const auto lenient = projectFromJson(R"({
"styles": [
{"kind": "nosuchkind", "name": "Ghost", "params": {}},
{"kind": "text", "name": "", "params": {}},
{"kind": "text", "name": "Title", "params": {"font_size": 60, "nonsense": 3}},
{"kind": "text", "name": "Title", "params": {"font_size": 12}}
],
"chain": [
{"effect": "text", "style": "Title", "params": {"text": "Kept"}},
{"effect": "text", "style": "Gone", "params": {"font_size": 30}},
{"effect": "blur", "style": "Title", "params": {}}
]
})");
check(lenient.styles.size() == 1 && lenient.styles[0].name == "Title",
"an unknown kind, a nameless style and a repeated name are dropped");
check(paramDouble(lenient.styles[0].values, "font_size", 0.0) == 60.0 &&
lenient.styles[0].values.find("nonsense") == lenient.styles[0].values.end(),
"a style only keeps the params its kind declares, and the first of a name wins");
check(lenient.stages[0].style == "Title" && lenient.stages[1].style.empty(),
"a stage naming a style that is not there is cut loose");
check(lenient.stages[2].style.empty(),
"and so is one whose effect takes no style at all");
check(paramDouble(stageValues(lenient.stages[1], lenient.styles), "font_size", 0.0) == 30.0,
"which leaves it rendering the values written beside it");
int refusals = 0; int refusals = 0;
for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}", for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}",
"{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) { "{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) {