Skip to content

Development Strip Editor Shell

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

Strip editor — the shell

The dock, the tool rail, TOOL OPTIONS, the two columns, the cursor, the colour pair, seam-free rendering and page memory, and the grade.

The dock — StripEdit::Editor

A per-project dock (widgets/stripeditor/, a .ui-defined QWidget inside a QDockWidget), opened as a tab beside the Workspace — free to float or split out from there — from the Output tab's View strip button, a Workflow-map card or View → Strip editor, via MainWindow::openStripEditorDock(). It is the authoring surface for the pipeline's two optional steps — colour correction and text/bubble overlays — and it shows the chapter as one continuous strip.

The strip is built from the project's INPUT pages, not from its rendered output. It stacks each input put through the library's page domain (EXIF-upright → canvas-profile margin crop → scale to the output's target width) via ProcessingPipeline::layoutPagesFromHeaders() / decodePageToRgba(). Three consequences, and they are the whole reason for the design:

  • It works before the first render. A grade or a bubble has to be authored before it is baked; a view of the output would have nothing to show until a render existed.
  • Rendering does not change the view. Edits are previewed against the input, so the render bakes exactly what was on screen. Previewing over the output would grade, a second time, what the render had already graded.
  • The unit of work is the page, which is the unit the grade's per-page exclusions and an overlay's page anchor both address. An output slice can straddle two pages, so neither could be honoured over slices at all.

Output slices are deliberately absent: they are a publishing artifact. What the author still needs is the slice grid, which the seam guides draw at every sliceHeight down the strip — the line a bubble must not be split by.

  • Placement. A QDockWidget allowed Left/Top/Bottom and never tab-combined with the Action right column; it defaults to floating and wears the shared custom title bar (Main window). It opens sized to the strip width + 100px each side (× 80% of the screen height), centred, and is tracked in m_openStripDocks (keyed by a projectIndex property), reindexed/closed with its project. MainWindow::refreshStripEditor() re-feeds it on every Project::projectModified.
  • Shared dock tab bar. The Workspace, project and strip docks can occupy one tab group, so their tab bar's close / double-click are resolved by dockForTabBarTab() (tab → dock by window title) rather than a raw index into any one list. wireDockTabBars() (re)applies this whenever a dock joins a group.

The two columns

editor.ui lays out [toolbar] over [toolColumn | graphicsView | rightPanel], with the two side columns each split vertically:

toolColumn = QSplitter(V): [ tool tiles | TOOL OPTIONS ]      what the NEXT object will be
rightPanel = QSplitter(V): [ OBJECT STATE | OBJECT STACK ]      what THIS object is

Each side answers one question, and only that one — and they are two classes, not one class in two places. BubbleToolOptions is the tool's options: what the next object will be, editing nothing and emitting nothing. ObjectState is the selection's properties. A single class answering both would mean two things depending on state the artist cannot see, which is what makes a preset picker above shared controls ambiguous.

Both own a PropertyGroupSet — the shape, skin, style and text editors, plus the only two rules about applying them: shape is written before tails (a tails editor reads it), and the style seed is topped up afterwards, since it belongs to no group. Written once, because both are the kind of rule that drifts when written twice — the seed as topUpStyleSeed(), beside the record rather than in the set, because four writers need it and only one of them is a property group. Tails are not in the set, because the two panels edit different things: the next balloon has one tail to set up (NextTailEditor), an existing balloon has a list whose members are objects of their own (TailListEditor, TailEditor). Each panel hands its own to collect(), which keeps the order.

PresetStore is a model, not a panel's field. The tool's options pick a preset for the next object; an object's context menu applies one to what is selected (Apply preset ▸); Tools → Import bubble presets… / Export bubble presets… move packs between machines (MainWindow::onImportBubblePresets()). A model reachable only by going through a widget cannot be reached, so Editor owns the store and hands a reference to each. It raises no dialogs: reading and writing a pack report what happened and leave the telling to whoever asked. PresetStore::applied() is the one place that knows what a preset does not carry — the lettering, the box, the tails and the style seed all survive being restyled.

ObjectState shows one CollapsibleSection per group, in a fixed order, and only the sections the selected object's kind has. Qt has no collapsible container — a checkable QGroupBox puts a checkbox in the title that means enabled — so widgets/collapsiblesection/ is a disclosure arrow over a content widget. Which sections are open is a working preference and lives in QSettings. With nothing selected the panel shows one line saying so and no sections at all.

The grade lives in tool options rather than on the right: its subject is the project, so it is the tool's own configuration and not any object's property.

The tool-options panel is never blank. A tool whose row names no options page gets the hint page: the tool's name and one sentence saying what a press or a drag with it does, both taken from its registry row. An empty panel under an armed tool reads as nothing is armed — which is how a tool gets picked by accident and the fault is then looked for somewhere else entirely. The same sentence is the second half of the rail button's tooltip (toolTooltip() joins the name to it), so it is written once rather than once per place it is read, and a new tool cannot arrive without one.

The right column is never hidden and never disabled, under any tool. Hiding it lets the canvas grow into the space, which makes the strip jump sideways on every tool change; disabling it costs the artist the only place the strip's contents can be seen and reordered. A tool changes what is in the column's tool-facing half, never whether the column is there.

Splitter positions are remembered in QSettings — a working preference that follows the artist rather than the comic, and written on every close, so a changed default only ever reaches someone who has not opened this editor before. The right column starts at two thirds OBJECT STATE, one third OBJECT STACK, stated as sizes rather than left to the children's size hints (a list's hint is one row tall, which is how it ended up a sliver).

  • A panel may not decide how wide its column is. Both stacks — the tool's options and the object's properties — hold each panel in a QScrollArea, because a stacked widget's minimum is its pages' minimum and a splitter may never take a child below that. Without it the right column would grow the moment something was selected and its controls appeared, sliding every row in the stack sideways out from under the pointer that had just come down on a mute checkbox. It is also what lets a long set of properties scroll instead of squeezing the object stack.
  • A side column is as wide as its panel needs, and cannot be dragged narrower. Both are enforced as minimums and not only as starting sizes: a size is outvoted by whatever the artist last had saved, and someone whose saved layout predates this would go on reading half a spin box. Where the two columns differ is who knows the width.
    • The tool column asks its panel. The tool options describe the object that does not exist yet, so every control is built with the panel and minimumSizeHint() is complete and final. Asking follows the screen, which a number cannot: the same panel measures 330 points at one font and scale and more at another, and a width measured on one machine is a width that clips on the next.
    • The right column is told. The properties panel builds its controls when something is selected, so at construction it has none and answers 55 — while the width has to be settled before anything is selected, and asking again afterwards would be the column widening under the pointer, which is the thing the scroll areas above exist to stop. So it carries a measured constant: with an object in it the panel asks for 324 and stops needing a horizontal scroll bar at 332; k_rightColumnPx is 340, leaving room for the vertical one.
  • The tools are never negotiable. A flow layout's minimum is one tile, so a splitter was free to shorten the rail until its last row of tools was simply not drawn. The rail's minimum height is whatever its own wrapping needs at its current width, recomputed whenever that width changes, and the tool options keep a floor of their own; no column can be collapsed out of existence by a drag.
  • The handles are visible and 6 points wide. Neither the native style nor Fusion paints anything on a splitter handle, so the default one was an invisible 1-point target. The seam is filled from the palette — Midlight on a dark window, Mid on a light one — chosen the way widgets/badge/ chooses a chip's lightness rather than named as a colour.

