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
+325 -11
View File
@@ -6,6 +6,7 @@
#include <cstdint>
#include <filesystem>
#include <sstream>
#include <utility>
#include "core/canvas.hpp"
#include "core/chain.hpp"
@@ -360,6 +361,7 @@ void EffectorWindow::buildParamWidgets() {
while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child);
paramWidgets_.clear();
ownedWidgets_.clear();
styleDrop_ = nullptr;
if (stages_.empty()) {
paramHeading_.set_text("No effects");
@@ -372,17 +374,49 @@ void EffectorWindow::buildParamWidgets() {
(stage.enabled ? " settings" : " settings (off)"));
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()) {
// 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);
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;
switch (spec.type) {
case ParamSpec::Type::Bool: {
auto toggle = std::make_unique<Gtk::Switch>();
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(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(toggle);
@@ -395,9 +429,9 @@ void EffectorWindow::buildParamWidgets() {
spin->set_increments(spec.step, spec.step * 10);
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
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)))
: paramDouble(stage.values, spec.key, spec.defaultValue));
: paramDouble(values, spec.key, spec.defaultValue));
spin->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(spin);
@@ -409,7 +443,7 @@ void EffectorWindow::buildParamWidgets() {
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
auto* entry = Gtk::make_managed<Gtk::Entry>();
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->signal_activate().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
@@ -427,7 +461,7 @@ void EffectorWindow::buildParamWidgets() {
}
case ParamSpec::Type::Text: {
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
// letter typed, so the text is taken when the entry is left or
// Enter is pressed.
@@ -444,7 +478,7 @@ void EffectorWindow::buildParamWidgets() {
auto button = std::make_unique<Gtk::ColorDialogButton>(Gtk::ColorDialog::create());
button->set_halign(Gtk::Align::START);
std::uint8_t rgb[3];
paramColor(stage.values, spec.key, spec.defaultChoice, rgb);
paramColor(values, spec.key, spec.defaultChoice, rgb);
Gdk::RGBA colour;
colour.set_rgba(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0);
button->set_rgba(colour);
@@ -457,7 +491,7 @@ void EffectorWindow::buildParamWidgets() {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>();
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) {
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.
void EffectorWindow::storeParams() {
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) {
@@ -1120,6 +1429,7 @@ void EffectorWindow::startRender() {
const auto sources = 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 height = static_cast<int>(heightSpin_.get_value());
const std::uint64_t seed = seed_;
@@ -1127,13 +1437,13 @@ void EffectorWindow::startRender() {
spinner_.start();
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;
std::string error;
const auto start = std::chrono::steady_clock::now();
try {
Rng rng(seed);
image = renderChain(stages, *sources, width, height, rng);
image = renderChain(stages, *sources, width, height, rng, styles);
} catch (const std::exception& exception) {
error = exception.what();
}
@@ -1241,6 +1551,7 @@ Project EffectorWindow::currentProject() const {
Project project;
for (const auto& image : *sources_) project.sources.push_back(image.path());
project.stages = stages_;
project.styles = styles_;
project.width = static_cast<int>(widthSpin_.get_value());
project.height = static_cast<int>(heightSpin_.get_value());
project.format = canvasFormatKey();
@@ -1252,9 +1563,12 @@ Project EffectorWindow::currentProject() const {
void EffectorWindow::applyProject(const Project& project, const std::string& path) {
// One render at the end rather than one per piece of the project.
suspendRender_ = true;
closeStyleWindow(); // it names a style of the project being replaced
clearSources();
stages_ = project.stages;
// Before the rows and the params: both ask which style a stage follows.
styles_ = project.styles;
selectedStage_ = 0;
rebuildChainRows();
buildParamWidgets();
+33 -1
View File
@@ -7,6 +7,7 @@
#include <memory>
#include <mutex>
#include <optional>
#include <functional>
#include <string>
#include <thread>
#include <vector>
@@ -16,6 +17,7 @@
#include "core/effect.hpp"
#include "core/image.hpp"
#include "core/project.hpp"
#include "core/style.hpp"
namespace effector {
@@ -30,7 +32,29 @@ private:
Gtk::Widget& buildPreview();
void buildParamWidgets();
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);
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
// output of the one before it.
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;
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
Image result_;
@@ -166,6 +193,11 @@ private:
// 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.
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::Label paramHeading_;
Gtk::Entry seedEntry_;