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
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: PaneInvariantSeverity—WarningorError.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
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.