Skip to Content
ftui-layoutPanesOperations & timeline

Operations and Timeline

Every change to a PaneTree goes through a single enum and a single append-only log. This is the discipline that makes the whole workspace deterministic: you never mutate the tree directly, you submit a PaneOperation, and the PaneInteractionTimeline remembers what happened.

Why the discipline matters

Three hard requirements share a solution:

  1. Undo and redo must be exact. Not “approximately undo”; bit-for-bit rewind and replay.
  2. Conformance tests must be reproducible. A recorded user trace must produce the same final tree in the web backend and the terminal backend.
  3. Debugging must surface what changed. When a layout looks wrong, you want to see the last five operations, not diff two state trees by hand.

A single mutation vocabulary — PaneOperation — with a single sink for history — PaneInteractionTimeline — gives you all three.

PaneOperation — the vocabulary

pub enum PaneOperation { SplitLeaf { target: PaneId, axis: SplitAxis, ratio: PaneSplitRatio, placement: PanePlacement, // Before | After new_leaf: PaneLeaf, }, CloseNode { target: PaneId }, MoveSubtree { source: PaneId, target: PaneId, axis: SplitAxis, ratio: PaneSplitRatio, placement: PanePlacement, }, SwapNodes { a: PaneId, b: PaneId }, SetSplitRatio { target: PaneId, ratio: PaneSplitRatio }, NormalizeRatios, }

Each variant has one obvious effect:

VariantEffect
SplitLeafReplace a leaf with a split; attach new_leaf on the given side.
CloseNodeRemove a node; its sibling is promoted up.
MoveSubtreeDetach source and re-graft it adjacent to target.
SwapNodesExchange two subtrees in-place.
SetSplitRatioChange a split’s ratio (e.g., during a drag).
NormalizeRatiosCanonicalize every ratio (6:4 → 3:2). No geometric change.

There is no Resize operation. Resize is expressed as a SetSplitRatio on the appropriate ancestor split. The drag/resize machine emits SetSplitRatio operations as pointers move.

Apply an operation

The straight-through path:

apply.rs
use ftui_layout::pane::{ PaneOperation, PaneLeaf, PanePlacement, PaneSplitRatio, PaneTree, SplitAxis, }; let mut tree = PaneTree::singleton("editor"); let outcome = tree.apply_operation( /* operation_id */ 1, PaneOperation::SplitLeaf { target: tree.root(), axis: SplitAxis::Vertical, ratio: PaneSplitRatio::new(3, 2)?, placement: PanePlacement::After, new_leaf: PaneLeaf::new("terminal"), }, )?; println!("{:?}", outcome.kind); // SplitLeaf println!("{:016x}", outcome.before_hash); println!("{:016x}", outcome.after_hash); println!("{:?}", outcome.touched_nodes);

PaneOperationOutcome

Every successful apply returns:

pub struct PaneOperationOutcome { pub operation_id: u64, pub kind: PaneOperationKind, pub before_hash: u64, // FNV-1a over canonical snapshot pub after_hash: u64, pub touched_nodes: Vec<PaneId>, }

Hashes make noop-detection trivial: if before == after, the operation was a no-op on the tree. touched_nodes is the minimal set of IDs whose records changed — useful for invalidating per-pane caches.

PaneOperationError when it fails

pub struct PaneOperationError { pub operation_id: u64, pub kind: PaneOperationKind, pub reason: PaneOperationFailure, } pub enum PaneOperationFailure { MissingNode { node: PaneId }, NodeNotLeaf { node: PaneId }, CannotCloseRoot, ConstraintViolation { /* … */ }, // … }

A failed apply leaves the tree unchanged. There is no partial-apply state.

PaneInteractionTimeline — undo / redo / replay

The timeline is the append-only log of what actually happened:

pub struct PaneInteractionTimelineEntry { pub operation: PaneOperation, pub outcome: PaneOperationOutcome, pub timestamp: u64, pub user_id: Option<String>, pub message: Option<String>, } pub struct PaneInteractionTimeline { entries: Vec<PaneInteractionTimelineEntry>, position: usize, // cursor for undo/redo checkpoints: Vec<PaneInteractionTimelineCheckpoint>, }

The cursor position is the count of entries currently applied. undo moves it backward; redo moves it forward again.

The cursor model, visualized

entries: [op0, op1, op2, op3, op4] position: ^ applied ────────────┘ not applied

After undo():

entries: [op0, op1, op2, op3, op4] position: ^ applied ───────┘ tail is "redo-able"

redo() moves the cursor right. If you apply a new operation while the cursor is mid-history, everything to the right of the cursor is discarded — standard linear-history semantics.

Checkpoints — bookmarks for branching exploration

pub struct PaneInteractionTimelineCheckpoint { pub position: usize, pub label: String, /* ... */ }

You tag a checkpoint (“before I tried layout A”), apply experimental ops, then restore_checkpoint("before I tried layout A") and try something else. Useful for “try both and pick” workflows.

Deterministic replay — the conformance test

The replay machinery is the reason this whole design exists. PaneSemanticInputTrace serializes a user’s input sequence; the PaneSemanticReplayOutcome compares a replay run against a recorded reference:

pub struct PaneSemanticReplayOutcome { pub diffs: Vec<PaneSemanticReplayDiffArtifact>, pub conformance: Vec<PaneSemanticReplayConformanceArtifact>, /* … */ } pub enum PaneSemanticReplayDiffKind { StateHashMismatch, OperationMismatch, EventCountMismatch, /* … */ }

A trace that replays cleanly on both terminal and web backends is the strongest possible evidence for parity. A trace that fails replay tells you which event produced divergence and how the state diverged (before_hash / after_hash).

See terminal/web parity for the adapter story.

A full worked example — undo after a drag

User drags a divider

The drag machine emits a sequence of SetSplitRatio operations as the pointer moves, each applied via the journaler.

Drag releases

The final SetSplitRatio lands in the timeline with a label like "resize:root-split".

User hits ⌘Z

if let Some(outcome) = timeline.undo(&mut tree) { // Tree is back to its pre-drag state. // outcome.before_hash == tree.state_hash() holds again. }

User hits ⌘⇧Z

timeline.redo(&mut tree); // reapplies the SetSplitRatio

Pitfalls

Don’t skip the timeline. tree.apply_operation() mutates the tree but doesn’t journal. Use PaneOperationJournaler or a higher-level wrapper that records into the timeline. Mutations that bypass history can’t be undone and break replay.

CloseNode on the root is always an error. The tree must always have exactly one root. To “close everything” you have to destroy the whole PaneTree and allocate a new singleton.

Timeline entries are not garbage-collected. If you run a very long session, the entries vector grows without bound. Snapshot at checkpoints and truncate the tail if you care about memory.

Where to go next