Skip to main content

gpui_base/dock/
state_convert.rs

1use gpui::{Axis, Pixels};
2
3use super::layout::{NodeKind, PaneNode, PaneRef, PaneTree, PanelId, RootKind, TilePanel};
4use super::state::{PanelInfo, PanelState, TileMeta};
5
6/// The names written to persisted layouts. These are contract, not type names:
7/// they must keep their values even if the Rust types are renamed.
8pub(crate) const STACK_PANEL_NAME: &str = "StackPanel";
9pub(crate) const TAB_PANEL_NAME: &str = "TabPanel";
10pub(crate) const TILES_PANEL_NAME: &str = "Tiles";
11
12/// How the layout tree reads properties of panels it only knows by id.
13///
14/// Keeping this behind a trait is what lets the whole layout algebra be tested
15/// without an `App`.
16pub trait PanelSource {
17    fn panel_name(&self, id: PanelId) -> &'static str;
18    fn is_visible(&self, id: PanelId) -> bool;
19    fn dump(&self, id: PanelId) -> PanelState;
20}
21
22impl PaneTree {
23    pub fn to_state(&self, source: &dyn PanelSource) -> PanelState {
24        node_to_state(self.persisted_root(), source)
25    }
26
27    /// The node the persisted layout starts at.
28    ///
29    /// A `RootKind::Split` tree keeps a `Split` root even around a lone tiles
30    /// canvas, so an edit always has a container to land in. That wrapper is a
31    /// tree invariant, not part of the persisted schema: the dock before the
32    /// tree rewrite wrote a tiles center as a bare `Tiles`, and its
33    /// `StackPanel::insert_panel` asserts every split child is a `TabPanel` or
34    /// `StackPanel`, so writing the wrapper crashes that reader on load.
35    /// `from_state` puts the wrapper back, so the round trip still holds.
36    fn persisted_root(&self) -> &PaneNode {
37        let root = self.root();
38        match root.kind() {
39            PaneRef::Split {
40                children: [only], ..
41            } if self.root_kind() == RootKind::Split
42                && matches!(only.kind(), PaneRef::Tiles { .. }) =>
43            {
44                only
45            }
46            _ => root,
47        }
48    }
49}
50
51fn node_to_state(node: &PaneNode, source: &dyn PanelSource) -> PanelState {
52    match node.kind() {
53        PaneRef::Split {
54            axis,
55            children,
56            sizes,
57        } => PanelState {
58            panel_name: STACK_PANEL_NAME.to_string(),
59            children: children
60                .iter()
61                .map(|child| node_to_state(child, source))
62                .collect(),
63            // `None` is a representation this rewrite introduces, not
64            // something the old writer ever produced: the schema's `sizes`
65            // field is `Vec<Pixels>`, with no slot for "unconstrained". `0.0`
66            // is the sentinel this writer chooses for that case, and the
67            // corresponding reader maps a `0.0` it loads back to `None`.
68            //
69            // That makes a `None` slot safe to persist only transiently: a
70            // caller building a tree meant to be written out must resolve
71            // every slot to concrete pixels first. An older build reading a
72            // persisted `0.0` back has no notion of the sentinel and
73            // constructs a real, zero-pixel-wide panel from it.
74            info: PanelInfo::stack(
75                sizes.iter().map(|size| size.unwrap_or_default()).collect(),
76                axis,
77            ),
78        },
79        PaneRef::Tabs { panels, active_ix } => PanelState {
80            panel_name: TAB_PANEL_NAME.to_string(),
81            children: panels.iter().map(|panel| source.dump(*panel)).collect(),
82            // Unconditional, unlike the old writer which assigned this inside
83            // its loop and left an empty group looking like a bare panel.
84            info: PanelInfo::tabs(active_ix),
85        },
86        PaneRef::Tiles { panels } => PanelState {
87            panel_name: TILES_PANEL_NAME.to_string(),
88            children: panels
89                .iter()
90                .map(|tile| source.dump(tile.panel()))
91                .collect(),
92            info: PanelInfo::tiles(
93                panels
94                    .iter()
95                    .map(|tile| TileMeta {
96                        bounds: tile.bounds(),
97                        z_index: tile.z_index(),
98                    })
99                    .collect(),
100            ),
101        },
102    }
103}
104
105/// Turns a persisted leaf into a live panel id.
106///
107/// The production implementation (at the `gpui-component` layer, above this
108/// crate) consults `PanelRegistry` and falls back to an invalid-panel
109/// placeholder that retains the original `PanelState`, so a panel type this
110/// build does not know about survives a load/save round trip instead of
111/// being erased.
112pub trait PanelBuilder {
113    fn build(&mut self, state: &PanelState, info: &PanelInfo) -> PanelId;
114}
115
116impl PaneTree {
117    /// Read a persisted layout.
118    ///
119    /// Compatibility rules, all previously implicit in `PanelState::to_item`:
120    ///
121    /// - a `Tabs` whose children are themselves `Tabs` is flattened;
122    /// - a bare `Panel` leaf appearing where a container belongs is wrapped in
123    ///   a `Tabs`;
124    /// - a node named `TabPanel` carrying `PanelInfo::Panel` is read as an
125    ///   empty tab group, recovering data written by the old dump defect (an
126    ///   empty `TabPanel` never entered the loop that set its `info` to
127    ///   `Tabs`, so it kept `PanelState`'s default `Panel(Value::Null)`). The
128    ///   old reader had no such rule: it looked "TabPanel" up in the panel
129    ///   registry, found nothing, and rendered an `InvalidPanel` placeholder
130    ///   where an empty tab group belonged. This rule is a genuine fix, not a
131    ///   preserved behavior;
132    /// - a `Tiles` child without a matching meta keeps the default placement.
133    ///   The old writer's counterpart (`DockItem::tiles`) hard-asserted
134    ///   `items.len() == metas.len()` and panicked the whole load on a short
135    ///   `metas` list, so this rule is a new safety net, not a preserved
136    ///   graceful-degradation path.
137    pub fn from_state(
138        state: &PanelState,
139        root_kind: RootKind,
140        builder: &mut dyn PanelBuilder,
141    ) -> Self {
142        let mut tree = PaneTree::new(root_kind);
143        let root = build_node(&mut tree, state, builder);
144
145        let root = match (root_kind, &root) {
146            (RootKind::Split, node) if !matches!(node.kind_ref(), NodeKind::Split { .. }) => {
147                let id = tree.allocate_node_id();
148                PaneNode::new(
149                    id,
150                    NodeKind::Split {
151                        axis: Axis::Horizontal,
152                        children: vec![node.clone()],
153                        sizes: vec![None],
154                    },
155                )
156            }
157            _ => root,
158        };
159
160        tree.replace_root(root);
161        tree.normalize();
162        tree
163    }
164}
165
166fn build_node(tree: &mut PaneTree, state: &PanelState, builder: &mut dyn PanelBuilder) -> PaneNode {
167    let id = tree.allocate_node_id();
168
169    match &state.info {
170        PanelInfo::Stack { sizes, axis } => {
171            let axis = if *axis == 0 {
172                Axis::Horizontal
173            } else {
174                Axis::Vertical
175            };
176            let children: Vec<PaneNode> = state
177                .children
178                .iter()
179                .map(|child| build_node(tree, child, builder))
180                .collect();
181            let sizes = (0..children.len())
182                .map(|ix| sizes.get(ix).copied().filter(|size| *size > Pixels::ZERO))
183                .collect();
184            PaneNode::new(
185                id,
186                NodeKind::Split {
187                    axis,
188                    children,
189                    sizes,
190                },
191            )
192        }
193        PanelInfo::Tabs { active_index } => {
194            let panels = collect_tab_panels(&state.children, builder);
195            PaneNode::new(
196                id,
197                NodeKind::Tabs {
198                    panels,
199                    active_ix: *active_index,
200                },
201            )
202        }
203        PanelInfo::Tiles { metas } => {
204            let mut panels = Vec::new();
205            for (ix, child) in state.children.iter().enumerate() {
206                // Keyed by the *child* index, not by the output index: a
207                // child that expands to several tiles must not shift the
208                // metas of the children after it.
209                let meta = metas.get(ix).copied().unwrap_or_default();
210                for panel in tile_panels(child, builder) {
211                    panels.push(TilePanel::new(panel, meta.bounds).with_z_index(meta.z_index));
212                }
213            }
214            PaneNode::new(id, NodeKind::Tiles { panels })
215        }
216        PanelInfo::Panel(_) => {
217            // A container name carrying a leaf info means the writer that
218            // produced this file had the empty-group defect.
219            let panels = if state.panel_name == TAB_PANEL_NAME {
220                Vec::new()
221            } else {
222                vec![builder.build(state, &state.info)]
223            };
224            PaneNode::new(
225                id,
226                NodeKind::Tabs {
227                    panels,
228                    active_ix: 0,
229                },
230            )
231        }
232    }
233}
234
235/// The panels one persisted `Tiles` child stands for.
236///
237/// Every tile the old dock ever wrote is `TabPanel`-shaped: `DockItem::tiles`
238/// wraps each child in a `TabPanel`, `DockItem::add_panel`'s tiles arm wraps
239/// every UI-added panel in a fresh one, and `PanelState::to_item` converts a
240/// plain-panel child into a `TabPanel` on the next save. In the tree model a
241/// tile *is* a panel, so the group has to be unwrapped — building the
242/// `"TabPanel"` leaf directly would miss the registry, fall to a placeholder,
243/// and never build the user's real panels at all, restoring a saved tiles
244/// canvas as blank tiles.
245///
246/// A group holding several panels expands to one tile per panel, sharing the
247/// group's meta. That is not merely defensive: the UI cannot produce it
248/// (tiles groups are locked, so they can never gain a tab), but
249/// `DockItem::tiles(vec![DockItem::tabs(vec![a, b])], ..)` can, and a tiles
250/// canvas has no way to show a second tab.
251fn tile_panels(child: &PanelState, builder: &mut dyn PanelBuilder) -> Vec<PanelId> {
252    match &child.info {
253        PanelInfo::Tabs { .. } => {
254            let panels = collect_tab_panels(&child.children, builder);
255            if panels.len() > 1 {
256                tracing::warn!(
257                    panels = panels.len(),
258                    "a tiles child held a tab group with more than one panel; \
259                     expanding it to one tile per panel, all sharing the group's placement"
260                );
261            }
262            panels
263        }
264        // The legacy empty-group form: a `TabPanel` name carrying leaf info.
265        // It stands for no panel, so it contributes no tile.
266        PanelInfo::Panel(_) if child.panel_name == TAB_PANEL_NAME => Vec::new(),
267        _ => vec![builder.build(child, &child.info)],
268    }
269}
270
271/// Flatten one level of tab nesting, which the old writer could produce.
272fn collect_tab_panels(children: &[PanelState], builder: &mut dyn PanelBuilder) -> Vec<PanelId> {
273    children
274        .iter()
275        .flat_map(|child| match &child.info {
276            PanelInfo::Tabs { .. } => collect_tab_panels(&child.children, builder),
277            PanelInfo::Panel(_) if child.panel_name == TAB_PANEL_NAME => Vec::new(),
278            _ => vec![builder.build(child, &child.info)],
279        })
280        .collect()
281}
282
283#[cfg(test)]
284mod tests {
285    use gpui::{Bounds, point, px, size};
286
287    use super::super::layout::{RootKind, TilePanel};
288    use super::super::state::DockAreaState;
289    use super::*;
290
291    /// A `PanelSource` backed by a fixed map, so conversion is testable
292    /// without an `App`.
293    struct FakePanels(Vec<(PanelId, &'static str)>);
294
295    impl PanelSource for FakePanels {
296        fn panel_name(&self, id: PanelId) -> &'static str {
297            self.0
298                .iter()
299                .find(|(p, _)| *p == id)
300                .map(|(_, n)| *n)
301                .unwrap_or("Unknown")
302        }
303        fn is_visible(&self, _: PanelId) -> bool {
304            true
305        }
306        fn dump(&self, id: PanelId) -> PanelState {
307            PanelState {
308                panel_name: self.panel_name(id).to_string(),
309                children: Vec::new(),
310                info: PanelInfo::panel(serde_json::Value::Null),
311            }
312        }
313    }
314
315    #[test]
316    fn a_split_serializes_as_a_stack_panel() {
317        let mut tree = PaneTree::new(RootKind::Split);
318        tree.push_tabs_for_test(tree.root().id(), vec![PanelId::from_u64(1)]);
319        tree.push_tabs_for_test(tree.root().id(), vec![PanelId::from_u64(2)]);
320        tree.normalize();
321
322        let source = FakePanels(vec![
323            (PanelId::from_u64(1), "Alpha"),
324            (PanelId::from_u64(2), "Beta"),
325        ]);
326        let state = tree.to_state(&source);
327
328        assert_eq!(state.panel_name, "StackPanel");
329        let PanelInfo::Stack { sizes, .. } = &state.info else {
330            panic!("expected Stack info");
331        };
332        // Both slots were pushed with no explicit size (`push_tabs_for_test`
333        // always pushes `None`), so both serialize as the zero sentinel.
334        assert_eq!(sizes, &vec![px(0.), px(0.)]);
335        assert_eq!(state.children[0].panel_name, "TabPanel");
336        assert_eq!(state.children[0].children[0].panel_name, "Alpha");
337    }
338
339    #[test]
340    fn an_unresolved_slot_size_serializes_as_the_zero_sentinel() {
341        let mut tree = PaneTree::new(RootKind::Split);
342        let root = tree.root().id();
343        tree.push_sized_tabs_for_test(root, vec![PanelId::from_u64(1)], Some(px(120.)));
344        tree.push_sized_tabs_for_test(root, vec![PanelId::from_u64(2)], None);
345        tree.normalize();
346
347        let source = FakePanels(vec![
348            (PanelId::from_u64(1), "Alpha"),
349            (PanelId::from_u64(2), "Beta"),
350        ]);
351        let state = tree.to_state(&source);
352
353        let PanelInfo::Stack { sizes, .. } = state.info else {
354            panic!("expected Stack info");
355        };
356        // The `Some` slot keeps its concrete value; only the genuinely
357        // unresolved (`None`) slot is written as the `0.0` sentinel.
358        assert_eq!(sizes, vec![px(120.), px(0.)]);
359    }
360
361    #[test]
362    fn an_empty_tab_group_serializes_as_tabs_not_as_a_panel() {
363        let mut tree = PaneTree::new(RootKind::Any);
364        tree.set_root_tabs_for_test(vec![], 0);
365
366        let state = tree.to_state(&FakePanels(vec![]));
367
368        assert_eq!(state.panel_name, "TabPanel");
369        assert!(
370            matches!(state.info, PanelInfo::Tabs { active_index: 0 }),
371            "the old writer emitted PanelInfo::Panel here, which failed to restore"
372        );
373    }
374
375    #[test]
376    fn an_empty_center_still_serializes_as_a_stack_panel() {
377        let tree = PaneTree::new(RootKind::Split);
378        let state = tree.to_state(&FakePanels(vec![]));
379        assert_eq!(state.panel_name, "StackPanel");
380        assert!(matches!(state.info, PanelInfo::Stack { .. }));
381    }
382
383    #[test]
384    fn tiles_serialize_with_their_metas_in_order() {
385        let mut tree = PaneTree::new(RootKind::Any);
386        let bounds = Bounds {
387            origin: point(px(5.), px(6.)),
388            size: size(px(7.), px(8.)),
389        };
390        tree.set_root_tiles_for_test(vec![
391            TilePanel::new(PanelId::from_u64(1), bounds).with_z_index(2),
392        ]);
393
394        let state = tree.to_state(&FakePanels(vec![(PanelId::from_u64(1), "Alpha")]));
395
396        assert_eq!(state.panel_name, "Tiles");
397        let PanelInfo::Tiles { metas } = state.info else {
398            panic!()
399        };
400        assert_eq!(metas[0].bounds, bounds);
401        assert_eq!(metas[0].z_index, 2);
402    }
403
404    /// Assigns each leaf `PanelState` an id in encounter order, so the reader
405    /// can be tested without a registry or an `App`.
406    #[derive(Default)]
407    struct RecordingBuilder {
408        built: Vec<String>,
409    }
410
411    impl PanelBuilder for RecordingBuilder {
412        fn build(&mut self, state: &PanelState, _: &PanelInfo) -> PanelId {
413            self.built.push(state.panel_name.clone());
414            PanelId::from_u64(self.built.len() as u64)
415        }
416    }
417
418    fn tabs_state(children: Vec<PanelState>, active_index: usize) -> PanelState {
419        PanelState {
420            panel_name: TAB_PANEL_NAME.to_string(),
421            children,
422            info: PanelInfo::tabs(active_index),
423        }
424    }
425
426    fn panel_state(name: &str) -> PanelState {
427        PanelState {
428            panel_name: name.to_string(),
429            children: Vec::new(),
430            info: PanelInfo::panel(serde_json::Value::Null),
431        }
432    }
433
434    #[test]
435    fn nested_tab_groups_are_flattened() {
436        let state = tabs_state(
437            vec![
438                tabs_state(vec![panel_state("Alpha")], 0),
439                tabs_state(vec![panel_state("Beta")], 0),
440            ],
441            1,
442        );
443
444        let mut builder = RecordingBuilder::default();
445        let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder);
446
447        assert_eq!(builder.built, vec!["Alpha", "Beta"]);
448        let PaneRef::Tabs { panels, active_ix } = tree.root().kind() else {
449            panic!()
450        };
451        assert_eq!(panels.len(), 2);
452        assert_eq!(active_ix, 1);
453    }
454
455    #[test]
456    fn a_bare_panel_leaf_is_wrapped_in_a_tab_group() {
457        let mut builder = RecordingBuilder::default();
458        let tree = PaneTree::from_state(&panel_state("Alpha"), RootKind::Any, &mut builder);
459
460        assert!(matches!(tree.root().kind(), PaneRef::Tabs { panels, .. } if panels.len() == 1));
461    }
462
463    #[test]
464    fn a_tab_panel_carrying_panel_info_is_read_as_an_empty_group() {
465        // What the old `TabPanel::dump` wrote for an empty tab group.
466        let state = PanelState {
467            panel_name: TAB_PANEL_NAME.to_string(),
468            children: Vec::new(),
469            info: PanelInfo::panel(serde_json::Value::Null),
470        };
471
472        let mut builder = RecordingBuilder::default();
473        let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder);
474
475        assert!(
476            builder.built.is_empty(),
477            "no panel is built for the phantom leaf"
478        );
479        assert!(matches!(tree.root().kind(), PaneRef::Tabs { panels, .. } if panels.is_empty()));
480    }
481
482    #[test]
483    fn a_split_root_is_forced_even_when_the_state_is_a_tab_group() {
484        let state = tabs_state(vec![panel_state("Alpha")], 0);
485        let mut builder = RecordingBuilder::default();
486        let tree = PaneTree::from_state(&state, RootKind::Split, &mut builder);
487
488        assert!(matches!(tree.root().kind(), PaneRef::Split { .. }));
489    }
490
491    #[test]
492    fn tile_metas_are_paired_with_children_by_index() {
493        let bounds = Bounds {
494            origin: point(px(1.), px(2.)),
495            size: size(px(3.), px(4.)),
496        };
497        let state = PanelState {
498            panel_name: TILES_PANEL_NAME.to_string(),
499            children: vec![panel_state("Alpha")],
500            info: PanelInfo::tiles(vec![TileMeta { bounds, z_index: 5 }]),
501        };
502
503        let mut builder = RecordingBuilder::default();
504        let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder);
505
506        let PaneRef::Tiles { panels } = tree.root().kind() else {
507            panic!()
508        };
509        assert_eq!(panels[0].bounds(), bounds);
510        assert_eq!(panels[0].z_index(), 5);
511    }
512
513    #[test]
514    fn a_tile_child_missing_its_meta_falls_back_to_the_default_placement() {
515        let state = PanelState {
516            panel_name: TILES_PANEL_NAME.to_string(),
517            children: vec![panel_state("Alpha"), panel_state("Beta")],
518            info: PanelInfo::tiles(vec![TileMeta::default()]),
519        };
520
521        let mut builder = RecordingBuilder::default();
522        let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder);
523
524        let PaneRef::Tiles { panels } = tree.root().kind() else {
525            panic!()
526        };
527        assert_eq!(panels.len(), 2, "a short metas list must not drop panels");
528    }
529
530    /// Round-trips leaves by remembering the exact `PanelState` each id came
531    /// from, which is what the production invalid-panel path must also do.
532    ///
533    /// `PanelSource::panel_name` returns `&'static str`, which a JSON-sourced
534    /// `String` can only satisfy by leaking. Rather than leak on every call
535    /// to `panel_name` (as many times as the layout is dumped), each leak
536    /// happens once, in `build`, when the id is minted; `panel_name` then
537    /// just indexes into the already-leaked slice. Still a leak, but a
538    /// bounded one, and test-only.
539    #[derive(Default)]
540    struct PreservingPanels {
541        states: Vec<PanelState>,
542        names: Vec<&'static str>,
543    }
544
545    impl PanelBuilder for PreservingPanels {
546        fn build(&mut self, state: &PanelState, _: &PanelInfo) -> PanelId {
547            self.states.push(state.clone());
548            self.names
549                .push(Box::leak(state.panel_name.clone().into_boxed_str()));
550            PanelId::from_u64(self.states.len() as u64)
551        }
552    }
553
554    impl PanelSource for PreservingPanels {
555        fn panel_name(&self, id: PanelId) -> &'static str {
556            self.names[id.as_u64() as usize - 1]
557        }
558        fn is_visible(&self, _: PanelId) -> bool {
559            true
560        }
561        fn dump(&self, id: PanelId) -> PanelState {
562            self.states[id.as_u64() as usize - 1].clone()
563        }
564    }
565
566    fn canonicalize(json: &str) -> PanelState {
567        let state: DockAreaState = serde_json::from_str(json).unwrap();
568        let mut panels = PreservingPanels::default();
569        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
570        tree.to_state(&panels)
571    }
572
573    #[test]
574    fn canonicalization_reaches_a_fixpoint_in_one_pass() {
575        for json in [
576            include_str!("fixtures/layout.json"),
577            include_str!("fixtures/tiles.json"),
578            include_str!("fixtures/nested_splits.json"),
579            include_str!("fixtures/legacy_empty_tab_group.json"),
580            include_str!("fixtures/unregistered_panel.json"),
581            include_str!("fixtures/zero_size_sentinel.json"),
582            include_str!("fixtures/tiles_tab_panel_children.json"),
583        ] {
584            let once = canonicalize(json);
585            let twice = {
586                let wrapped = DockAreaState {
587                    center: once.clone(),
588                    ..Default::default()
589                };
590                canonicalize(&serde_json::to_string(&wrapped).unwrap())
591            };
592            assert_eq!(once, twice, "r(r(x)) != r(x)");
593        }
594    }
595
596    #[test]
597    fn an_unregistered_panel_keeps_its_payload_through_a_round_trip() {
598        let state = canonicalize(include_str!("fixtures/unregistered_panel.json"));
599        let leaf = &state.children[0].children[0];
600
601        assert_eq!(leaf.panel_name, "PanelFromTheFuture");
602        assert_eq!(
603            leaf.info,
604            PanelInfo::panel(serde_json::json!({"keep": "me"}))
605        );
606    }
607
608    #[test]
609    fn the_legacy_empty_tab_group_is_rewritten_into_the_tabs_form() {
610        let state = canonicalize(include_str!("fixtures/legacy_empty_tab_group.json"));
611
612        // The empty group collapses, leaving the mandatory split root.
613        assert_eq!(state.panel_name, "StackPanel");
614        assert!(state.children.is_empty());
615    }
616
617    #[test]
618    fn nested_same_axis_splits_are_flattened_and_single_child_splits_collapse() {
619        let state = canonicalize(include_str!("fixtures/nested_splits.json"));
620
621        assert_eq!(state.panel_name, "StackPanel");
622        assert_eq!(
623            state.children.len(),
624            3,
625            "the horizontal inner split splices in and the vertical single-child split collapses"
626        );
627        assert!(
628            state
629                .children
630                .iter()
631                .all(|child| child.panel_name == "TabPanel")
632        );
633        // Order matters as much as count: a splice that reversed the spliced
634        // children, or that put the collapsed single-child split ahead of
635        // them, would still satisfy the two assertions above.
636        assert_eq!(state.children[0].children[0].panel_name, "Alpha");
637        assert_eq!(state.children[1].children[0].panel_name, "Beta");
638        assert_eq!(state.children[2].children[0].panel_name, "Gamma");
639
640        // The fixture's inner slot (50.0 + 150.0 = 200.0) does not equal its
641        // outer slot (400.0), so the scale factor is a genuine 2x, not 1x:
642        // `distribute_slot`'s `slot / total` and a wrongly inverted
643        // `total / slot` would disagree here. The vertical single-child
644        // split's own inner size (300.0) is discarded; its outer slot
645        // (300.0) is what the surviving child inherits.
646        let PanelInfo::Stack { sizes, .. } = &state.info else {
647            panic!("expected Stack info");
648        };
649        assert_eq!(
650            sizes,
651            &vec![px(100.), px(300.), px(300.)],
652            "the spliced-in sizes are scaled by outer/inner (400/200 = 2x), and the \
653             collapsed split hands its own outer slot (300.0) to its surviving child"
654        );
655    }
656
657    /// The shape every persisted tiles canvas actually has: each tile child
658    /// is a `TabPanel` wrapping the real panel. Reading the `"TabPanel"` leaf
659    /// literally would build a placeholder and drop the user's panel.
660    #[test]
661    fn a_tiles_child_that_is_a_tab_group_is_unwrapped_to_its_panels() {
662        let json = include_str!("fixtures/tiles_tab_panel_children.json");
663        let state: DockAreaState = serde_json::from_str(json).unwrap();
664        let mut panels = PreservingPanels::default();
665        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
666
667        assert_eq!(
668            panels.names,
669            vec!["Alpha", "Beta", "Gamma"],
670            "the real panels are built; no `TabPanel` leaf is handed to the builder"
671        );
672
673        let dumped = tree.to_state(&panels);
674        let tiles = &dumped;
675        assert_eq!(tiles.panel_name, "Tiles");
676        assert_eq!(
677            tiles
678                .children
679                .iter()
680                .map(|child| child.panel_name.as_str())
681                .collect::<Vec<_>>(),
682            vec!["Alpha", "Beta", "Gamma"],
683        );
684
685        let PanelInfo::Tiles { metas } = &tiles.info else {
686            panic!("expected Tiles info");
687        };
688        // The two-panel child expands to two tiles sharing its own meta, and
689        // the *next* child still gets the meta at its own child index — an
690        // implementation keyed on the output index would hand Gamma the first
691        // meta and lose the second entirely.
692        assert_eq!(metas.len(), 3);
693        assert_eq!(
694            metas[0].bounds.origin.x,
695            px(10.),
696            "Alpha keeps child 0's meta"
697        );
698        assert_eq!(
699            metas[1].bounds.origin.x,
700            px(10.),
701            "Beta shares child 0's meta"
702        );
703        assert_eq!(metas[1].z_index, 0);
704        assert_eq!(
705            metas[2].bounds.origin.x,
706            px(400.),
707            "Gamma gets child 1's meta"
708        );
709        assert_eq!(metas[2].z_index, 3);
710    }
711
712    #[test]
713    fn an_empty_tab_group_on_a_tiles_canvas_contributes_no_tile() {
714        let state = PanelState {
715            panel_name: TILES_PANEL_NAME.to_string(),
716            children: vec![
717                // The legacy empty-group form.
718                PanelState {
719                    panel_name: TAB_PANEL_NAME.to_string(),
720                    children: Vec::new(),
721                    info: PanelInfo::panel(serde_json::Value::Null),
722                },
723                tabs_state(vec![panel_state("Alpha")], 0),
724            ],
725            info: PanelInfo::tiles(vec![TileMeta::default(), TileMeta::default()]),
726        };
727
728        let mut builder = RecordingBuilder::default();
729        let tree = PaneTree::from_state(&state, RootKind::Any, &mut builder);
730
731        assert_eq!(builder.built, vec!["Alpha"]);
732        let PaneRef::Tiles { panels } = tree.root().kind() else {
733            panic!()
734        };
735        assert_eq!(panels.len(), 1);
736    }
737
738    /// The dock before the tree rewrite wrote a tiles center as a bare
739    /// `Tiles`, and its `StackPanel::insert_panel` asserts every split child
740    /// is a `TabPanel` or `StackPanel`. Writing the in-memory `Split` root
741    /// around the canvas therefore crashes an older build on load: the wrapper
742    /// is a tree invariant, not part of the persisted schema.
743    #[test]
744    fn a_tiles_center_is_written_the_way_the_pre_tree_dock_wrote_it() {
745        let legacy = PanelState {
746            panel_name: TILES_PANEL_NAME.to_string(),
747            children: vec![tabs_state(vec![panel_state("Alpha")], 0)],
748            info: PanelInfo::tiles(vec![TileMeta::default()]),
749        };
750        let mut panels = PreservingPanels::default();
751        let tree = PaneTree::from_state(&legacy, RootKind::Split, &mut panels);
752
753        let dumped = tree.to_state(&panels);
754
755        assert_eq!(
756            dumped.panel_name, "Tiles",
757            "no `StackPanel` wrapper: the old reader hands the canvas to `StackPanel::insert_panel`"
758        );
759        assert_eq!(dumped.children[0].panel_name, "Alpha");
760    }
761
762    #[test]
763    fn tile_bounds_and_z_order_survive_a_round_trip() {
764        let state = canonicalize(include_str!("fixtures/tiles.json"));
765        let tiles = &state;
766
767        assert_eq!(tiles.panel_name, "Tiles");
768        // `metas` and `children` are parallel arrays, matched up by index. A
769        // bug that swapped `children`'s order while leaving `metas` in place
770        // would misattribute bounds to the wrong panel without either array
771        // changing length, so check both arrays' contents *and* that they
772        // still line up by identity, not just that each array is correct in
773        // isolation.
774        assert_eq!(tiles.children[0].panel_name, "Alpha");
775        assert_eq!(tiles.children[1].panel_name, "Beta");
776
777        let PanelInfo::Tiles { metas } = &tiles.info else {
778            panic!()
779        };
780        assert_eq!(metas.len(), 2);
781        assert_eq!(metas[0].z_index, 0, "Alpha's meta");
782        assert_eq!(metas[0].bounds.origin.x, px(10.), "Alpha's meta");
783        assert_eq!(metas[1].z_index, 1, "Beta's meta");
784        assert_eq!(metas[1].bounds.origin.x, px(220.), "Beta's meta");
785    }
786
787    #[test]
788    fn a_tree_survives_a_round_trip_exactly() {
789        let json = include_str!("fixtures/nested_splits.json");
790        let state: DockAreaState = serde_json::from_str(json).unwrap();
791        let mut panels = PreservingPanels::default();
792        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
793
794        let dumped = tree.to_state(&panels);
795        let mut rebuilt_panels = PreservingPanels::default();
796        let rebuilt = PaneTree::from_state(&dumped, RootKind::Split, &mut rebuilt_panels);
797
798        assert_eq!(
799            tree.to_state(&panels),
800            rebuilt.to_state(&rebuilt_panels),
801            "load(dump(t)) must describe the same layout as t"
802        );
803    }
804
805    /// Pins the one value in the schema that means something different now
806    /// than it ever did before: a literal `0.0` slot size sits next to a
807    /// genuine, non-collapsing `200.0` sibling, so the fixture cannot pass by
808    /// accident of single-child-split collapse swallowing the slot. The
809    /// reader must map the `0.0` back to `None` (unconstrained) while
810    /// leaving the `200.0` sibling as `Some`, and the writer must round-trip
811    /// `None` back to the literal `0.0` sentinel.
812    #[test]
813    fn a_zero_size_slot_round_trips_through_the_none_sentinel() {
814        let json = include_str!("fixtures/zero_size_sentinel.json");
815        let state: DockAreaState = serde_json::from_str(json).unwrap();
816        let mut panels = PreservingPanels::default();
817        let tree = PaneTree::from_state(&state.center, RootKind::Split, &mut panels);
818
819        let PaneRef::Split { sizes, .. } = tree.root().kind() else {
820            panic!("expected the root to stay a split");
821        };
822        assert_eq!(
823            sizes,
824            &[None, Some(px(200.))],
825            "the 0.0 sentinel reads back as None, not as a real zero-width panel"
826        );
827
828        let dumped = tree.to_state(&panels);
829        let PanelInfo::Stack { sizes, .. } = &dumped.info else {
830            panic!("expected Stack info");
831        };
832        assert_eq!(
833            sizes,
834            &vec![px(0.), px(200.)],
835            "the unconstrained slot writes back out as the 0.0 sentinel"
836        );
837    }
838
839    /// Pins that a bare `TabPanel` root — the shape every dock in the
840    /// shipped `fixtures/layout.json` (`left_dock`, `right_dock`,
841    /// `bottom_dock`) actually stores — survives a round trip under
842    /// `RootKind::Any` as a `Tabs` node, with its one panel intact.
843    ///
844    /// It does *not* pin that the forced-wrap arm in `from_state` is gated
845    /// on `RootKind::Split` and correctly skipped here. It can't: `normalize`
846    /// runs `collapse_root` on every pass, which un-wraps a single-child
847    /// `Split` root for any `root_kind != RootKind::Split` before this test
848    /// (or anything else) can observe the tree. So if the guard were deleted
849    /// and the wrap fired unconditionally, `collapse_root` would strip the
850    /// synthetic split right back off within the same `normalize()` call,
851    /// and this test would still pass — a correctly-guarded arm and a
852    /// missing guard produce byte-identical output here. The half of the
853    /// guard that *is* observable — the wrap firing and surviving — is
854    /// pinned by `a_split_root_is_forced_even_when_the_state_is_a_tab_group`,
855    /// where `RootKind::Split` makes `collapse_root` return early instead of
856    /// undoing it.
857    #[test]
858    fn a_bare_tab_panel_root_is_not_wrapped_under_root_kind_any() {
859        let json = include_str!("fixtures/bare_tab_panel_root.json");
860        let state: PanelState = serde_json::from_str(json).unwrap();
861        let mut panels = PreservingPanels::default();
862        let tree = PaneTree::from_state(&state, RootKind::Any, &mut panels);
863
864        assert!(
865            matches!(tree.root().kind(), PaneRef::Tabs { .. }),
866            "a bare TabPanel root must stay a Tabs node under RootKind::Any, \
867             not get wrapped in a synthetic split"
868        );
869
870        let dumped = tree.to_state(&panels);
871        assert_eq!(dumped.panel_name, "TabPanel");
872    }
873}