The tool rail and the pointer

  • Tool rail (left) — square checkable QToolButtons in an exclusive QButtonGroup, laid out by FlowLayout so they reflow to the rail's width (a flow layout cannot be expressed in a .ui). The colour pair sits in its own row underneath, not in the flow: in the grid it would read as another tool. Tools, in rail order: Select (default — a drag on the bare strip rubber-bands what it covers), Pan (a drag scrolls), Colour correction (the grade), Bucket paint, Colour picker, Speech bubble, Artwork, Text. A row is a kind of object, not a value of one of its properties: Speech bubble and Text author the same record and differ in the one thing that is structural — whether the object has a silhouette. So there is no Caption tool: a caption is one of nine silhouettes, picked in the shape tiles like every other, and a preset places one in a click. dragMode is a single property, which is why Select and Pan are two rows rather than one tool: the rubber band and the hand both want the left button on the bare strip. Both only act on a press no item took, so dragging an object moves it under either. The middle button scrolls under every tool — implemented here rather than by the view, because Qt's hand-drag is the left button's. A tool decides what a placement creates; selecting, moving and resizing what is already there is available under every one of them (selection).
  • The rail is built from a table, and a tool is a record. StripEdit::tools() holds one Tool per rail entry — id, icon, tooltip, ToolKind (Select, Pan, Create, Grade, Sample, Apply), the shape a Create tool places, and which options page it shows — and the rail, the options stack, the drag mode and the cursor are all read off it. A tool that reuses an existing ToolKind and options page is a row there and nothing else; a new options page or ToolKind reaches the editor itself (Extending the strip editor). A tool is stateless, so it needs no object and no class per button; the record gains a hook the day a tool needs behaviour of its own.
    • A Create tool that fixes its shape (Text places none) says so in its row, and BubbleToolOptions::prototype() applies it over the artist's pick without disturbing it — which is why switching Text → Speech bubble brings back the shape chosen before. It is the one place that says this tool makes a shapeless object, so ObjectController knows nothing about which tool is armed.
  • The pointer is decided in one place. A cursor is a function of (active tool, what is under the pointer), evaluated by StripEdit::cursorFor(); nothing else writes the viewport cursor. The tool row carries the data half — cursor over the bare strip, cursorOnObject over an object — so Pan shows a hand and a move cross, the authoring tools a crosshair, and a new tool still costs one row.
    • The two resize corners and a tail tip are the canvas's, whatever the tool: every object is movable and resizable under every one of them. Because the cursor promises that, Bucket paint lets a press on a grip through to the canvas rather than painting.
    • The view's drag mode still writes a cursor of its own (ScrollHandDrag, under Pan). Rather than taking it away — its documented rule, "only affects mouse clicks that are not handled by any item", is worth keeping — cursorFor() answers the same open hand in that state, so the two agree instead of overwriting each other. setTool() therefore sets the drag mode first and decides the cursor after; reversing those two lines brings the flicker back.
    • It is re-decided on hover (only while no button is held), when the pointer enters the canvas, after a release, on a tool change, after a zoom and after a feed — the last two because they move the scene under a pointer that has not moved. The viewport has mouse tracking on for exactly this reason: a widget hears about the mouse only while a button is held otherwise, which would leave the cursor following the last click rather than the pointer. The re-decision after a release is queued: under ScrollHandDrag the view restores an open hand on every left release, even one an item took, and its handler runs after the filter.

