Skip to content

Development Strip Editor History

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

History and undo

Why a project has exactly one undo history, what one step carries, how an undo shows itself, and the interleavings to try before believing it.

Status — built. MainWindow owns one QUndoStack per project, keyed by ProjectItem::uid and alive for the session; it is the active stack while either of that project's docks is in front.

One history per project, owned by no window

MainWindow owns a QUndoGroup and, per project, one stack. setActiveStack() follows dock visibility, so Ctrl+Z applies to whatever project the artist is looking at — but both of a project's windows, the project dock and its strip editor, activate the same stack. What the window decides is the project, never which half of the work is undoable.

The stacks are keyed by ProjectItem::uid and live for the session. A stack owned by a widget dies when the widget does, and the project dock used to be destroyed on close — taking the history with it, and taking with it the machinery an open strip editor was still sending edits to. Those edits were accepted on screen and dropped. The dock now hides instead; only removing the project, or closing the workspace, drops its history, and a command that outlives its project holds a QPointer and does nothing.

Why not one history per window. Because the obvious argument for splitting — Ctrl+Z must show its own effect; pressing it in the Output tab should not quietly rewind a balloon three screens away — buys that at a worse price. Reorder the pages, move a balloon, press Ctrl+Z in the project dock: with two histories the reorder is undone, not the balloon. The artist has just undone the second-to-last thing they did. That is harder to explain than "Ctrl+Z undid something in the other window", and no editor behaves that way.

Nobody else splits either. Blender has had one linear UndoStack per file since 2.8, whose entries are typed steps — memfile, mesh, sculpt, text, image; the per-mode feel comes from a step encoding only its own domain, not from a second stack. Image editors keep one history per image, shared by every view of it. Photoshop, Krita and Figma keep one per document. VS Code is per buffer, which looks per-view only because each view is a different document. Where a tool lets you undo something off-screen, it mitigates by showing what will be undone — which is the section below.

An undo takes you to what it changed

Project::historyStepApplied(EditScope, uids) is emitted by the restores and by nothing else: an edit is already on screen in the window it was made in, so only undo and redo need announcing. MainWindow resolves the scope to a dock — the project dock, or that project's strip editor — and hands it to showDockAttention(), which shows it, raises it, focuses it, and outlines it for about a second and a half.

The scope is recorded with the step, by whoever made it (EditScope). It answers where to look, not which half: a colour grade is a project step, but only the strip editor edits it, so undoing one stays in the strip editor rather than bringing the project dock forward.

The outline appears only when that dock was not already the focused one. That condition is the whole effect: a flash on the window being looked at teaches nothing, and a flash that fires every time stops being read at all. The colour is the palette's highlight — "the colour this theme uses for look here" is exactly the question being asked — and the outline is a click-through child widget that fades itself out and deletes itself, so there is nothing to clean up and nothing to unset.

It lives beside DockTitleBar rather than inside the strip editor, because every dock in this application has the same need and none of it is editor-specific.

What a step carries

One command per settled edit:

Signal Meaning History
changed() a control is moving — live preview none
committed() the control settled, or a discrete action fired one step

A per-keystroke or per-mouse-move history is unusable, so geometry is reported on mouse release and text edits are debounced. Every editor and panel spells the pair the same way.

A command holds the whole of its half before and after — OverlayState for a lettering step, the library's project snapshot for a project one — rather than a description of what changed. Restoring a known-good state is one code path however the edit was made; replaying a description is one path per kind of edit, and the kinds keep arriving.

It holds the project as a QPointer, never a raw one: the stack outlives the widget, so a command whose project has actually been removed does nothing rather than reaching into freed memory.

Typed steps over one document

ProjectSnapshotCommand and OverlaySnapshotCommand share one stack and are undone in the order they were made. What is typed is their reach, and that is where the value is: restoring the lettering does not reload the project, restoring the project does not rewind the lettering, and neither command has to know what the other did.

Making that true took work, because the library's project snapshot carries the overlays — they are part of the project:

  • A project step snapshots the library's project state (fullSnapshot()) and no authoring records. On restore (applyProjectSnapshot()) it lifts the overlays out and puts them back, so undoing a page reorder cannot rewind every bubble to where it stood at the time of the reorder.
  • A lettering step carries OverlayState — stripOverlays plus the authoring records, typed rather than serialised — and touches nothing else. The two halves of it are never split: an overlay says which file renders, its record says what that file contains.

