tear_types/plan.rs
1//! The latent layout *plan* — what a session definition stores, before
2//! it is instantiated into live panes.
3//!
4//! A [`crate::SessionDefinition`] (in praça) needs to describe a layout
5//! WITHOUT naming any live [`PaneId`]: a stored definition that
6//! referenced a runtime pane id would be nonsense the moment the daemon
7//! restarts and mints fresh ids. So the plan is layout-as-data over
8//! [`PaneSlot`]s — stable, definition-local keys — and the
9//! [`LayoutPlan::realize`] morphism turns a plan into a live
10//! [`LayoutNode`] by minting one [`PaneId`] per slot. After `realize`,
11//! the whole shipped layout algebra (`compute_rects`, `neighbor`,
12//! `split_leaf`, …) applies unchanged — the plan reuses, never
13//! duplicates, the live tree.
14//!
15//! This is the typed fix for the pressure-test's illegal state #3: a
16//! definition that carried *no* layout/panes/commands, so there was
17//! nothing to instantiate. Here the layout and the per-pane spawn specs
18//! are first-class, and a plan that references a live id is
19//! **unrepresentable** — [`LayoutPlan`]'s leaves hold [`PaneSlot`], not
20//! [`PaneId`].
21
22use std::collections::BTreeMap;
23
24use serde::{Deserialize, Serialize};
25
26use crate::{
27 direction::SplitOrientation,
28 id::PaneId,
29 layout::{LayoutNode, SplitRatio},
30 pane::InputPolicy,
31};
32
33/// A stable, definition-local pane slot key. Assigned at authoring time
34/// (a small index), it identifies a pane *within a definition* — never a
35/// live pane. The newtype means a slot can never be passed where a live
36/// [`PaneId`] is expected (and vice-versa): the two id spaces don't mix.
37#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
38#[serde(transparent)]
39pub struct PaneSlot(pub u32);
40
41/// What to spawn in one slot when a definition is instantiated. This is
42/// the per-pane data a stored definition must carry so a session can be
43/// re-created (or re-instantiated after a restart) faithfully. Every
44/// field mirrors the corresponding [`crate::TearPane`] field so the
45/// `instantiate` interpreter maps `SpawnSpec` → `TearPane` directly.
46#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
47pub struct SpawnSpec {
48 /// The slot this spec fills — the join key to the [`LayoutPlan`].
49 pub slot: PaneSlot,
50 /// Shell/command to run (e.g. `"/run/current-system/sw/bin/zsh"`).
51 pub shell: String,
52 /// Arguments after the shell command.
53 #[serde(default)]
54 pub args: Vec<String>,
55 /// Working directory at spawn; `None` inherits from the session.
56 #[serde(default)]
57 pub cwd: Option<String>,
58 /// Per-pane environment overrides — same `Vec<(String, String)>`
59 /// shape as [`crate::TearPane::env`].
60 #[serde(default)]
61 pub env: Vec<(String, String)>,
62 /// Initial title.
63 #[serde(default)]
64 pub title: String,
65 /// Input acceptance policy for the spawned pane.
66 #[serde(default)]
67 pub input_policy: InputPolicy,
68}
69
70impl SpawnSpec {
71 /// A bare shell in a slot — the common single-pane case.
72 #[must_use]
73 pub fn shell(slot: PaneSlot, shell: impl Into<String>) -> Self {
74 Self {
75 slot,
76 shell: shell.into(),
77 args: Vec::new(),
78 cwd: None,
79 env: Vec::new(),
80 title: String::new(),
81 input_policy: InputPolicy::default(),
82 }
83 }
84}
85
86/// The latent layout: structurally the binary-tree twin of
87/// [`LayoutNode`], but its leaves hold [`PaneSlot`] (a definition-local
88/// key) instead of a live [`PaneId`]. A stored definition therefore
89/// references nothing runtime — the design's deliberate choice over
90/// generifying the shipped `LayoutNode<L>` (which would churn its whole
91/// proptest suite + every call site). [`LayoutPlan::realize`] bridges the
92/// two id spaces.
93#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
94#[serde(tag = "kind", rename_all = "lowercase")]
95pub enum LayoutPlan {
96 /// One slot filling its box.
97 Leaf { slot: PaneSlot },
98 /// A split between side `a` (top/left) and side `b` (bottom/right);
99 /// `ratio` is side `a`'s fraction, same convention as [`LayoutNode`].
100 Split {
101 orientation: SplitOrientation,
102 /// Same refinement as [`LayoutNode`]'s — and it matters MORE here,
103 /// because a plan is **persisted** (praça writes definitions to
104 /// `praca.json`), so an out-of-range or `NaN` ratio would survive a
105 /// restart and be re-instantiated every time.
106 ratio: SplitRatio,
107 a: Box<LayoutPlan>,
108 b: Box<LayoutPlan>,
109 },
110}
111
112/// The latent mirror of a [`crate::TearWindow`]: a named window with a
113/// layout plan and the slot that should be focused after instantiation.
114/// Stored inside a session definition; carries no runtime ids — a
115/// definition can hold several of these (multi-window sessions).
116#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
117pub struct WindowPlan {
118 /// Window name (`work`, `logs`, …).
119 pub name: String,
120 /// The pane layout, over slots.
121 pub layout: LayoutPlan,
122 /// The slot that becomes `active_pane` after instantiation.
123 pub active_slot: PaneSlot,
124}
125
126impl WindowPlan {
127 /// A single-pane window over one slot, that slot active.
128 #[must_use]
129 pub fn single(name: impl Into<String>, slot: PaneSlot) -> Self {
130 Self {
131 name: name.into(),
132 layout: LayoutPlan::leaf(slot),
133 active_slot: slot,
134 }
135 }
136}
137
138/// Why a [`LayoutPlan`] failed [`LayoutPlan::validate`]. Mirrors the
139/// shipped [`crate::LayoutError`] discipline for the plan's id space
140/// (slots, not panes). A plan that fails to validate never reaches
141/// `realize`, so a malformed definition can't produce a broken live tree.
142// No `Eq` — `BadRatio(f32)` carries a float.
143#[derive(Clone, Debug, PartialEq)]
144pub enum PlanError {
145 /// The same [`PaneSlot`] appears in two leaves — a slot fills exactly
146 /// one box, so aliasing would map two panes onto one position.
147 DuplicateSlot(PaneSlot),
148 /// A split's ratio is not in the open interval `(0.0, 1.0)`.
149 BadRatio(f32),
150}
151
152impl LayoutPlan {
153 /// A single-slot plan.
154 #[must_use]
155 pub fn leaf(slot: PaneSlot) -> Self {
156 Self::Leaf { slot }
157 }
158
159 /// A balanced (`ratio = 0.5`) split.
160 #[must_use]
161 pub fn split(orientation: SplitOrientation, a: LayoutPlan, b: LayoutPlan) -> Self {
162 Self::Split {
163 orientation,
164 ratio: SplitRatio::BALANCED,
165 a: Box::new(a),
166 b: Box::new(b),
167 }
168 }
169
170 /// Every slot in the plan, left-to-right (then top-to-bottom).
171 #[must_use]
172 pub fn slots(&self) -> Vec<PaneSlot> {
173 let mut out = Vec::new();
174 self.collect(&mut out);
175 out
176 }
177
178 fn collect(&self, out: &mut Vec<PaneSlot>) {
179 match self {
180 Self::Leaf { slot } => out.push(*slot),
181 Self::Split { a, b, .. } => {
182 a.collect(out);
183 b.collect(out);
184 }
185 }
186 }
187
188 /// Number of slots (= number of panes the plan will spawn).
189 #[must_use]
190 pub fn slot_count(&self) -> usize {
191 match self {
192 Self::Leaf { .. } => 1,
193 Self::Split { a, b, .. } => a.slot_count() + b.slot_count(),
194 }
195 }
196
197 /// The first slot in left-to-right (top-to-bottom) order — the slot
198 /// the session's initial pane (from `new_session`) holds when the plan
199 /// is instantiated. The interpreter spawns this slot's shell first,
200 /// then splits outward to build the rest of the tree.
201 #[must_use]
202 pub fn leftmost_slot(&self) -> PaneSlot {
203 match self {
204 Self::Leaf { slot } => *slot,
205 Self::Split { a, .. } => a.leftmost_slot(),
206 }
207 }
208
209 /// Structural validation: no duplicate slot, every ratio in
210 /// `(0.0, 1.0)`. A plan that validates is safe to `realize`.
211 pub fn validate(&self) -> Result<(), PlanError> {
212 let mut seen = Vec::new();
213 self.validate_into(&mut seen)
214 }
215
216 fn validate_into(&self, seen: &mut Vec<PaneSlot>) -> Result<(), PlanError> {
217 match self {
218 Self::Leaf { slot } => {
219 if seen.contains(slot) {
220 return Err(PlanError::DuplicateSlot(*slot));
221 }
222 seen.push(*slot);
223 Ok(())
224 }
225 Self::Split { ratio, a, b, .. } => {
226 // Defence in depth — `SplitRatio` makes this unreachable
227 // from any constructible plan. See `LayoutNode::validate_into`.
228 let r = ratio.get();
229 if !(r > 0.0 && r < 1.0) {
230 return Err(PlanError::BadRatio(r));
231 }
232 a.validate_into(seen)?;
233 b.validate_into(seen)
234 }
235 }
236 }
237
238 /// Turn a plan into a live [`LayoutNode`] by minting one [`PaneId`]
239 /// per slot. `mint` is called once per leaf in traversal order — the
240 /// instantiation interpreter passes a closure that spawns a PTY and
241 /// returns its id. After this, the entire shipped layout algebra
242 /// (`compute_rects`, `neighbor`, `split_leaf`, `validate`, …) applies
243 /// to the result — the plan reuses the live tree, never duplicates it.
244 pub fn realize(&self, mint: &mut impl FnMut(PaneSlot) -> PaneId) -> LayoutNode {
245 match self {
246 Self::Leaf { slot } => LayoutNode::leaf(mint(*slot)),
247 Self::Split {
248 orientation,
249 ratio,
250 a,
251 b,
252 } => LayoutNode::Split {
253 orientation: *orientation,
254 ratio: *ratio,
255 a: Box::new(a.realize(mint)),
256 b: Box::new(b.realize(mint)),
257 },
258 }
259 }
260
261 /// Harvest a *live* [`LayoutNode`] back into a plan — the inverse of
262 /// [`realize`](Self::realize). Assigns `PaneSlot(0)`, `(1)`, … to the
263 /// tree's leaves in traversal order, producing a structurally-identical
264 /// plan (same shape + ratios) plus the `slot → PaneId` map recording
265 /// which live pane each slot stood for. This is how a running layout is
266 /// captured as a reusable preset: `from_node` dehydrates the live tree,
267 /// the plan + spawn specs become a [`crate::SessionDefinition`].
268 ///
269 /// Round-trips with `realize` both ways: `from_node(realize(plan))`
270 /// returns `plan` (when its slots are already `0..n` in traversal
271 /// order), and `realize(from_node(node).0, |s| map[&s])` returns `node`.
272 #[must_use]
273 pub fn from_node(node: &LayoutNode) -> (LayoutPlan, BTreeMap<PaneSlot, PaneId>) {
274 let mut next = 0u32;
275 let mut map = BTreeMap::new();
276 let plan = Self::from_node_into(node, &mut next, &mut map);
277 (plan, map)
278 }
279
280 /// The running-counter harvest behind [`from_node`](Self::from_node):
281 /// assign slots starting at `*next`, advancing it, and record each
282 /// `slot → PaneId` in `map`. Use this to harvest *several* trees into
283 /// one global slot space — e.g. a multi-window session, where each
284 /// window's panes must get distinct slots (per-window `from_node`
285 /// would restart at 0 and collide).
286 pub fn from_node_into(
287 node: &LayoutNode,
288 next: &mut u32,
289 map: &mut BTreeMap<PaneSlot, PaneId>,
290 ) -> LayoutPlan {
291 match node {
292 LayoutNode::Leaf { pane } => {
293 let slot = PaneSlot(*next);
294 *next += 1;
295 map.insert(slot, *pane);
296 LayoutPlan::Leaf { slot }
297 }
298 LayoutNode::Split {
299 orientation,
300 ratio,
301 a,
302 b,
303 } => LayoutPlan::Split {
304 orientation: *orientation,
305 ratio: *ratio,
306 a: Box::new(Self::from_node_into(a, next, map)),
307 b: Box::new(Self::from_node_into(b, next, map)),
308 },
309 }
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::geometry::Rect;
317
318 #[test]
319 fn slots_traverse_left_to_right() {
320 let p = LayoutPlan::split(
321 SplitOrientation::Horizontal,
322 LayoutPlan::leaf(PaneSlot(0)),
323 LayoutPlan::split(
324 SplitOrientation::Vertical,
325 LayoutPlan::leaf(PaneSlot(1)),
326 LayoutPlan::leaf(PaneSlot(2)),
327 ),
328 );
329 assert_eq!(p.slots(), vec![PaneSlot(0), PaneSlot(1), PaneSlot(2)]);
330 assert_eq!(p.slot_count(), 3);
331 }
332
333 #[test]
334 fn validate_rejects_duplicate_slot() {
335 let p = LayoutPlan::split(
336 SplitOrientation::Vertical,
337 LayoutPlan::leaf(PaneSlot(7)),
338 LayoutPlan::leaf(PaneSlot(7)),
339 );
340 assert_eq!(p.validate(), Err(PlanError::DuplicateSlot(PaneSlot(7))));
341 }
342
343 /// Was: construct `ratio: 1.0` and assert `validate()` rejects it.
344 /// That plan is now unconstructible — `SplitRatio` refines on the way
345 /// in. Matters more here than for a live tree, because a plan is
346 /// PERSISTED: a bad ratio used to survive a restart and be
347 /// re-instantiated on every attach.
348 #[test]
349 fn a_degenerate_plan_ratio_has_no_representation() {
350 let p = LayoutPlan::Split {
351 orientation: SplitOrientation::Vertical,
352 ratio: SplitRatio::new(1.0),
353 a: Box::new(LayoutPlan::leaf(PaneSlot(0))),
354 b: Box::new(LayoutPlan::leaf(PaneSlot(1))),
355 };
356 p.validate().expect("a refined ratio always validates");
357 }
358
359 #[test]
360 fn realize_maps_slots_to_minted_panes_and_the_live_algebra_applies() {
361 // The whole point: a plan realizes into a real LayoutNode that the
362 // shipped algebra renders. Mint slot N -> PaneId(100+N).
363 let plan = LayoutPlan::split(
364 SplitOrientation::Vertical,
365 LayoutPlan::leaf(PaneSlot(0)),
366 LayoutPlan::leaf(PaneSlot(1)),
367 );
368 let mut mint = |s: PaneSlot| PaneId(100 + u64::from(s.0));
369 let live = plan.realize(&mut mint);
370 // It IS a LayoutNode — the shipped algebra works on it.
371 assert_eq!(live.panes(), vec![PaneId(100), PaneId(101)]);
372 live.validate().unwrap();
373 let rects = live.compute_rects(Rect::sized(80, 24));
374 let total: u32 = rects.iter().map(|(_, r)| r.area()).sum();
375 assert_eq!(total, 80 * 24); // tiles exactly
376 }
377
378 #[test]
379 fn realize_preserves_ratio_and_orientation() {
380 let plan = LayoutPlan::Split {
381 orientation: SplitOrientation::Horizontal,
382 ratio: SplitRatio::new(0.25),
383 a: Box::new(LayoutPlan::leaf(PaneSlot(0))),
384 b: Box::new(LayoutPlan::leaf(PaneSlot(1))),
385 };
386 let mut mint = |s: PaneSlot| PaneId(u64::from(s.0));
387 match plan.realize(&mut mint) {
388 LayoutNode::Split { orientation, ratio, .. } => {
389 assert_eq!(orientation, SplitOrientation::Horizontal);
390 assert!((ratio.get() - 0.25).abs() < f32::EPSILON);
391 }
392 LayoutNode::Leaf { .. } => panic!("expected a split"),
393 }
394 }
395
396 #[test]
397 fn spawn_spec_serde_round_trips() {
398 let s = SpawnSpec::shell(PaneSlot(3), "/bin/zsh");
399 let json = serde_json::to_string(&s).unwrap();
400 let back: SpawnSpec = serde_json::from_str(&json).unwrap();
401 assert_eq!(s, back);
402 }
403
404 fn sample_plan() -> LayoutPlan {
405 // slot 0 | (slot 1 / slot 2), asymmetric ratios.
406 LayoutPlan::Split {
407 orientation: SplitOrientation::Vertical,
408 ratio: SplitRatio::new(0.3),
409 a: Box::new(LayoutPlan::leaf(PaneSlot(0))),
410 b: Box::new(LayoutPlan::Split {
411 orientation: SplitOrientation::Horizontal,
412 ratio: SplitRatio::new(0.7),
413 a: Box::new(LayoutPlan::leaf(PaneSlot(1))),
414 b: Box::new(LayoutPlan::leaf(PaneSlot(2))),
415 }),
416 }
417 }
418
419 #[test]
420 fn from_node_dehydrates_a_live_tree_with_canonical_slots() {
421 // realize the plan, then harvest it back. Slots come out 0,1,2 in
422 // traversal order; the map records the minted panes.
423 let plan = sample_plan();
424 let mut mint = |s: PaneSlot| PaneId(100 + u64::from(s.0));
425 let node = plan.realize(&mut mint);
426 let (plan2, map) = LayoutPlan::from_node(&node);
427 // Structure + ratios round-trip; slots are already canonical here.
428 assert_eq!(plan2, plan);
429 assert_eq!(map[&PaneSlot(0)], PaneId(100));
430 assert_eq!(map[&PaneSlot(1)], PaneId(101));
431 assert_eq!(map[&PaneSlot(2)], PaneId(102));
432 }
433
434 #[test]
435 fn realize_of_from_node_reconstructs_the_original_live_tree() {
436 // The other direction: harvest an arbitrary live tree, then realize
437 // the plan back through the recorded map — exactly the input tree.
438 let node = LayoutNode::Split {
439 orientation: SplitOrientation::Horizontal,
440 ratio: SplitRatio::new(0.4),
441 a: Box::new(LayoutNode::leaf(PaneId(7))),
442 b: Box::new(LayoutNode::Split {
443 orientation: SplitOrientation::Vertical,
444 ratio: SplitRatio::new(0.6),
445 a: Box::new(LayoutNode::leaf(PaneId(9))),
446 b: Box::new(LayoutNode::leaf(PaneId(2))),
447 }),
448 };
449 let (plan, map) = LayoutPlan::from_node(&node);
450 let mut mint = |s: PaneSlot| map[&s];
451 assert_eq!(plan.realize(&mut mint), node);
452 // The plan carries no live id — it references only slots.
453 assert_eq!(plan.slots(), vec![PaneSlot(0), PaneSlot(1), PaneSlot(2)]);
454 }
455
456 #[test]
457 fn from_node_of_a_single_pane_is_one_slot() {
458 let (plan, map) = LayoutPlan::from_node(&LayoutNode::leaf(PaneId(42)));
459 assert_eq!(plan, LayoutPlan::leaf(PaneSlot(0)));
460 assert_eq!(map[&PaneSlot(0)], PaneId(42));
461 }
462}