Colour

  • The colour pair is furniture — ColourPair, under the tiles in the tool column, where it stays whichever tool is active. It is drawn the way every drawing application draws one: two overlapping swatches, primary in front, with swap and reset to black and white beside them, inside a panel of its own. Two swatches side by side in a rail read as two more tool tiles; the overlap and the frame say one control, and not a tool. They are real buttons rather than painted regions, so the theme's hover and focus states and keyboard reach come for free, and the swatches show a chequer under a colour that is not opaque. It is not on the tool-options page, because that page swaps with the tool and furniture that vanishes is not furniture. The tools that use it hold a reference to it rather than a colour of their own, which is what keeps them stateless. It is independent of the colours in OBJECT STATE — setting a balloon's fill does not touch the pair, and changing the pair does not touch any object — and it persists in QSettings, because it is a habit of the artist rather than a property of one comic.
  • Bucket paint (ToolKind::Apply) spends the pair on what a press lands on: left with the primary, Shift+left with the secondary. X swaps the pair and D resets it to black and white, both scoped to the canvas so typing an x into a balloon stays typing an x.
    • What changes is what is under the pointer — the lettering, the outline band, or the fill — answered by Painter::partAt() off the same two paths the scene draws (Painter::silhouette(), Painter::textOutline()), topmost first. A few screen pixels of slack make a hairline outline and a thin letter hittable, converted through the zoom so they do not swallow the fill when magnified. A press inside a balloon's box but outside its silhouette paints nothing; imported artwork takes nothing. One undo step per press, named after what it painted.
    • The tool therefore has no "what am I painting" setting: the picture is the setting, and the two axes — which colour, which property — stay separate.
    • The right button stays the context menu's. Only the Colour picker takes it, and only because sampling two colours is what a pair is for.
    • The press selects what it painted, so OBJECT STATE shows the change that just happened rather than leaving an undo step describing something invisible.
    • Its other face needed no code: editing the selection's colours is SkinEditor in OBJECT STATE, which was already there because OBJECT STATE follows the selection and never the tool. A tool's "editor face" is a tool naming a property group some panel already shows.
  • The Colour picker (ToolKind::Sample) takes what is drawn at the press — one composited pixel of the scene: the page through its grade, with every balloon, caption and imported asset over it, each with its own blend mode. Sampling the page pixmap alone would give, for a click on a balloon, the paper behind it, which is not what the artist sees. Left fills the primary half, right the secondary (Ctrl+left does the same as right), and the right button opens no context menu while the tool is active, because it belongs to the tool.
    • Chrome is not part of the picture. Selection boxes, grips, tail handles and the seam guides are hidden for that one repaint — Object::setChromeVisible() — so sampling a selected balloon gives its fill rather than the highlight colour.
    • A page still showing its blurry proxy is not sampled: the page is requested instead, because a stand-in would answer with an average of the colours around the point rather than the colour at it.
    • The press is consumed, so sampling never changes the selection.

