Skip to content

Development Strip Editor Properties

Bartłomiej Mucha edited this page Oct 10, 2026 · 10 revisions

Property groups

Everything editable about an object, cut into named slices that one editor each is responsible for — and the contract that stops two editors overwriting each other.

Status — built. All five groups and their editors exist, one tail is a group of its own (TailProperties), and tests/gui-unit-tests/ checks every one. The editors are shown by the object-state and tool-option panels. See the framework overview.

What a group is

A property group is a named struct of related properties, with exactly one editor widget responsible for it. It has no identity: an object either has the group or does not.

The test that separates a group from a nested object is can there be several of it, and can one be deleted? A tail passes — several, deletable. A fill colour does not. A bubble's text does not either: there is exactly one and it can be emptied but not removed.

The structs

struct ShapeProperties { enum class Kind { None, Speech, … }; Kind kind; };
struct SkinProperties  { QColor fill, stroke; int strokeWidth; };
struct StyleProperties { enum class Kind { Clean, Marker, Ink }; Kind kind; qreal amount; };
struct TextProperties  { QString body, family; int pixelSize; bool bold; int align; QColor colour; };
struct TailsProperties { QList<Tail> items; };
struct TailProperties  { int index; Tail tail; };   // one tail, the group of a selected tail object

ObjectRecord composes them, and keeps Shape and Style as aliases for the enums that moved into their groups — so every existing ObjectRecord::Shape::Speech still compiles and every persisted name is untouched.

Two properties belong to no group, which is what stops a preset copying them: box, which belongs to the object rather than to its look, and styleSeed, minted the first time a balloon is styled (topUpStyleSeed()) and kept from then on. A seed inside StyleProperties would be written by every applyTo(), and a chapter of marker balloons would wear one repeated wobble; a balloon with no style has no wobble to seed.

Groups arrived with their editors, not as a rename landing ahead of the code that would use them: a struct with no owner is a rename; a struct with an owner is the contract.

An object is its groups plus its children. TextProperties is shared by a balloon, standalone lettering and a lettered picture rather than each kind carrying its own copy of the same six fields — parameterising rather than duplicating with specialisation.

Assembling an editor from an object's groups is the framework's job. No kind writes out its own panel.

What it cost

  • Nothing in the file format. The SVG and the JSON snapshot read and write the same attribute names as before the groups existed; recordsvg.cpp reaches them through the groups.
  • It was the enabling change: multi-selection, presets, conversion and the ownership tests all reduce to copy this group, which is only expressible once a group is a thing.

Ownership

One group owns each property. Nothing else may write it.

Group Owns Present on
Placement position, anchor page — on the overlay, no editor every placed object
Size width fraction — on the overlay, no editor every placed object
Compositing mute, blend — on the overlay, no editor every placed object
Shape shape bubbles
Skin fill, stroke, strokeWidth objects with a silhouette
Style style, amount — not the seed objects with a silhouette
Text text, family, size, bold, align, colour every kind: balloons, standalone lettering, pictures
Tail the list — how many, and each tail's values when one is added bubbles
TailItem tip, baseWidth, bend of one tail, by index each tail object
Colour correction curves, brightness/contrast, saturation the strip
Page exclusion one flag each page object

Three of these rows are decisions rather than bookkeeping:

  • Placement, Size and Compositing belong to the object, not to its look. They live on the overlay (StripOverlay), never in the record, so no editor writes them and no preset carries them. The enum keeps their names (propertygroup.hpp) only because a section's number is persisted. Blend and an imported picture's scale are rows in OBJECT STATE that act on the overlay.
  • Text colour belongs to Text, not Skin. Editing a balloon's frame does not edit its lettering, so the colour tool's two swatches are fill and stroke.
  • The style seed belongs to no editor. It is set once when an object is placed and seeds the filter's noise; applying a preset must preserve it, or every restyled balloon in a chapter wears identical wobble.

The text outline — designed, not built

TextProperties has no outline yet; this is the design for when it lands (docs/TODO.md). Colour and width, in strip-scale pixels like fontPixelSize, so it means the same thing in the preview and in the output. It is occasional in a balloon and necessary for standalone text — a dark character whose balloon and lettering are both black, with only the letters' outline in violet.

  • Drawn outside the glyph, not centred on it: stroke the cached glyph path at twice the width, then fill the glyph on top, so the letterform keeps the weight its typeface was drawn with. That is the comic-lettering convention, and it is one extra stroke over a path the painter already caches.
  • In the SVG, encode it as two draws — stroked, then filled — rather than relying on paint-order. The render goes through librsvg; correctness there outranks elegance.
  • It joins presets with no code. Presets persist by dropping the content keys from the record's JSON, precisely so a new styling field is not listed twice.

The write contract

class PropertyGroupEditor : public QWidget {
public:
    using Subjects = QList<const ObjectRecord*>;

    virtual PropertyGroup group() const = 0;

    //! Show these values. Called by the framework; never emits.
    virtual void bind(const Subjects& subjects) = 0;

    //! Write ONLY this group's properties into `target`. The single write path.
    virtual void applyTo(ObjectRecord& target) const = 0;

    //! Only what the artist touched since bind() — the multi-selection write path.
    virtual void applyEditedTo(ObjectRecord& target) const;

    //! The single-subject case.
    void bindOne(const ObjectRecord& subject);

signals:
    void changed();    //!< continuous — live preview, no history step
    void committed();  //!< settled — one history step
};