Which half a step covers is decided by what the operation changes, not by which window triggered it: Clear text & bubbles is a button on the project dock and records a lettering step.

Two properties then hold for free:

  • Deleting an input does not delete objects. They become unanchored and are preserved, so every editor command still has its subject. See Inputs and anchoring.
  • Undoing an editor delete after its page has gone re-creates the object unanchored, which is correct rather than broken.

Nothing is cleared when the project changes structurally. That is a judgement, not a proof, so it comes with a list.

Undo moves the selection

Dock attention says where; the selection says what. Undoing or redoing selects whatever the step touched and scrolls it into view, in the canvas and in the object list — a long chapter is exactly where an undone edit sits three screens away, and a selection that changes off-screen is no better than no selection at all.

The objects are found by diffing the two overlay states, in Project::restoreOverlayState(), rather than being tracked per operation. So every kind of edit reports what it moved without any of them being taught to, and a kind added later reports correctly on the day it is added. Records are compared alongside placements: a text edit rewrites a bubble's asset, and reading that as "changed" through the placement alone would rest on the hash having settled.

The signal is emitted before the state reaches the views, which is the part worth remembering. It arms Editor::selectAfterFeed() and the feed that follows consumes the arming — the objects do not exist in the editor until that feed builds them. It is the same shape as m_selectNewOverlay, which has always had to wait for the round trip because the library mints a new object's uid; the difference is that this one can name what it wants and that one cannot.

Two cases fall out rather than being handled:

  • A step that removed everything it touched — an undone placement, a redone delete — finds nothing standing and clears the selection instead of pointing at a ghost.
  • A step whose objects are already in front of you moves nothing: ensureVisible() and scrollToItem() are both no-ops on something already visible, the same "don't shout at someone who is already looking" rule the outline follows.

A list rather than a single uid, because one step can touch several objects. Only the first survivor is selected, although the canvas can now hold several; the rest are carried, so selecting all of them is a change in one place (ObjectController::setSource()).

What to try

Each row is one interleaving of the two step kinds, with the expected result stated so that a wrong one is recognisable rather than merely surprising.

Do this Expect
1 Place a balloon on page 4 → delete input 4 → undo the input comes back first (it was the last edit); the balloon is untouched and re-anchored
2 Delete input 4 → place a balloon → undo the balloon goes, the page list does not move
3 Reorder pages → move a balloon → undo, undo the balloon returns to where it was, then the page order does — last edit first, both times
4 Delete input 4 → delete the unanchored balloon → undo, undo the balloon returns unanchored, then page 4 comes back and it re-anchors to it
5 With the strip editor focused, undo a page reorder the project dock comes to the front and is outlined
6 With the strip editor focused, undo a balloon move it undoes in place — no outline, because you are already looking at it
7 Close the project dock, letter for a while, undo several times every step is there; the project dock reappears only when a step of its kind is undone
8 Replace file… on page 4 → undo/redo across it nothing unanchors in either direction
9 Leave objects unanchored → Render the gate appears; each of its four buttons does what it says
10 Move a balloon → scroll three pages away → undo the strip scrolls back to it, and it is selected in the canvas and in the object stack
11 Place a balloon → undo it is gone and nothing is selected — not a ghost row, not the previous selection
12 Edit a balloon's text → undo the balloon is selected; OBJECT STATE shows the restored text

Rows 3 and 4 are the ones that demonstrate the single history: everything undoes in the order it was done. Rows 5 and 6 demonstrate the attention rule, and 6 is the one that fails loudest if the "already focused" test is wrong. Rows 10–12 demonstrate the selection: 10 that it reaches off-screen, 11 that it lets go when there is nothing left to hold.

The hole this closed

Every mutation is still fire-and-forget through a signal, becoming real only when MainWindow round-trips it back — that part is unchanged and fine. What was not fine is that the round trip ended at a widget that could be destroyed. With the project dock closed there was nowhere to push, so the edit was declined, silently: the editor kept the edited state, showed it, and diverged from the model until the next feed.

The intent was right and the missing half was telling anyone. Tying the history and the editing machinery to the session rather than to a window removes the case instead of announcing it.


See also: Strip editor · Inputs and anchoring · Property groups

Clone this wiki locally