Skip to main content

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}