Development Strip Editor Objects - ShadobaDev/Platemaker-qt GitHub Wiki

Strip editor β€” objects

Bubbles and their tails, what every placed object shares, placement and creation, presets, and imported artwork.

Bubbles and tails

  • One object, two tools. A bubble is an ObjectRecord (widgets/objectrecord/): shape, box, tails, text, font, colours, line style. The Text tool is the same object with shape == None. Nine silhouettes (shapeOrder()), plus None; each brings the rectangle its text may occupy (textSafeArea()), which is the half that takes the thought β€” a path is a few lines, knowing where words fit inside it is what stops them crossing a stroke. That rectangle is inset by the stroke and by TextProperties::padding (default 8 px), which may be negative so the lettering can burst out of an impact balloon. It is the lettering's property β€” how the words sit β€” so it is edited in TEXT, but only a balloon uses it: a text object (None) gets its whole box, carries the value unused, and is not offered the row.
  • The lettering is never wrapped. layOutText() lays it out with QTextOption::NoWrap: the lines are the author's line breaks, and the box width only places each line by its alignment. The layout therefore does not depend on the box. TextProperties::lineSpacing (percent, default 100) sets a proportional line height; at 100 the block format is left alone, because a proportional 100 is not the same arithmetic as Qt's default single height.
  • Fitting is geometry. Because the layout does not depend on the box, Painter::textBlockSize() measures the lettering once, and everything else is a comparison against Painter::textArea(): fits() (half a pixel of slack, so whole-pixel rounding is not an overflow), textOverflows(), and fitBoxFor() β€” the smallest box on both axes, a binary search per axis repeated until both settle (a thought balloon's lobes follow the shorter side, so the axes are not independent), floored at the grips' 40 px. Fit to text is fittedBox(), the two together. fits() and fitBoxFor() touch no font, so they are unit-tested for every shape without a QGuiApplication. Fix padding is the other way round β€” the box stays, the padding gives: paddingToFit() binary-searches the largest padding not above the current one at which fits() holds, so it only ever lowers it, and asks fits() rather than solving the shape formulas so it cannot disagree with the red outline.
  • Overflow is shown, not prevented. A selected object whose lettering does not fit has its text area outlined in red by the selection chrome, measured when the object reports a change rather than on every repaint. The lettering is still clipped to the text area.
  • Tails are a list. Each is a tip (in balloon coordinates, and free to fall outside the balloon), a base width and a bend. The base is found by casting a ray from the balloon's centre and binary-searching for where it crosses the silhouette with QPainterPath::contains() β€” shape-agnostic, so every shape grew a working tail for free and a tail may leave any edge. Painter::bounds() therefore computes what the record actually covers; box is the balloon alone.
  • Each tail is an object of its own. It shares nothing with its siblings: its width and bend are its own, and Add tail only seeds a new tail's starting values from the last one. It is selected in the object tree or by pressing its handle on the strip, which leaves the bubble selected there β€” its handles are drawn only on a selected bubble β€” but hides its box and grips, which stop resizing while a tail is the subject; the selected tail's handle is drawn hollow. OBJECT STATE then shows only Tail (width, bend) and Delete tail, which removes that tail and selects its bubble. A press anywhere else on the bubble selects the bubble again.
    • A tail is addressed by position, ObjectController::selectedTail(), not by an id: undo restores whole states and holds no tail by number. A feed that changes how many tails the bubble has cannot say which one went, so the selection moves up to the bubble.
    • With Ctrl, a tail joins a selection instead of replacing it (Selection).

Every placed thing is an object

  • Every placed thing is a StripEdit::Object. One QGraphicsObject per overlay, and everything an author does to one is the same whatever kind it is: select, move, drag a grip, mute, delete, reorder. That all lives on the base class, once β€” the drag state machine (beginDrag() / dragTo() / endDrag()) and the "report only a settled drag" rule the undo stack depends on. A kind supplies only what it draws, how big that is, and any extra handle it offers.
    • The selection chrome is one item above every object (SelectionChrome): the dashed box, eight grips (corners and edges, GIMP's model) and every tail handle of whatever is selected. Drawn by each object it sat at that object's place in the stack, so an object above hid its grips and took presses meant for them; drawn above everything, in scene units at a fixed screen size, it does neither, and a picture's item scale no longer changes its grips' size. Its shape is the grips alone, so other presses fall through; a press on a grip hands the drag to the object (Object::beginDrag()). It follows the objects through Object::chromeChanged() and the zoom through ObjectController::refreshChrome().
    • The resize maths is a pure function, resizedRect() (objects/resize.*): the dragged corner or edge moves, the opposite one is the anchor; Shift keeps the aspect (a corner follows the axis pulled further), Ctrl anchors the centre. Sizes snap to one item unit before anchoring, so the anchored side never drifts, and a box below 40 px is refused rather than clamped. An edge grip answers along the whole edge and is drawn as a square at its middle; it is not offered on artwork (keepsAspect()) nor on an edge shorter on screen than three grips.
    • BubbleObject β€” painted from the authoring model by Painter::paintPaths(), so typing updates the strip with no file round-trip; the file is written from the same paths at the same scale, so the preview is the render. A styled bubble shows the library's own rasterisation at rest (Where a bubble lives). Its tails are the object's handles.
    • ArtworkObject β€” imported artwork, drawn from its own picture (an SVG through QSvgRenderer, below), with no parameters to edit. It keeps aspect on a corner drag, because the record stores one width fraction and a distorted one is not expressible.
    • What is clickable is the box, not the bounding rectangle. Object::shape() is the box, plus the tail tips that are drawn (grabbable at any time); the grips are the chrome's. A tip inside its own balloon draws no tail (Painter::tailsDrawn()), so it is not in the shape β€” a press there moves the balloon β€” and is reachable only through the chrome, once the balloon is selected. Qt's default would be boundingRect(), which here is the drawn extent β€” balloon βˆͺ glyphs βˆͺ every tail tip, padded for grips β€” a rectangle largely made of empty page whenever a tail is long, and one that lets the upper of two overlapping bubbles swallow presses meant for the lower. A tail's shaft is left out deliberately: it is a thin sliver far from anything anyone aims at.
    • The split is a correctness measure, not tidiness. One item class switching on whether a pixmap had been handed to it would force every caller to re-derive the same distinction from the model β€” and a caller that gets it wrong persists a default bubble over imported artwork, or makes Duplicate write a blank balloon. With two types none of that is expressible. Three model-side asks remain and all three are legitimate: choosing which object to build, naming a row before the scene has a layout, and comparing records in Project::applyOverlays().

Placement and creation

  • Placement is resolution-independent. Every coordinate and the artwork's width are stored as fractions of the render's target width, so re-profiling a chapter moves and resizes every object proportionally and nothing has to remember what the numbers used to mean. StripLayout::targetWidth() is that unit on the GUI side β€” the strip's width, which is the target width because the page domain scales every page to it β€” and StripLayout::pixels() / the write-back in onOverlayGeometryCommitted() are the only two places a placement crosses between the two forms.
  • Placement is page-anchored. An overlay stores anchorInputUid plus an offset from that page's top; the editor resolves it against the layout it just built, exactly as ProcessingPipeline::run() does. Dragging across a page boundary silently re-anchors. An overlay whose page is not in the strip is unanchored: drawn faint, not selectable on the strip (there is nothing to select it on, so it is picked in the object stack), never re-homed by itself and never deleted. Re-anchor to β–Έ on its menu puts it on a page (Inputs and anchoring).
  • Creation is the library's. The editor emits recordCreated(); Project::createOverlay() writes the SVG and calls ProjectItem::addOverlay(), which mints the uid, hashes the file and dedups identical content. Every other edit arrives as the complete new state on overlaysCommitted().

Presets

  • Which preset an object looks like is computed, never stored. PresetStore::matching() restyles the object by each preset in turn and keeps the one that changes nothing; lookLabel() names it, or says Custom. A remembered "this came from Shout" would be a field that goes stale on the next edit and has no answer at all for a selection of several β€” this one self-heals if the artist edits back to an exact match, and answers Mixed when a selection disagrees.
    • Only the groups a preset fills are compared β€” shape, fill & outline, line style, text style. The box, the tails and the lettering are the object's own and applied() copies them across, and the style seed belongs to no group: comparing whole records would make two balloons that match in every visible way report as different looks.
    • It is shown where it answers something: OBJECT STATE says what the selection is (Mixed when they disagree, nothing at all for a tail), and the OBJECT STACK names the preset on a row only when it matches one β€” Custom on every hand-made balloon would be a column of chips reporting that there is nothing to report.
    • TOOL OPTIONS wears no chip. Every property a preset covers is on screen a few points below the picker, so a chip there would report what the controls already say, and beside the picker it read as a second control answering the same question β€” the fault this panel was rearranged to fix.
    • The picker offers; it never claims. The combo in TOOL OPTIONS shows a prompt and no current entry, because a drop-down showing an entry means this is what is selected and a preset is a one-shot fill that stops describing anything the moment a control moves. Save… and Delete act on the look the controls currently are β€” matching() again β€” rather than on a picker's selection.
  • A preset is a bubble with nothing said in it. BubblePreset (widgets/stripeditor/presetstore.hpp) is a name plus an ObjectRecord whose text, box, tails and styleSeed are meaningless β€” applying one copies the look over the selection and copies the content straight back, so restyling never touches the lettering. With nothing selected it restyles prototype() instead, which is what the next placement is built from. Serialised by taking ObjectRecord::toJson() and removing those four keys: the reader's defaults fill them back in, and a styling field added to ObjectRecord is carried without being enumerated anywhere. Built-ins are code and are not deletable; the artist's own live in the application config (QSettings, key bubblePresets) because restyling follows the artist, not the chapter. A pack is the same array in a file, marked with platemakerBubblePresets, imported by replacing same-named presets rather than accumulating them.

What an object is, and who answers

  • Both axes have a name, and one place each. Which silhouette an object wears is a property; whether it has one is ObjectRecord::hasSilhouette(), the one structural question β€” it decides which property groups the object carries at all. Whether we author the object is ObjectController::isParametric(): true for something whose drawing we generate from a record, false for imported artwork. Written out by hand at each call site instead, either test is a place to get it wrong β€” and the wrong answer is quiet: ObjectRecord::Map::value() returns a default balloon for a uid it does not hold, so a panel bound that way offers a shape, a fill and a line style for a photograph and swallows every edit.
    • carriesGroup(record, group) is the one answer to "which sections apply". The lettering is the group every kind carries; shape, fill, line style and tails all need a silhouette to sit on; TailItem belongs to a tail, which is not a record. OBJECT STATE writes it out for a single subject and for a set, and the object menu measures a selection by it β€” one answer, so a group added to it reaches every surface. A pure function of a record and header-only, which is what lets it be a test rather than a comment. What each surface does with the answer stays that surface's: OBJECT STATE shows a set the union, the menu offers only the intersection, and a set is never offered Shape or Tails because giving several objects one shape is a conversion.
    • One write path, and pairing is stated rather than inferred. applyRecords(uids, records, …) writes whichever kind each object is, so no path can take only a balloon and drop a picture without saying so. Records are never matched to the selection by position β€” that refuses whenever the two lengths disagree, which makes a mixed selection uneditable. OBJECT STATE's own signals carry records and no uids, so the objects it was bound to are remembered in m_panelSubjects at the moment of binding: a selection that has changed since cannot make the right records land on the wrong objects.
    • A set of several binds whatever kinds it holds. The panel sorts records into roles β€” skin and line style to the objects with a silhouette, the lettering to all of them β€” and writes each group back only to the objects it was bound from, so the union is what shows and nothing is written to an object that has no place for it.
    • The objects hold the state; the controller holds no second copy. A map kept beside the objects would have to be written at every edit, and a write path that updated only one would leave the other describing something that is not on screen. What the controller keeps is a seed: the records the last feed brought, read only to build an object that does not exist yet and to answer for a uid that has none. Every other read goes through recordFor(), and what goes out on overlaysCommitted() is currentRecords(), derived from the objects at the moment it is asked for. A record reaches an object in exactly two places β€” its constructor, and the adoption loop in setSource(), which is the one direction a record travels from outside.
    • Every object carries an authoring record, and Object::record() is how anything asks for it. A balloon's is everything about it; a picture's says which file it is and what words are over it, on the base class β€” so no caller has to prove which subclass it holds before reading a record. What is still cast is what only one kind has: a picture's pixmap, a balloon's library rasterisation.
    • The object is the authority on what a picture is. ArtworkObject keeps its record describing the file it loaded, at that file's own pixels (describePicture()), so asking any object for a record can never hand back the default speech balloon that ObjectRecord::Map::value() returns for a uid it does not hold. A record placed before pictures had one, or one whose size was guessed at import, is repaired the moment the object adopts it. The box matters because Svg::write() refuses an empty one, so an unrepaired picture silently cannot be lettered; the size itself is read by the one loader that asks an SVG rather than the image plugin, and carried on the import signal because the far end sees only the copy it just wrote.
    • A row names a picture by its lettering, or by its file (ArtworkObject::label()), so no two pictures' rows read the same.
    • One OBJECT STATE for every kind of object. A picture needs no panel of its own: deciding which sections apply (carriesGroup()) already hides the ones it lacks. So a picture is bound like any other object: its file names it, its lettering is the same section a balloon's is, and its one extra question is a row. Its size is a percentage of its own pixels, where 100% is one image pixel per strip pixel β€” a percentage rather than a width because the artist's question is how much bigger than I drew it, and because the stored form is a fraction of the page, so the same percentage survives a re-profile. Like blend it belongs to the overlay rather than to the drawing, so it is not a property group and arrives through its own setter. Moving the spin box previews; releasing it is the one history step. Fit to text is absent for a picture, whose box is the picture's and not its words'.
    • The object's menu offers artwork only what applies to it. Apply preset β–Έ, Save as preset…, Apply from tool options β–Έ and the two colour entries are absent β€” not greyed β€” unless every selected object can take them: greying says not now, and these never apply to a picture somebody else drew. The intersection rather than the union, the same rule Convert to β–Έ follows and for the same reason β€” offering Fill with primary colour over a balloon and a picture would be offering to do it to both and quietly doing it to one. In their place artwork has two of its own, Original size (100%) and Fit to strip width, which a balloon does not get: a balloon has no size of its own, since its drawing is generated at whatever size it is given.

Imported artwork

  • Placing artwork is a tool, not a menu entry. The Artwork tool's options (TOOL OPTIONS) hold one picture β€” chosen once, previewed, and remembered in QSettings. There are two ways to put it down, and they differ in the one thing artwork cares about:

    • Dragged out of TOOL OPTIONS' preview it lands at its own size, centred where it was dropped β€” past the strip's edge if that is what it is, which for a sound effect is the point rather than a mistake to correct. The payload is a file URL, so a picture dragged in from a file manager lands the same way, under any tool.
    • Dragged on the strip with the tool armed, it is fitted to the box drawn.

    Arming the tool with nothing chosen asks for a file there and then, before any drag, so a dialog never interrupts one. A tool, because the object menu is not where anyone looks to add something; Import artwork… stays on that menu for the case with no tool armed.

    • An imported SVG that carries a recipe comes home as a balloon. Our own files hold the parameters they were drawn from in the pm: namespace, and Svg::read() reads them back β€” so a bubble sent by a collaborator, or copied out of another chapter, arrives re-typable instead of being filed as a picture of itself. Matched on the namespace URI, never the prefix, which in XML is only a local shorthand. A drawing with no recipe stays artwork, which is the honest answer: someone else's paths are not something our silhouettes can express.
    • The namespace is https://github.com/ShadobaDev/Platemaker-qt/ns/artifact/1 β€” an identifier, not an address. XML compares a namespace name as a string and never fetches it, so it neither has to resolve nor may ever change once files carry it. It names the repository because that is a name the project controls, and says artifact because the record describes pictures as well as balloons. It is compared whole and exactly (QXmlStreamAttributes::hasAttribute(ns, name)), and exactly one namespace is accepted β€” no alias for any earlier spelling. A file that does not match is not an error: it has no recipe, so it is placed as a picture and still renders.
    • One version number. The URI's trailing /1 changes only when a file becomes unreadable to an older build β€” an old reader should then fail to recognise it at all, which a different namespace achieves by itself. The file carries no version attribute of its own: nothing would read one until a released reader meets a newer file. The first compatible revision that has to be told apart adds one, and a file without it is this format.
  • There is one record type and three kinds of object. ObjectRecord::artwork names an imported picture β€” a file name inside the workspace's overlays/ β€” and its presence is the kind: empty and the drawing is ours (a balloon when shape is not None, lettering when it is), non-empty and the drawing is somebody else's. A second map keyed by the same uid would be a second channel carrying the same object, and the two would drift; a record that says what it is keeps one. isArtwork() makes hasSilhouette() and hasTail() answer no whatever else the record holds, so our geometry cannot be applied to a picture by accident.

    • A picture can be lettered. The same Text group a balloon carries is bound to it β€” one editor, one group, one answer β€” and laid out across the whole picture, which textSafeArea() already gave to any object with no silhouette of ours. Hand-drawn balloons and sound effects are typeset without ceasing to be somebody's drawing.
    • What the render composites is a wrapper. A lettered picture's overlay points at a generated ovl-<sha>.svg that embeds the picture as a data URI and draws the lettering over it; an unlettered one points straight at the imported file, so the common case writes nothing. Embedded rather than referenced because the library hands the renderer a buffer with no base path (vips_svgload_buffer β†’ librsvg), so a relative href has nothing to resolve against β€” and a wrapper that cannot draw its picture is never written, because it would render as lettering floating over nothing.
    • The item draws the picture, not the file the library renders. They are the same thing until the picture is lettered; after that the overlay points at the wrapper, and an item built from that would draw the words twice β€” once baked into the wrapper and once itself. pictureFor() resolves the record's artwork beside the overlay's own file, so the preview is built from what the artist imported and the lettering stays the editor's to draw.
    • A vector picture stays vector on the canvas. ArtworkObject keeps a QSvgRenderer for an SVG source and draws through it, so zooming in sharpens the sprite instead of magnifying pixels chosen at import. The rasterised copy is kept for the row's icon and for the one question a picture has to answer about itself β€” how big it is. A raster source draws as it always did.
    • The picture itself is never overwritten. Svg::writeFile() ignores its reuse path for an artwork record: that path may be the imported file, and the imported file is the one thing in the workspace we did not make.
  • Imported artwork is the same overlay with fewer attributes. Import artwork… copies a file into overlays/ as art-<sha16>.<ext> and registers it with a picture record β€” artwork naming the file, shape None, box the picture's own pixels β€” so it becomes an ArtworkObject: placed, moved, re-anchored, muted, resized, lettered and rendered like any bubble, but its drawing is not ours to re-type. Duplicating one goes back through the import channel rather than through creation, and the library dedups the identical bytes onto the file already there.


See also: Strip editor Β· Property groups Β· Workspace folder ownership

⚠️ **GitHub.com Fallback** ⚠️