Skip to Content

Pane Tree

PaneTree is the raw data model for a pane workspace. It’s a tree of splits and leaves, keyed by stable PaneIds, with enough bookkeeping to round-trip through disk and replay operations byte-identically.

The shape

┌───── PaneTree ─────┐ │ schema_version │ │ root: PaneId │ │ next_id: PaneId │ │ nodes: BTreeMap │ │ extensions: ... │ └────────┬───────────┘ ┌─────────┴─────────┐ ▼ ▼ PaneNodeKind PaneNodeKind ::Split ::Leaf { axis, ratio, { surface_key: first, second } String, extensions }

Every node is a PaneNodeRecord:

pub struct PaneNodeRecord { pub id: PaneId, pub parent: Option<PaneId>, pub constraints: PaneConstraints, pub kind: PaneNodeKind, // Leaf or Split pub extensions: BTreeMap<String, String>, }

And every split carries its geometry:

pub struct PaneSplit { pub axis: SplitAxis, // Horizontal | Vertical pub ratio: PaneSplitRatio, // reduced form, e.g. 3:2 stays 3:2 pub first: PaneId, pub second: PaneId, }

SplitAxis::Horizontal means the split line runs horizontally — so first is the top child, second is the bottom child. This matches hsplit in tmux. SplitAxis::Vertical puts first on the left.

PaneId — stable, non-zero

PaneId(u64) with 0 reserved. Construction via PaneId::new(raw) returns Err(PaneModelError::InvalidPaneId { .. }) on zero. The tree tracks next_id as a monotonic allocator — operations never reuse a recently-freed ID, which keeps timeline replay well-defined.

PaneConstraints — per-node size bounds

Each node carries its own constraints for minimum and maximum size plus margin / padding overrides:

pub struct PaneConstraints { pub min_width: u16, pub min_height: u16, pub max_width: Option<u16>, pub max_height: Option<u16>, pub collapsible: bool, pub margin: Option<Sides>, pub padding: Option<Sides>, }

collapsible = true lets the layout planner hide the node when there is insufficient space, rather than pushing other panes off-screen.

Snapshots — the serializable form

PaneTreeSnapshot is the flat, serde-friendly representation:

pub struct PaneTreeSnapshot { pub schema_version: u16, pub root: PaneId, pub next_id: PaneId, pub nodes: Vec<PaneNodeRecord>, pub extensions: BTreeMap<String, String>, }

The conversion is round-trip safe:

let tree = PaneTree::singleton("main"); let snap = tree.to_snapshot(); let back = PaneTree::from_snapshot(snap)?; assert_eq!(tree.state_hash(), back.state_hash());

Canonicalization

snapshot.canonicalize() sorts the nodes vector by id and normalizes any redundant split ratios (e.g. 6:4 → 3:2). After canonicalization, any two trees with the same logical structure serialize to the same bytes. This is what state_hash() hashes over.

state_hash() — FNV-1a over the canonical form

let h1 = tree.state_hash(); // u64 // ... apply some operation ... let h2 = tree.state_hash(); if h1 != h2 { /* something actually changed */ }

Every PaneOperationOutcome carries before_hash and after_hash. The timeline uses these to detect noops and to validate replay.

Construction — singleton and from_snapshot

bootstrap.rs
use ftui_layout::pane::PaneTree; // Start fresh with a single root leaf. let tree = PaneTree::singleton("editor-1"); assert_eq!(tree.node_count(), 1); // Restore from disk. let json = std::fs::read_to_string("workspace.json")?; let snapshot: PaneTreeSnapshot = serde_json::from_str(&json)?; let tree = PaneTree::from_snapshot(snapshot)?;

from_snapshot validates as it builds. If the snapshot has duplicate IDs, orphaned nodes, cycles, a missing root, or next_id lower than an existing node’s ID, construction fails with PaneModelError. The tree on your heap is never in an illegal state.

Invariant validation — before you apply anything

Sometimes you want to validate a snapshot before trying to construct the working tree, or validate a working tree mid-session:

let report = tree.invariant_report(); for issue in &report.issues { eprintln!( "[{severity:?}] {code:?}: {msg}", severity = issue.severity, code = issue.code, msg = issue.message, ); } if report.has_unrepairable_errors() { // abort; something irrecoverable is wrong }

PaneInvariantIssue carries:

  • severity: PaneInvariantSeverityWarning or Error.
  • code: PaneInvariantCode — the category (DuplicateId, Orphan, Cycle, MissingRoot, RatioNotReduced, StaleNextId, …).
  • message: String — human-readable diagnostic.
  • repair: Option<PaneRepairAction> — the automatic fix, if one exists.

Repair actions — what safe automation looks like

pub enum PaneRepairAction { Reparent { node: PaneId, new_parent: Option<PaneId> }, NormalizeRatios, RemoveOrphan { node: PaneId }, BumpNextId { new_next_id: PaneId }, ClearExtensions { node: PaneId }, }

snapshot.repair_safe() attempts every repair the report judges safe, returning PaneRepairOutcome { repaired_snapshot, applied_actions, unresolved_issues }. A repair is considered safe when it doesn’t change the user’s intent — normalizing 6:4 to 3:2 is safe; deleting a root is not.

A worked example — splitting a tree

split_right.rs
use ftui_layout::pane::{ PaneOperation, PaneOperationJournaler, PanePlacement, PaneSplitRatio, PaneTree, SplitAxis, }; let mut journaler = PaneOperationJournaler::new( PaneTree::singleton("editor-1"), ); journaler.apply(PaneOperation::SplitLeaf { target: journaler.tree().root(), axis: SplitAxis::Vertical, // vertical split line → left/right ratio: PaneSplitRatio::new(3, 2).unwrap(), placement: PanePlacement::After, new_leaf: ftui_layout::pane::PaneLeaf::new("terminal-1"), })?; assert_eq!(journaler.tree().node_count(), 3); // root split + 2 leaves let hash = journaler.tree().state_hash();

See operations and timeline for the full mutation story.

Pitfalls

Don’t mutate snapshots by hand. Editing Vec<PaneNodeRecord> directly is unsafe in the design sense — you can produce dangling parent pointers, duplicate IDs, or stale next_id. Always go through PaneOperation + the journaler, or through repair_safe().

SplitAxis naming confuses people. Horizontal means the split line is horizontal, so children stack vertically (top/bottom). If you want a left/right split, use Vertical. The tmux convention disagrees with some window managers; check before you split.

Collapsible nodes need render-layer cooperation. Setting collapsible = true on a constraint only tells the planner it may hide the node. Whether it does depends on the selected intelligence mode and current layout pressure. See intelligence modes.

Where to go next