Skip to main content

gpui_kit/layout/
tree.rs

1//! A tree of splits, and the frame that draws one.
2//!
3//! [`SplitPane`] is two panes and a divider. [`SplitTree`] is however many of
4//! those the caller nests, arranged as a [`SplitLayout`] the caller owns. The
5//! tree is data, not view state: the library draws whatever tree it is handed
6//! and reports every change the typist asked for as a [`SplitChange`], so a
7//! host that refuses a resize keeps the arrangement that still holds.
8//!
9//! # Persisting a layout
10//!
11//! This crate takes no serialization dependency, so [`SplitLayout`] carries no
12//! derived `Serialize`. Instead it converts losslessly to and from a flat
13//! [`Vec<SplitRecord>`] of plain fields, which a host serializes with whatever
14//! format it already uses:
15//!
16//! ```
17//! # use gpui_kit::layout::{SplitLayout, SplitPaneSpec};
18//! let layout = SplitLayout::horizontal(
19//!     "workspace",
20//!     0.3,
21//!     SplitLayout::leaf(SplitPaneSpec::new("files").min(180.0)),
22//!     SplitLayout::pane("editor"),
23//! );
24//! let records = layout.to_records();
25//! // ... the host writes `records` out field by field, and reads them back ...
26//! assert_eq!(SplitLayout::from_records(&records).unwrap(), layout);
27//! ```
28//!
29//! # Minimums propagate
30//!
31//! A leaf states the smallest it may be drawn at. A branch's minimum along its
32//! own axis is the sum of its children's, plus the divider between them, and
33//! across the other axis it is the larger of the two. A divider high in the
34//! tree therefore stops where a leaf far below it would run out of room,
35//! rather than reporting a ratio that starves it.
36
37use std::collections::HashMap;
38use std::rc::Rc;
39
40use gpui::{
41    AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
42    Styled, Window, div, prelude::FluentBuilder, px,
43};
44use gpui_kit_semantics::{NodeSpec, Role, Semantic};
45
46use crate::foundation::{Disableable, Ident};
47use crate::layout::split::{HANDLE, SplitAxis, SplitPane, SplitSide};
48
49type ChangeHandler = Rc<dyn Fn(SplitChange, &mut Window, &mut App)>;
50
51/// One leaf of a [`SplitLayout`]: a named place the caller puts content.
52#[derive(Debug, Clone, PartialEq)]
53pub struct SplitPaneSpec {
54    id: SharedString,
55    min_width: f32,
56    min_height: f32,
57    rail: f32,
58    collapsed: bool,
59}
60
61impl SplitPaneSpec {
62    pub fn new(id: impl Into<SharedString>) -> Self {
63        Self {
64            id: id.into(),
65            min_width: 0.0,
66            min_height: 0.0,
67            rail: 0.0,
68            collapsed: false,
69        }
70    }
71
72    /// The smallest this pane may be drawn at on either axis, in pixels.
73    ///
74    /// A pane that only cares about one axis states that axis instead: a file
75    /// tree that needs 180px of width does not thereby need 180px of height,
76    /// and saying so would stop a divider it has nothing to do with.
77    pub fn min(self, min: f32) -> Self {
78        self.min_width(min).min_height(min)
79    }
80
81    pub fn min_width(mut self, min: f32) -> Self {
82        self.min_width = min.max(0.0);
83        self
84    }
85
86    pub fn min_height(mut self, min: f32) -> Self {
87        self.min_height = min.max(0.0);
88        self
89    }
90
91    /// How wide the pane is while collapsed. A rail of zero removes the pane
92    /// from the drawing entirely.
93    pub fn rail(mut self, rail: f32) -> Self {
94        self.rail = rail.max(0.0);
95        self
96    }
97
98    /// Whether the caller says this pane is collapsed. A collapsed pane is
99    /// drawn at its rail extent and its divider is not offered, because there
100    /// is nothing to drag it between.
101    pub fn collapsed(mut self, collapsed: bool) -> Self {
102        self.collapsed = collapsed;
103        self
104    }
105
106    pub fn id(&self) -> &SharedString {
107        &self.id
108    }
109
110    /// The smallest this pane may be drawn at along `axis`, in pixels.
111    pub fn min_size(&self, axis: SplitAxis) -> f32 {
112        match axis {
113            SplitAxis::Horizontal => self.min_width,
114            SplitAxis::Vertical => self.min_height,
115        }
116    }
117
118    pub fn rail_size(&self) -> f32 {
119        self.rail
120    }
121
122    pub fn is_collapsed(&self) -> bool {
123        self.collapsed
124    }
125}
126
127/// A tree of splits the caller owns.
128#[derive(Debug, Clone, PartialEq)]
129pub enum SplitLayout {
130    Pane(SplitPaneSpec),
131    Branch {
132        id: SharedString,
133        axis: SplitAxis,
134        /// How much of the branch the first child takes, from 0 to 1.
135        ratio: f32,
136        start: Box<SplitLayout>,
137        end: Box<SplitLayout>,
138    },
139}
140
141impl SplitLayout {
142    pub fn pane(id: impl Into<SharedString>) -> Self {
143        Self::Pane(SplitPaneSpec::new(id))
144    }
145
146    pub fn leaf(spec: SplitPaneSpec) -> Self {
147        Self::Pane(spec)
148    }
149
150    pub fn split(
151        id: impl Into<SharedString>,
152        axis: SplitAxis,
153        ratio: f32,
154        start: SplitLayout,
155        end: SplitLayout,
156    ) -> Self {
157        Self::Branch {
158            id: id.into(),
159            axis,
160            ratio: ratio.clamp(0.0, 1.0),
161            start: Box::new(start),
162            end: Box::new(end),
163        }
164    }
165
166    pub fn horizontal(
167        id: impl Into<SharedString>,
168        ratio: f32,
169        start: SplitLayout,
170        end: SplitLayout,
171    ) -> Self {
172        Self::split(id, SplitAxis::Horizontal, ratio, start, end)
173    }
174
175    pub fn vertical(
176        id: impl Into<SharedString>,
177        ratio: f32,
178        start: SplitLayout,
179        end: SplitLayout,
180    ) -> Self {
181        Self::split(id, SplitAxis::Vertical, ratio, start, end)
182    }
183
184    pub fn id(&self) -> &SharedString {
185        match self {
186            Self::Pane(spec) => &spec.id,
187            Self::Branch { id, .. } => id,
188        }
189    }
190
191    pub fn is_pane(&self) -> bool {
192        matches!(self, Self::Pane(_))
193    }
194
195    /// Every leaf, in the order it is drawn.
196    pub fn panes(&self) -> Vec<&SplitPaneSpec> {
197        let mut found = Vec::new();
198        self.walk(&mut |node| {
199            if let Self::Pane(spec) = node {
200                found.push(spec);
201            }
202        });
203        found
204    }
205
206    /// The subtree named `id`, wherever it sits.
207    pub fn find(&self, id: &str) -> Option<&SplitLayout> {
208        if self.id().as_ref() == id {
209            return Some(self);
210        }
211        match self {
212            Self::Pane(_) => None,
213            Self::Branch { start, end, .. } => start.find(id).or_else(|| end.find(id)),
214        }
215    }
216
217    fn walk<'a>(&'a self, visit: &mut impl FnMut(&'a SplitLayout)) {
218        visit(self);
219        if let Self::Branch { start, end, .. } = self {
220            start.walk(visit);
221            end.walk(visit);
222        }
223    }
224
225    /// The extent this subtree is collapsed to, when it is a collapsed leaf.
226    fn rail(&self) -> Option<f32> {
227        match self {
228            Self::Pane(spec) if spec.collapsed => Some(spec.rail),
229            _ => None,
230        }
231    }
232
233    /// Whether a branch offers a divider at all.
234    ///
235    /// A branch with a collapsed side has nothing to trade: the rail is a
236    /// fixed extent, so there is no ratio to move.
237    fn divides(&self) -> bool {
238        match self {
239            Self::Pane(_) => false,
240            Self::Branch { start, end, .. } => start.rail().is_none() && end.rail().is_none(),
241        }
242    }
243
244    /// The smallest this subtree may be drawn at along `axis`, in pixels.
245    pub fn min_extent(&self, axis: SplitAxis) -> f32 {
246        match self {
247            Self::Pane(spec) => {
248                if spec.collapsed {
249                    spec.rail
250                } else {
251                    spec.min_size(axis)
252                }
253            }
254            Self::Branch {
255                axis: branch_axis,
256                start,
257                end,
258                ..
259            } => {
260                let (first, second) = (start.min_extent(axis), end.min_extent(axis));
261                if *branch_axis == axis {
262                    first + second + if self.divides() { HANDLE } else { 0.0 }
263                } else {
264                    first.max(second)
265                }
266            }
267        }
268    }
269
270    /// The same tree with one branch's ratio replaced.
271    pub fn with_ratio(&self, split: &str, ratio: f32) -> Self {
272        self.mapped(&mut |node| match node {
273            Self::Branch {
274                id,
275                axis,
276                start,
277                end,
278                ..
279            } if id.as_ref() == split => Self::Branch {
280                id: id.clone(),
281                axis: *axis,
282                ratio: ratio.clamp(0.0, 1.0),
283                start: start.clone(),
284                end: end.clone(),
285            },
286            other => other.clone(),
287        })
288    }
289
290    /// The same tree with one leaf's collapsed flag replaced.
291    pub fn with_collapsed(&self, pane: &str, collapsed: bool) -> Self {
292        self.mapped(&mut |node| match node {
293            Self::Pane(spec) if spec.id.as_ref() == pane => {
294                Self::Pane(spec.clone().collapsed(collapsed))
295            }
296            other => other.clone(),
297        })
298    }
299
300    /// The same tree with a reported change applied.
301    ///
302    /// This is offered so a host that simply accepts every change has one call
303    /// to make. A host that judges them applies the ones it accepts itself.
304    pub fn applied(&self, change: &SplitChange) -> Self {
305        match change {
306            SplitChange::Ratio { split, ratio } => self.with_ratio(split, *ratio),
307            SplitChange::Collapsed { pane, .. } => self.with_collapsed(pane, true),
308        }
309    }
310
311    fn mapped(&self, map: &mut impl FnMut(&SplitLayout) -> SplitLayout) -> Self {
312        let replaced = map(self);
313        match replaced {
314            Self::Pane(spec) => Self::Pane(spec),
315            Self::Branch {
316                id,
317                axis,
318                ratio,
319                start,
320                end,
321            } => Self::Branch {
322                id,
323                axis,
324                ratio,
325                start: Box::new(start.mapped(map)),
326                end: Box::new(end.mapped(map)),
327            },
328        }
329    }
330
331    /// The tree flattened into plain records, parents before children and the
332    /// first child before the second.
333    pub fn to_records(&self) -> Vec<SplitRecord> {
334        let mut records = Vec::new();
335        self.record_into(None, &mut records);
336        records
337    }
338
339    fn record_into(&self, parent: Option<SharedString>, records: &mut Vec<SplitRecord>) {
340        match self {
341            Self::Pane(spec) => records.push(SplitRecord {
342                id: spec.id.clone(),
343                parent,
344                kind: SplitKind::Pane,
345                ratio: 0.0,
346                min_width: spec.min_width,
347                min_height: spec.min_height,
348                rail: spec.rail,
349                collapsed: spec.collapsed,
350            }),
351            Self::Branch {
352                id,
353                axis,
354                ratio,
355                start,
356                end,
357            } => {
358                records.push(SplitRecord {
359                    id: id.clone(),
360                    parent,
361                    kind: match axis {
362                        SplitAxis::Horizontal => SplitKind::Horizontal,
363                        SplitAxis::Vertical => SplitKind::Vertical,
364                    },
365                    ratio: *ratio,
366                    min_width: 0.0,
367                    min_height: 0.0,
368                    rail: 0.0,
369                    collapsed: false,
370                });
371                start.record_into(Some(id.clone()), records);
372                end.record_into(Some(id.clone()), records);
373            }
374        }
375    }
376
377    /// Rebuilds a tree the host wrote out with [`SplitLayout::to_records`].
378    pub fn from_records(records: &[SplitRecord]) -> Result<Self, SplitRecordError> {
379        let mut children: HashMap<&str, Vec<&SplitRecord>> = HashMap::new();
380        let mut by_id: HashMap<&str, &SplitRecord> = HashMap::new();
381        let mut roots: Vec<&SplitRecord> = Vec::new();
382
383        for record in records {
384            if by_id.insert(record.id.as_ref(), record).is_some() {
385                return Err(SplitRecordError::DuplicateId(record.id.clone()));
386            }
387            match &record.parent {
388                Some(parent) => children.entry(parent.as_ref()).or_default().push(record),
389                None => roots.push(record),
390            }
391        }
392
393        for record in records {
394            if let Some(parent) = &record.parent
395                && !by_id.contains_key(parent.as_ref())
396            {
397                return Err(SplitRecordError::MissingParent {
398                    id: record.id.clone(),
399                    parent: parent.clone(),
400                });
401            }
402        }
403
404        let root = match roots.as_slice() {
405            [] => return Err(SplitRecordError::NoRoot),
406            [root] => *root,
407            _ => {
408                return Err(SplitRecordError::ManyRoots(
409                    roots.iter().map(|record| record.id.clone()).collect(),
410                ));
411            }
412        };
413
414        // A cycle leaves its members out of the root's reachable set, so
415        // counting what was built is enough to find one without walking twice.
416        let mut built = 0;
417        let layout = build(root, &children, &mut built)?;
418        if built != records.len() {
419            return Err(SplitRecordError::Unreachable);
420        }
421        Ok(layout)
422    }
423}
424
425fn build(
426    record: &SplitRecord,
427    children: &HashMap<&str, Vec<&SplitRecord>>,
428    built: &mut usize,
429) -> Result<SplitLayout, SplitRecordError> {
430    *built += 1;
431    let own = children
432        .get(record.id.as_ref())
433        .map(Vec::as_slice)
434        .unwrap_or_default();
435    match record.kind {
436        SplitKind::Pane => {
437            if !own.is_empty() {
438                return Err(SplitRecordError::PaneWithChildren(record.id.clone()));
439            }
440            Ok(SplitLayout::Pane(
441                SplitPaneSpec::new(record.id.clone())
442                    .min_width(record.min_width)
443                    .min_height(record.min_height)
444                    .rail(record.rail)
445                    .collapsed(record.collapsed),
446            ))
447        }
448        SplitKind::Horizontal | SplitKind::Vertical => {
449            let [start, end] = own else {
450                return Err(SplitRecordError::WrongChildCount {
451                    id: record.id.clone(),
452                    found: own.len(),
453                });
454            };
455            Ok(SplitLayout::split(
456                record.id.clone(),
457                match record.kind {
458                    SplitKind::Horizontal => SplitAxis::Horizontal,
459                    _ => SplitAxis::Vertical,
460                },
461                record.ratio,
462                build(start, children, built)?,
463                build(end, children, built)?,
464            ))
465        }
466    }
467}
468
469/// What one [`SplitRecord`] describes.
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471pub enum SplitKind {
472    Pane,
473    Horizontal,
474    Vertical,
475}
476
477impl SplitKind {
478    pub fn name(self) -> &'static str {
479        match self {
480            Self::Pane => "pane",
481            Self::Horizontal => "horizontal",
482            Self::Vertical => "vertical",
483        }
484    }
485}
486
487/// One node of a [`SplitLayout`], as plain fields a host can write anywhere.
488///
489/// `ratio` is meaningful for a branch, and `min_width`, `min_height`, `rail`,
490/// and `collapsed` for a pane; the other fields are zero and ignored, so a host
491/// that stores every field back gets the same tree.
492#[derive(Debug, Clone, PartialEq)]
493pub struct SplitRecord {
494    pub id: SharedString,
495    pub parent: Option<SharedString>,
496    pub kind: SplitKind,
497    pub ratio: f32,
498    pub min_width: f32,
499    pub min_height: f32,
500    pub rail: f32,
501    pub collapsed: bool,
502}
503
504/// Why a set of records is not a tree.
505#[derive(Debug, Clone, PartialEq)]
506pub enum SplitRecordError {
507    NoRoot,
508    ManyRoots(Vec<SharedString>),
509    DuplicateId(SharedString),
510    MissingParent {
511        id: SharedString,
512        parent: SharedString,
513    },
514    /// A split needs exactly two children.
515    WrongChildCount {
516        id: SharedString,
517        found: usize,
518    },
519    PaneWithChildren(SharedString),
520    /// Records that no path from the root reaches, which means a cycle.
521    Unreachable,
522}
523
524impl std::fmt::Display for SplitRecordError {
525    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526        match self {
527            Self::NoRoot => write!(formatter, "no record without a parent"),
528            Self::ManyRoots(ids) => {
529                let names: Vec<&str> = ids.iter().map(SharedString::as_ref).collect();
530                write!(formatter, "more than one root: {}", names.join(", "))
531            }
532            Self::DuplicateId(id) => write!(formatter, "`{id}` appears more than once"),
533            Self::MissingParent { id, parent } => {
534                write!(formatter, "`{id}` names a parent `{parent}` that is absent")
535            }
536            Self::WrongChildCount { id, found } => {
537                write!(formatter, "split `{id}` has {found} children, not 2")
538            }
539            Self::PaneWithChildren(id) => write!(formatter, "pane `{id}` has children"),
540            Self::Unreachable => write!(formatter, "records the root does not reach"),
541        }
542    }
543}
544
545impl std::error::Error for SplitRecordError {}
546
547/// A change the typist asked the layout for. Nothing has been applied.
548#[derive(Debug, Clone, PartialEq)]
549pub enum SplitChange {
550    Ratio {
551        split: SharedString,
552        ratio: f32,
553    },
554    Collapsed {
555        split: SharedString,
556        side: SplitSide,
557        pane: SharedString,
558    },
559}
560
561/// Draws a [`SplitLayout`] and reports what the typist asked to change.
562#[derive(IntoElement)]
563pub struct SplitTree {
564    ident: Ident,
565    layout: SplitLayout,
566    panes: HashMap<SharedString, AnyElement>,
567    disabled: bool,
568    on_change: Option<ChangeHandler>,
569}
570
571impl std::fmt::Debug for SplitTree {
572    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
573        formatter
574            .debug_struct("SplitTree")
575            .field("ident", &self.ident)
576            .field("panes", &self.panes.len())
577            .field("disabled", &self.disabled)
578            .field("has_handler", &self.on_change.is_some())
579            .finish()
580    }
581}
582
583impl SplitTree {
584    pub fn new(ident: impl Into<Ident>) -> Self {
585        Self {
586            ident: ident.into(),
587            layout: SplitLayout::pane("pane"),
588            panes: HashMap::new(),
589            disabled: false,
590            on_change: None,
591        }
592    }
593
594    pub fn layout(mut self, layout: SplitLayout) -> Self {
595        self.layout = layout;
596        self
597    }
598
599    /// What goes in the leaf named `id`. A leaf nothing is given draws empty.
600    pub fn pane(mut self, id: impl Into<SharedString>, content: impl IntoElement) -> Self {
601        self.panes.insert(id.into(), content.into_any_element());
602        self
603    }
604
605    pub fn on_change(
606        mut self,
607        handler: impl Fn(SplitChange, &mut Window, &mut App) + 'static,
608    ) -> Self {
609        self.on_change = Some(Rc::new(handler));
610        self
611    }
612
613    fn node(
614        &self,
615        layout: &SplitLayout,
616        panes: &mut HashMap<SharedString, AnyElement>,
617        cx: &mut App,
618    ) -> AnyElement {
619        match layout {
620            SplitLayout::Pane(spec) => {
621                let ident = self.ident.child(spec.id.as_ref());
622                div()
623                    .flex()
624                    .flex_col()
625                    .min_w(px(0.0))
626                    .min_h(px(0.0))
627                    .size_full()
628                    .overflow_hidden()
629                    .children(panes.remove(&spec.id))
630                    .semantic_in(
631                        cx,
632                        NodeSpec::new(ident.semantic_id(), Role::Group)
633                            .parent(self.ident.semantic_id())
634                            .expanded(!spec.collapsed),
635                    )
636                    .into_any_element()
637            }
638            SplitLayout::Branch {
639                id,
640                axis,
641                ratio,
642                start,
643                end,
644            } => {
645                let horizontal = *axis == SplitAxis::Horizontal;
646                let first = self.node(start, panes, cx);
647                let second = self.node(end, panes, cx);
648
649                // A branch with a collapsed side has no ratio to move, so it
650                // is a fixed rail beside a pane rather than a split.
651                if let Some(rail) = start.rail().or_else(|| end.rail()) {
652                    let start_rail = start.rail().is_some();
653                    let fixed = |element: AnyElement| {
654                        div()
655                            .flex_none()
656                            .when(horizontal, |frame| frame.w(px(rail)).h_full())
657                            .when(!horizontal, |frame| frame.h(px(rail)).w_full())
658                            .overflow_hidden()
659                            .child(element)
660                    };
661                    let flexible = |element: AnyElement| {
662                        div()
663                            .flex_1()
664                            .min_w(px(0.0))
665                            .min_h(px(0.0))
666                            .overflow_hidden()
667                            .child(element)
668                    };
669                    let (first, second) = if start_rail {
670                        (fixed(first), flexible(second))
671                    } else {
672                        (flexible(first), fixed(second))
673                    };
674                    return div()
675                        .flex()
676                        .when(horizontal, |frame| frame.flex_row())
677                        .when(!horizontal, |frame| frame.flex_col())
678                        .items_stretch()
679                        .size_full()
680                        .overflow_hidden()
681                        .child(first)
682                        .child(second)
683                        .into_any_element();
684                }
685
686                let split_id = id.clone();
687                let collapsible = start.is_pane() || end.is_pane();
688                let start_pane = start.id().clone();
689                let end_pane = end.id().clone();
690                let start_is_pane = start.is_pane();
691                let end_is_pane = end.is_pane();
692
693                let mut split = SplitPane::new(self.ident.child(id.as_ref()))
694                    .axis(*axis)
695                    .ratio(*ratio)
696                    .min_sizes(start.min_extent(*axis), end.min_extent(*axis))
697                    .collapsible(collapsible)
698                    .disabled(self.disabled)
699                    .start(first)
700                    .end(second);
701
702                if let Some(handler) = self.on_change.clone() {
703                    let resized = split_id.clone();
704                    let reported = Rc::clone(&handler);
705                    split = split.on_resize(move |ratio, window, cx| {
706                        reported(
707                            SplitChange::Ratio {
708                                split: resized.clone(),
709                                ratio,
710                            },
711                            window,
712                            cx,
713                        );
714                    });
715                    split = split.on_collapse(move |side, window, cx| {
716                        // Only a leaf can be collapsed; a whole subtree has no
717                        // single identity the host could hide.
718                        let pane = match side {
719                            SplitSide::Start if start_is_pane => start_pane.clone(),
720                            SplitSide::End if end_is_pane => end_pane.clone(),
721                            _ => return,
722                        };
723                        handler(
724                            SplitChange::Collapsed {
725                                split: split_id.clone(),
726                                side,
727                                pane,
728                            },
729                            window,
730                            cx,
731                        );
732                    });
733                }
734
735                split.into_any_element()
736            }
737        }
738    }
739}
740
741impl Disableable for SplitTree {
742    /// Freezes every divider in the tree. A frozen tree installs no handler.
743    fn disabled(mut self, disabled: bool) -> Self {
744        self.disabled = disabled;
745        self
746    }
747}
748
749impl RenderOnce for SplitTree {
750    fn render(mut self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
751        let layout = self.layout.clone();
752        let mut panes = std::mem::take(&mut self.panes);
753        let body = self.node(&layout, &mut panes, cx);
754        div()
755            .id(self.ident.element_id())
756            .size_full()
757            .overflow_hidden()
758            .child(body)
759            .semantic_in(
760                cx,
761                NodeSpec::new(self.ident.semantic_id(), Role::Group)
762                    .value(layout.panes().len().to_string()),
763            )
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    fn workspace() -> SplitLayout {
772        SplitLayout::vertical(
773            "root",
774            0.75,
775            SplitLayout::horizontal(
776                "body",
777                0.25,
778                SplitLayout::leaf(SplitPaneSpec::new("files").min_width(180.0).rail(40.0)),
779                SplitLayout::leaf(SplitPaneSpec::new("editor").min_width(320.0)),
780            ),
781            SplitLayout::leaf(SplitPaneSpec::new("terminal").min_height(120.0)),
782        )
783    }
784
785    #[test]
786    fn a_tree_round_trips_through_plain_records() {
787        let layout = workspace();
788        let records = layout.to_records();
789        assert_eq!(records.len(), 5);
790        assert_eq!(records[0].parent, None);
791        assert_eq!(SplitLayout::from_records(&records), Ok(layout.clone()));
792        assert_eq!(
793            SplitLayout::from_records(&records)
794                .expect("the records are a tree")
795                .to_records(),
796            records,
797            "the conversion loses nothing in either direction"
798        );
799    }
800
801    #[test]
802    fn records_that_are_not_a_tree_say_why() {
803        assert_eq!(
804            SplitLayout::from_records(&[]),
805            Err(SplitRecordError::NoRoot)
806        );
807
808        let mut records = workspace().to_records();
809        records.retain(|record| record.id.as_ref() != "terminal");
810        assert_eq!(
811            SplitLayout::from_records(&records),
812            Err(SplitRecordError::WrongChildCount {
813                id: "root".into(),
814                found: 1
815            })
816        );
817
818        let mut orphan = workspace().to_records();
819        orphan[4].parent = Some("nowhere".into());
820        assert!(matches!(
821            SplitLayout::from_records(&orphan),
822            Err(SplitRecordError::MissingParent { .. })
823        ));
824    }
825
826    #[test]
827    fn a_minimum_is_the_sum_along_the_axis_and_the_larger_across_it() {
828        let layout = workspace();
829        let body = layout.find("body").expect("body is in the tree");
830        // Side by side, the two widths add, and so does the divider.
831        assert_eq!(
832            body.min_extent(SplitAxis::Horizontal),
833            180.0 + 320.0 + HANDLE
834        );
835        // Across the split, the taller of the two children decides, and
836        // neither of them states a height.
837        assert_eq!(body.min_extent(SplitAxis::Vertical), 0.0);
838        // The root stacks the body over the terminal, so their heights add.
839        assert_eq!(layout.min_extent(SplitAxis::Vertical), 120.0 + HANDLE);
840        assert_eq!(
841            layout.min_extent(SplitAxis::Horizontal),
842            180.0 + 320.0 + HANDLE,
843            "the widest row decides the whole tree's width"
844        );
845    }
846
847    #[test]
848    fn a_collapsed_leaf_is_worth_its_rail_and_removes_the_divider() {
849        let collapsed = workspace().with_collapsed("files", true);
850        let body = collapsed.find("body").expect("body is in the tree");
851        assert_eq!(body.min_extent(SplitAxis::Horizontal), 40.0 + 320.0);
852    }
853
854    #[test]
855    fn applying_a_reported_change_produces_the_tree_the_host_would_store() {
856        let layout = workspace();
857        let moved = layout.applied(&SplitChange::Ratio {
858            split: "body".into(),
859            ratio: 0.4,
860        });
861        let SplitLayout::Branch { ratio, .. } = moved.find("body").expect("body is in the tree")
862        else {
863            panic!("body is a split");
864        };
865        assert_eq!(*ratio, 0.4);
866        assert_ne!(moved, layout, "the caller's tree is untouched");
867
868        let hidden = layout.applied(&SplitChange::Collapsed {
869            split: "body".into(),
870            side: SplitSide::Start,
871            pane: "files".into(),
872        });
873        assert!(hidden.panes()[0].is_collapsed());
874    }
875
876    #[test]
877    fn every_leaf_is_reachable_by_name() {
878        let layout = workspace();
879        let names: Vec<&str> = layout
880            .panes()
881            .iter()
882            .map(|spec| spec.id().as_ref())
883            .collect();
884        assert_eq!(names, vec!["files", "editor", "terminal"]);
885        assert!(layout.find("editor").is_some());
886        assert!(layout.find("nothing").is_none());
887    }
888}