What the columns hold

  • Tool options (bottom-left, under the rail) — a QStackedWidget, one page per page rather than per tool: GradeToolOptions for Colour correction, ArtworkToolOptions for Artwork, BubbleToolOptions for every tool that authors a lettered record (Speech bubble and Text), because they author the same object (objects) and two copies of those controls would drift. A tool with no options gets the hint page (above).
  • Object stack (right-bottom) — ui->objectStack, the overlays as a stack: row 0 is the front-most object, and a row covers every row below it wherever they overlap. The library's composite order is the opposite (it draws stripOverlays in vector order, so the last element is on top), so the list is that vector reversed — done in ObjectStack::refresh() / ObjectStack::commitOrder() alone, because a layers panel that reads bottom-up is a surprise in every tool that has one, and the reversal belongs at the view rather than in a model the render shares.

Rendering (seam-free) and memory

QGraphicsView + QGraphicsScene. The scene's coordinates are strip coordinates, 1:1 — which is what lets an overlay's scene position be its library placement plus its anchor page's top, with no mapping layer.

  • One item for the pages. StripItem draws every page as its own image in one pass. One QGraphicsPixmapItem per page would leave a 1px hairline at each join — QGraphicsView clips and rounds each item's edge independently — but the actual cause is edge antialiasing: with AA on, each drawPixmap coverage-antialiases the destination rect's edges at fractional zoom, so the boundary row is only partly covered and the background hairlines through. StripItem::paint disables QPainter::Antialiasing (keeping SmoothPixmapTransform), so adjacent pages tile with hard edges. Overlays are separate items above it (objects) — they are sparse, so they cost no seam.
  • Layout without pixels. layoutPagesFromHeaders() reads each page's header and decodes nothing; pages the render would skip (missing / unreadable) are dropped here exactly as the render drops them, or every page below would sit at the wrong strip offset.
  • Two tiers, both off the UI thread (threads):
    • Proxy — the input page's thumbnail from the lib ThumbnailCache the Input tab already warms (reused, not reinvented), drawn instantly so a page is never blank.
    • Sharp — decodePageToRgba() on QtConcurrent for pages in view plus a one-page prefetch margin, into a byte-capped LRU QCache. Off-screen pages are evicted, so RAM tracks the viewport, not the chapter. A generation counter drops async results from a superseded rebuild.
  • Zoom. 100% default, plus fit-width / 100% / − / + and Ctrl+wheel; the default re-settles on resize until the user takes control.

The grade (Colour correction tool)

GradeToolOptions edits Models::ColourCorrection — brightness, contrast and saturation (curves are run by the library but have no editor here). It emits changed() continuously (live preview) and committed() debounced (persist + one undo step), which MainWindow routes to Project::applyColourCorrection(). A page's exclusion is a checkbox in OBJECT STATE when that page is selected.

The preview grades the resident, ungraded page pixels in place with Core::ColourCorrector::applyToRgba() — the same engine the render uses, so the preview is not an approximation. Because the colour step never influences how a page is read, no grade edit can invalidate a built page: re-grading what is resident is always enough, and a page fetched once stays a valid baseline for every grade tried on it. Excluded pages are skipped, matching the render.

  • A colour grade is its own switch. The library dropped ColourCorrection::enabled, so graded means not neutral — Models::isNeutral() is the one test the render, the staleness signature and this preview all gate on, which is what makes the strip editor WYSIWYG by construction rather than by two predicates happening to agree.
    • The workflow card reads the values, like the Text & bubbles card next to it reads the overlay count: active because there is something in it, no in-place + (a grade is made in the editor), and − removes what is there: every adjustment back to neutral, recorded as Remove colour correction. The page exclusions stay — they are each page's decision, and apply again once the strip is graded again.
    • The Colour correction tool is an image editor's colour menu, applied to the selected object. Its TOOL OPTIONS page lists the adjustments — Brightness & contrast, Saturation — with the applied ones in bold, and the chosen one's controls below, live, settling into one undo step named Adjust …; Reset takes that adjustment off (Reset …) and leaves the others. Curves are not listed. The panel edits only the shown adjustment's fields and switches nothing.
    • It can act on the strip, and on nothing else yet (a page's only colour decision is its exclusion). With a page, an overlay or nothing selected the list and controls are disabled and the panel says why, with Select the strip. Picking the tool with nothing selected selects the strip, as an image editor always has an active layer for a colour tool; a selection the artist made is left alone.
    • ColourAdjustment is the one mapping between the names an artist knows and ColourCorrection's fields, used by both panels: which adjustments exist, whether one is applied, its values, and the grade without it. Neutral is read from a default-constructed ColourCorrection rather than restated, and a GUI test pins that removing one adjustment touches nothing else and that the per-adjustment rules add up to the library's isNeutral().
    • The undo step is named by whoever made the edit. Editor::colourCorrectionCommitted(cc, undoText) carries the name to Project::applyColourCorrection(cc, undoText), which records it as given.

See also: Strip editor · Code map · Objects

Clone this wiki locally