The write path, in outline:

// OBJECT STATE, on any control change (ObjectState::onControlChanged)
for (ObjectRecord& r : boundRecords)              // the records it was bound to, re-read
    for (editor : the groups r carries)
        editor->applyEditedTo(r);                 // only this group, only what was touched
emit changed(records) or committed(records);      // live preview, or one history step

// ObjectController::applyPanelRecords: onto the objects it was bound to (m_panelSubjects);
// a commit leaves as overlaysCommitted → MainWindow → one undo step

Two guarantees, structural rather than by discipline:

  1. An editor cannot write a property it does not own — applyTo is its only write path.
  2. No editor can clobber with a stale copy — the base is re-read immediately before the patch, every time.

A set is written property by property. applyTo() writes a whole group, which is right for one object and wrong for several — it would stamp the first object's other values onto the rest. So applyEditedTo() writes only what the artist has touched since bind(), and the panel binds each group to the objects that carry it: the skin group to the shaped ones, the lettering's colour to all of them. That is what a role is, built rather than described.

Reading the target is allowed; writing outside the group is not. TailListEditor::applyTo() reads the shape, because a shapeless record has nothing to grow a tail from, and still writes only the tails. TailEditor::applyTo() reads its tail's tip, so a spin box cannot undo a drag made since, and writes only that tail. One level down, TailProperties is one tail by index: its applyTo() writes that tail and no other, and an index the balloon no longer has writes nothing, so a deleted tail cannot come back.

One ordering rule follows from that, and it is the only one: apply Shape before Tails. Nothing else in the five can collide, because no property has two owners.

The second guarantee is the one that matters in practice: an editor holding a copy of the whole record would write back stale values for properties it never touched — a canvas resize during an edit being the obvious way in.

bind() takes a list, not a pointer, from the first line of code. Retrofitting many-subject support into nine editors later is the expensive version. It is a QList rather than a std::span because the GUI builds as C++17 — qt_standard_project_setup()'s default — and nothing here is worth a standard bump for.

Roles

A property carries a role — its identity across kinds — and a multi-selection matches by role, not by group. Text colour means the same thing in a balloon and in a standalone text, so selecting one of each and recolouring is a single action.

Role Balloon Standalone lettering Picture Tail
position placement placement placement tip
fill colour skin.fill — — —
outline colour skin.stroke — — —
text colour text.colour text.colour text.colour —
typeface text.family text.family text.family —

Two consequences:

  • Position is the one role every object has. A tail of one balloon and the body of another can still be moved together, because a move is the same offset applied to whatever each object calls its position.
  • A panel with no sections is a legitimate state. Objects and tails selected together have only position in common, and position is edited by dragging, so OBJECT STATE says so and shows nothing.

Multi-selection

OBJECT STATE shows the union: a section is there when at least one selected object carries its group (carriesGroup()), bound to the objects that carry it and written back only to those — the skin to the ones with a silhouette, the lettering to all. A value that differs across them reads Mixed; editing it writes to every object that carries it.

The object menu offers the intersection instead, because an entry that acts on part of a selection is an entry that lied about its subject. The panel used to show the intersection too, which hid the lettering from a balloon and a lettered picture selected together — the one thing they share.

Editing still writes to every selected object at once, which is the half of Figma's behaviour worth having: a letterer selecting five balloons to recolour them expects one action, not Blender's edit the active object, then copy to selected.

Reconciling on a selection change

When the selection changes, open the new editors, then close the old:

newGroups = groupsOf(selection)
for g in newGroups:  open(g) ? editor(g).bind(selection) : create(g).bind(selection)
for g in openGroups - newGroups:  close(g)

Update-in-place is the common case — balloon to balloon — and it is what preserves scroll position, expanded sections and keyboard focus across a selection change. Destroying last means the panel never flickers through an empty state.

This is the same reconcile-by-key the editor already does for scene items by uid; only the keys differ.

Presets

A preset is a set of group values: {Shape, Skin, Style, Text minus its content} — everything a balloon is, minus everything it says. It populates those groups and nothing is locked afterwards; see Object state and tool options for the badge that reports whether the values are still the preset's.

Tests

The ownership table above is the first thing in this GUI that the build can enforce, and the mechanism is ordinary GoogleTest, the framework the library's tests use. tests/gui-unit-tests/ links Qt6::Gui and nothing else, so anything it can reach is free of QWidget by construction.

No Qt Creator, no GUI, no event loop: applyTo is a pure function from properties to properties, so a test builds one struct, applies one group, and asserts that every property outside that group is byte-identical.

For each group G:
    a = a randomised property set
    b = a
    editorFor(G).applyTo(b)
    ASSERT every property not owned by G is unchanged

It catches exactly the class of bug that has shipped three times in this editor already: code writing a field that was not its own. It needs one small CMake target and the discipline of keeping L1 free of QWidget — and it was written with the first group, when it was one test rather than nine. There are 51 now, in five files: the property groups (one per group, the two colour groups' boundary, the seed, the file format, conversion and import, which groups a record carries), presets, colour adjustments, and the workspace folder and its lock.

The target is tests/gui-unit-tests/, off by default so an ordinary Qt Creator build is unaffected:

cmake -S . -B build/tests -DPLATEMAKER_GUI_BUILD_TESTS=ON
cmake --build build/tests --target platemaker-gui-tests
ctest --test-dir build/tests

See also: Strip editor · Object state and tool options · History and undo

Clone this wiki locally