Skip to main content

panes/
snapshot.rs

1use std::sync::Arc;
2
3use crate::error::{PaneError, TreeError};
4use crate::node::{Node, NodeId, PanelKey};
5use crate::overlay::{OverlayDef, SnapshotOverlay};
6use crate::panel::Axis;
7use crate::panel::Constraints;
8use crate::strategy::{ActivePanelVariant, CardSpan, GridColumnMode, SlotDef, StrategyKind};
9use crate::tree::LayoutTree;
10use crate::validate::{check_f32_non_negative, float_invalid_to_constraint};
11
12/// Serializable snapshot of a [`LayoutRuntime`](crate::runtime::LayoutRuntime)
13/// for session persistence.
14///
15/// Strategy runtimes serialize the recipe (strategy config + panel kinds).
16/// Non-strategy runtimes serialize the tree topology.
17///
18/// # Example
19///
20/// ```rust
21/// # use panes::Layout;
22/// let mut rt = Layout::master_stack(["editor", "chat", "status"])
23///     .master_ratio(0.6).gap(1.0).into_runtime().unwrap();
24/// let snapshot = rt.snapshot().unwrap();
25///
26/// // Serialize with any serde format:
27/// // let json = serde_json::to_string(&snapshot).unwrap();
28///
29/// // Restore later:
30/// let mut rt2 = panes::runtime::LayoutRuntime::from_snapshot(snapshot).unwrap();
31/// ```
32#[derive(Debug, Clone)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34pub struct LayoutSnapshot {
35    source: SnapshotSource,
36    focused: Option<Box<str>>,
37    collapsed: Box<[Box<str>]>,
38    /// Sequence index of the focused panel for deterministic restore
39    /// with repeated kinds. Preferred over `focused` when present.
40    #[cfg_attr(
41        feature = "serde",
42        serde(default, skip_serializing_if = "Option::is_none")
43    )]
44    focused_key: Option<PanelKey>,
45    /// Sequence indices of collapsed panels for deterministic restore
46    /// with repeated kinds. Preferred over `collapsed` when present.
47    #[cfg_attr(
48        feature = "serde",
49        serde(default, skip_serializing_if = "is_box_slice_empty")
50    )]
51    collapsed_keys: Box<[PanelKey]>,
52    #[cfg_attr(
53        feature = "serde",
54        serde(default, skip_serializing_if = "is_box_slice_empty")
55    )]
56    overlays: Box<[SnapshotOverlay]>,
57}
58
59#[cfg(feature = "serde")]
60fn is_box_slice_empty<T>(s: &[T]) -> bool {
61    s.is_empty()
62}
63
64impl LayoutSnapshot {
65    /// Returns the snapshot source: a strategy config, tree topology, or adaptive breakpoint set.
66    pub fn source(&self) -> &SnapshotSource {
67        &self.source
68    }
69
70    /// Returns the kind of the focused panel at capture time, if any.
71    pub fn focused(&self) -> Option<&str> {
72        self.focused.as_deref()
73    }
74
75    /// Returns the kinds of all collapsed panels at capture time.
76    pub fn collapsed(&self) -> &[Box<str>] {
77        &self.collapsed
78    }
79
80    /// Sequence index of the focused panel for deterministic restore.
81    pub fn focused_key(&self) -> Option<PanelKey> {
82        self.focused_key
83    }
84
85    /// Sequence indices of collapsed panels for deterministic restore.
86    pub fn collapsed_keys(&self) -> &[PanelKey] {
87        &self.collapsed_keys
88    }
89
90    /// Returns the overlay definitions captured at snapshot time.
91    pub fn overlays(&self) -> &[SnapshotOverlay] {
92        &self.overlays
93    }
94
95    /// Consumes the snapshot and returns the overlay definitions as an owned `Vec`.
96    pub fn into_overlays(self) -> Vec<SnapshotOverlay> {
97        self.overlays.into_vec()
98    }
99}
100
101/// What a snapshot restores from: a strategy recipe, a tree topology,
102/// or an adaptive breakpoint set.
103
104#[derive(Debug, Clone)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
106pub enum SnapshotSource {
107    /// Strategy-based runtime — rebuild from recipe.
108    Strategy {
109        strategy: StrategyConfig,
110        panels: Box<[Box<str>]>,
111    },
112    /// Non-strategy runtime — rebuild from tree topology.
113    Tree { root: SnapshotNode },
114    /// Adaptive runtime — rebuild from breakpoints.
115    Adaptive {
116        breakpoints: Box<[SnapshotBreakpoint]>,
117        panels: Box<[Box<str>]>,
118        active_index: usize,
119    },
120}
121
122/// Serializable breakpoint entry for adaptive layouts.
123#[derive(Debug, Clone)]
124#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
125pub struct SnapshotBreakpoint {
126    pub min_width: u32,
127    pub strategy: StrategyConfig,
128}
129
130/// Serializable strategy recipe for snapshot restore.
131///
132/// Mirrors [`StrategyKind`] with owned collections (`Box<[T]>`) instead of
133/// `Arc<[T]>`, making it safe for serde round-trips without shared-ownership
134/// bookkeeping.
135#[derive(Debug, Clone)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
137pub enum StrategyConfig {
138    Sequence {
139        axis: Axis,
140        gap: f32,
141        #[cfg_attr(
142            feature = "serde",
143            serde(default, skip_serializing_if = "Option::is_none")
144        )]
145        ratio: Option<f32>,
146    },
147    MasterStack {
148        master_ratio: f32,
149        gap: f32,
150    },
151    Deck {
152        master_ratio: f32,
153        gap: f32,
154    },
155    CenteredMaster {
156        master_ratio: f32,
157        gap: f32,
158    },
159    BinarySplit {
160        spiral: bool,
161        ratio: f32,
162        gap: f32,
163    },
164    Dashboard {
165        columns: GridColumnMode,
166        gap: f32,
167        spans: Box<[CardSpan]>,
168        #[cfg_attr(feature = "serde", serde(default))]
169        auto_rows: bool,
170    },
171    ActivePanel {
172        variant: ActivePanelVariant,
173        bar_height: f32,
174    },
175    Window {
176        panel_count: usize,
177        gap: f32,
178    },
179    Slotted {
180        slots: Box<[SnapshotSlotDef]>,
181        gap: f32,
182        axis: Axis,
183    },
184}
185
186/// Serializable slot definition for the Slotted strategy.
187#[derive(Debug, Clone)]
188#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
189pub struct SnapshotSlotDef {
190    pub kind: Box<str>,
191    pub constraints: Constraints,
192}
193
194/// A grid item inside a [`SnapshotNode::Grid`] container.
195///
196/// Wraps a child node with an optional column span. Items without a span
197/// use the default single-column placement.
198#[derive(Debug, Clone)]
199#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
200pub struct SnapshotGridItem {
201    #[cfg_attr(
202        feature = "serde",
203        serde(default, skip_serializing_if = "Option::is_none")
204    )]
205    pub span: Option<CardSpan>,
206    pub node: SnapshotNode,
207}
208
209/// Recursive tree topology node for non-strategy snapshots.
210///
211/// Variants mirror the runtime node types (`Panel`, `Row`, `Col`, `Grid`).
212/// The tree is walked depth-first during capture and rebuilt on restore.
213#[derive(Debug, Clone)]
214#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
215pub enum SnapshotNode {
216    Panel {
217        kind: Box<str>,
218        constraints: Constraints,
219    },
220    Row {
221        gap: f32,
222        #[cfg_attr(
223            feature = "serde",
224            serde(default, skip_serializing_if = "Option::is_none")
225        )]
226        constraints: Option<Constraints>,
227        children: Box<[SnapshotNode]>,
228    },
229    Col {
230        gap: f32,
231        #[cfg_attr(
232            feature = "serde",
233            serde(default, skip_serializing_if = "Option::is_none")
234        )]
235        constraints: Option<Constraints>,
236        children: Box<[SnapshotNode]>,
237    },
238    Grid {
239        columns: GridColumnMode,
240        gap: f32,
241        #[cfg_attr(feature = "serde", serde(default))]
242        auto_rows: bool,
243        children: Box<[SnapshotGridItem]>,
244    },
245}
246
247// ---------------------------------------------------------------------------
248// StrategyKind ↔ StrategyConfig conversions
249// ---------------------------------------------------------------------------
250
251/// Generate bidirectional `From` impls between `StrategyKind` and `StrategyConfig`.
252///
253/// Copy-only variants list fields for automatic `*field` copying.
254/// Custom variants provide explicit conversion bodies for each direction.
255macro_rules! strategy_convert {
256    (
257        // Copy-only variants: fields are all Copy, just dereference.
258        copy: [ $( $variant:ident { $($field:ident),* } ),* $(,)? ],
259        // Custom variant: StrategyKind → StrategyConfig body, then reverse.
260        custom_to_config: [ $($to_config_arm:tt)* ],
261        custom_to_kind:   [ $($to_kind_arm:tt)* ],
262    ) => {
263        impl From<&StrategyKind> for StrategyConfig {
264            fn from(sk: &StrategyKind) -> Self {
265                match sk {
266                    $(
267                        StrategyKind::$variant { $($field),* } =>
268                            StrategyConfig::$variant { $($field: *$field),* },
269                    )*
270                    $($to_config_arm)*
271                }
272            }
273        }
274
275        impl From<&StrategyConfig> for StrategyKind {
276            fn from(sc: &StrategyConfig) -> Self {
277                match sc {
278                    $(
279                        StrategyConfig::$variant { $($field),* } =>
280                            StrategyKind::$variant { $($field: *$field),* },
281                    )*
282                    $($to_kind_arm)*
283                }
284            }
285        }
286    };
287}
288
289strategy_convert! {
290    copy: [
291        Sequence { axis, gap, ratio },
292        MasterStack { master_ratio, gap },
293        Deck { master_ratio, gap },
294        CenteredMaster { master_ratio, gap },
295        BinarySplit { spiral, ratio, gap },
296        ActivePanel { variant, bar_height },
297        Window { panel_count, gap },
298    ],
299    custom_to_config: [
300        StrategyKind::Dashboard { columns, gap, spans, auto_rows } => StrategyConfig::Dashboard {
301            columns: *columns, gap: *gap, spans: Box::from(&**spans), auto_rows: *auto_rows,
302        },
303        StrategyKind::Slotted { slots, gap, axis } => StrategyConfig::Slotted {
304            slots: slots.iter().map(|s| SnapshotSlotDef {
305                kind: Box::from(&*s.kind), constraints: s.constraints,
306            }).collect::<Box<[_]>>(),
307            gap: *gap, axis: *axis,
308        },
309    ],
310    custom_to_kind: [
311        StrategyConfig::Dashboard { columns, gap, spans, auto_rows } => StrategyKind::Dashboard {
312            columns: *columns, gap: *gap, spans: Arc::from(&**spans), auto_rows: *auto_rows,
313        },
314        StrategyConfig::Slotted { slots, gap, axis } => StrategyKind::Slotted {
315            slots: slots.iter().map(|s| SlotDef {
316                kind: Arc::from(&*s.kind), constraints: s.constraints,
317            }).collect::<Arc<[_]>>(),
318            gap: *gap, axis: *axis,
319        },
320    ],
321}
322
323// ---------------------------------------------------------------------------
324// Tree → SnapshotNode (walk the arena)
325// ---------------------------------------------------------------------------
326
327/// Maximum recursion depth for snapshot tree operations.
328const MAX_SNAPSHOT_DEPTH: usize = 64;
329
330/// Walk the tree from `root` and build a recursive `SnapshotNode`.
331/// Returns `None` if the tree has no root.
332pub(crate) fn tree_to_snapshot(tree: &LayoutTree) -> Result<Option<SnapshotNode>, PaneError> {
333    let Some(root) = tree.root() else {
334        return Ok(None);
335    };
336    node_to_snapshot(tree, root, 0).map(Some)
337}
338
339fn container_snapshot(
340    is_row: bool,
341    gap: f32,
342    constraints: Option<Constraints>,
343    children: Box<[SnapshotNode]>,
344) -> SnapshotNode {
345    match is_row {
346        true => SnapshotNode::Row {
347            gap,
348            constraints,
349            children,
350        },
351        false => SnapshotNode::Col {
352            gap,
353            constraints,
354            children,
355        },
356    }
357}
358
359fn node_to_snapshot(
360    tree: &LayoutTree,
361    nid: NodeId,
362    depth: usize,
363) -> Result<SnapshotNode, PaneError> {
364    if depth > MAX_SNAPSHOT_DEPTH {
365        return Err(PaneError::InvalidTree(TreeError::SnapshotTooDeep(
366            MAX_SNAPSHOT_DEPTH,
367        )));
368    }
369    let Some(node) = tree.node(nid) else {
370        return Err(PaneError::NodeNotFound(nid));
371    };
372    match node {
373        Node::Grid {
374            columns,
375            gap,
376            auto_rows,
377            children,
378        } => {
379            let snap_children = children
380                .iter()
381                .map(|&child_nid| grid_child_to_snapshot(tree, child_nid, depth + 1))
382                .collect::<Result<Vec<_>, _>>()?
383                .into_boxed_slice();
384            Ok(SnapshotNode::Grid {
385                columns: *columns,
386                gap: *gap,
387                auto_rows: *auto_rows,
388                children: snap_children,
389            })
390        }
391        Node::TaffyPassthrough { .. } | Node::GridItemWrapper { .. } => Err(
392            PaneError::InvalidTree(TreeError::UnsupportedSnapshotNode(nid)),
393        ),
394        Node::Panel {
395            kind, constraints, ..
396        } => Ok(SnapshotNode::Panel {
397            kind: Box::from(&**kind),
398            constraints: *constraints,
399        }),
400        Node::Row {
401            gap,
402            constraints,
403            children,
404        }
405        | Node::Col {
406            gap,
407            constraints,
408            children,
409        } => {
410            let is_row = matches!(node, Node::Row { .. });
411            let snap_children = children
412                .iter()
413                .map(|&child_id| node_to_snapshot(tree, child_id, depth + 1))
414                .collect::<Result<Vec<_>, _>>()?
415                .into_boxed_slice();
416            Ok(container_snapshot(
417                is_row,
418                *gap,
419                *constraints,
420                snap_children,
421            ))
422        }
423    }
424}
425
426/// Convert a single grid child to a snapshot grid item.
427///
428/// If the child is a `GridItemWrapper`, unwrap it and attach the span
429/// to the inner node. Otherwise treat as a regular child.
430fn grid_child_to_snapshot(
431    tree: &LayoutTree,
432    nid: NodeId,
433    depth: usize,
434) -> Result<SnapshotGridItem, PaneError> {
435    let Some(node) = tree.node(nid) else {
436        return Err(PaneError::NodeNotFound(nid));
437    };
438    match node {
439        Node::GridItemWrapper { span, child } => {
440            let inner = node_to_snapshot(tree, *child, depth)?;
441            Ok(SnapshotGridItem {
442                span: Some(*span),
443                node: inner,
444            })
445        }
446        _ => {
447            let inner = node_to_snapshot(tree, nid, depth)?;
448            Ok(SnapshotGridItem {
449                span: None,
450                node: inner,
451            })
452        }
453    }
454}
455
456// ---------------------------------------------------------------------------
457// SnapshotNode → LayoutTree (rebuild via builder)
458// ---------------------------------------------------------------------------
459
460/// Rebuild a `LayoutTree` from a `SnapshotNode`.
461pub(crate) fn snapshot_to_tree(root: &SnapshotNode) -> Result<LayoutTree, PaneError> {
462    let mut tree = LayoutTree::new();
463    let root_id = snapshot_node_to_tree(&mut tree, root, 0)?;
464    tree.set_root(root_id);
465    tree.validate()?;
466    Ok(tree)
467}
468
469fn snapshot_node_to_tree(
470    tree: &mut LayoutTree,
471    snapshot_node: &SnapshotNode,
472    depth: usize,
473) -> Result<NodeId, PaneError> {
474    if depth > MAX_SNAPSHOT_DEPTH {
475        return Err(PaneError::InvalidTree(TreeError::SnapshotTooDeep(
476            MAX_SNAPSHOT_DEPTH,
477        )));
478    }
479
480    match snapshot_node {
481        SnapshotNode::Panel { kind, constraints } => {
482            let (_, node_id) = tree.add_panel(&**kind, *constraints)?;
483            Ok(node_id)
484        }
485        SnapshotNode::Row {
486            gap,
487            constraints,
488            children,
489        } => {
490            validate_snapshot_gap(*gap)?;
491            let child_ids = snapshot_children_to_tree(tree, children, depth)?;
492            tree.add_row_constrained(*gap, *constraints, child_ids)
493        }
494        SnapshotNode::Col {
495            gap,
496            constraints,
497            children,
498        } => {
499            validate_snapshot_gap(*gap)?;
500            let child_ids = snapshot_children_to_tree(tree, children, depth)?;
501            tree.add_col_constrained(*gap, *constraints, child_ids)
502        }
503        SnapshotNode::Grid {
504            columns,
505            gap,
506            auto_rows,
507            children,
508        } => {
509            crate::preset::validate_grid_columns(*columns)?;
510            validate_snapshot_gap(*gap)?;
511            let child_ids = grid_children_to_tree(tree, children, depth)?;
512            tree.add_grid(*columns, *gap, *auto_rows, child_ids)
513        }
514    }
515}
516
517fn snapshot_children_to_tree(
518    tree: &mut LayoutTree,
519    children: &[SnapshotNode],
520    depth: usize,
521) -> Result<Vec<NodeId>, PaneError> {
522    children
523        .iter()
524        .map(|child| snapshot_node_to_tree(tree, child, depth + 1))
525        .collect()
526}
527
528fn grid_children_to_tree(
529    tree: &mut LayoutTree,
530    children: &[SnapshotGridItem],
531    depth: usize,
532) -> Result<Vec<NodeId>, PaneError> {
533    children
534        .iter()
535        .map(|grid_item| grid_item_to_tree(tree, grid_item, depth + 1))
536        .collect()
537}
538
539fn grid_item_to_tree(
540    tree: &mut LayoutTree,
541    grid_item: &SnapshotGridItem,
542    depth: usize,
543) -> Result<NodeId, PaneError> {
544    if depth > MAX_SNAPSHOT_DEPTH {
545        return Err(PaneError::InvalidTree(TreeError::SnapshotTooDeep(
546            MAX_SNAPSHOT_DEPTH,
547        )));
548    }
549
550    match (&grid_item.span, &grid_item.node) {
551        (Some(span), SnapshotNode::Panel { kind, constraints }) => {
552            crate::preset::validate_grid_span(*span)?;
553            let (_, panel_id) = tree.add_panel(&**kind, *constraints)?;
554            tree.add_grid_item(*span, panel_id)
555        }
556        (Some(_), snapshot_node) => Err(PaneError::InvalidTree(
557            TreeError::SnapshotSpanRequiresPanel(snapshot_node_kind(snapshot_node)),
558        )),
559        (None, snapshot_node) => snapshot_node_to_tree(tree, snapshot_node, depth),
560    }
561}
562
563fn validate_snapshot_gap(gap: f32) -> Result<(), PaneError> {
564    check_f32_non_negative(gap)
565        .map_err(|error| PaneError::InvalidConstraint(float_invalid_to_constraint("gap", error)))
566}
567
568fn snapshot_node_kind(snapshot_node: &SnapshotNode) -> &'static str {
569    match snapshot_node {
570        SnapshotNode::Panel { .. } => "panel",
571        SnapshotNode::Row { .. } => "row",
572        SnapshotNode::Col { .. } => "col",
573        SnapshotNode::Grid { .. } => "grid",
574    }
575}
576
577// ---------------------------------------------------------------------------
578// Capture helper (called by LayoutRuntime)
579// ---------------------------------------------------------------------------
580
581/// Capture a snapshot from the current runtime state.
582pub(crate) fn capture(
583    tree: &LayoutTree,
584    strategy: Option<&StrategyKind>,
585    sequence: &crate::sequence::PanelSequence,
586    viewport: &crate::viewport::ViewportState,
587    overlay_defs: &[OverlayDef],
588    breakpoints: Option<(&[crate::breakpoint::BreakpointEntry], usize)>,
589) -> Result<LayoutSnapshot, PaneError> {
590    let focused = viewport
591        .focus
592        .map(|pid| tree.panel_kind(pid).map(Box::from))
593        .transpose()?;
594
595    let focused_key = match sequence.is_empty() {
596        true => None,
597        false => viewport
598            .focus
599            .map(|pid| {
600                sequence
601                    .index_of(pid)
602                    .map(|idx| PanelKey::from_raw(idx as u32))
603                    .ok_or(PaneError::InvalidTree(
604                        TreeError::SnapshotFocusedMissingFromSequence(pid),
605                    ))
606            })
607            .transpose()?,
608    };
609
610    let collapsed: Box<[Box<str>]> = viewport
611        .collapsed
612        .iter()
613        .map(|&pid| tree.panel_kind(pid).map(Box::from))
614        .collect::<Result<Vec<_>, _>>()?
615        .into_boxed_slice();
616
617    let collapsed_keys: Box<[PanelKey]> = match sequence.is_empty() {
618        true => Box::default(),
619        false => viewport
620            .collapsed
621            .iter()
622            .map(|&pid| {
623                sequence
624                    .index_of(pid)
625                    .map(|idx| PanelKey::from_raw(idx as u32))
626                    .ok_or(PaneError::InvalidTree(
627                        TreeError::SnapshotCollapsedMissingFromSequence(pid),
628                    ))
629            })
630            .collect::<Result<Vec<_>, _>>()?
631            .into_boxed_slice(),
632    };
633
634    let panels_box = || -> Result<Box<[Box<str>]>, PaneError> {
635        Ok(sequence
636            .iter()
637            .map(|pid| tree.panel_kind(pid).map(Box::from))
638            .collect::<Result<Vec<_>, _>>()?
639            .into_boxed_slice())
640    };
641
642    let source = match (breakpoints, strategy) {
643        (Some((bps, active_index)), _) => {
644            let snap_bps: Box<[SnapshotBreakpoint]> = bps
645                .iter()
646                .map(|bp| SnapshotBreakpoint {
647                    min_width: bp.min_width,
648                    strategy: StrategyConfig::from(&bp.strategy),
649                })
650                .collect();
651            SnapshotSource::Adaptive {
652                breakpoints: snap_bps,
653                panels: panels_box()?,
654                active_index,
655            }
656        }
657        (None, Some(sk)) => SnapshotSource::Strategy {
658            strategy: StrategyConfig::from(sk),
659            panels: panels_box()?,
660        },
661        (None, None) => {
662            let root =
663                tree_to_snapshot(tree)?.ok_or(PaneError::InvalidTree(TreeError::SnapshotNoRoot))?;
664            SnapshotSource::Tree { root }
665        }
666    };
667
668    let overlays: Box<[SnapshotOverlay]> = overlay_defs
669        .iter()
670        .map(|def| SnapshotOverlay {
671            kind: Box::from(&*def.kind),
672            anchor: def.anchor.clone(),
673            width: def.width,
674            height: def.height,
675            visible: def.visible,
676        })
677        .collect();
678
679    Ok(LayoutSnapshot {
680        source,
681        focused,
682        collapsed,
683        focused_key,
684        collapsed_keys,
685        overlays,
686    })
687}