Skip to main content

teksilo_widgets/splitter/
model.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`SplitterModel`] — the shared, cloneable, serializable state behind a
5//! [`Splitter`](crate::splitter::Splitter).
6//!
7//! Mirrors the `SceneModel = Rc<RefCell<…>>` handle pattern: cloning a
8//! `SplitterModel` produces a **second handle to the same data**, so the
9//! app keeps a clone to read/mutate/persist while the widget renders it,
10//! and a future `DockingLayout` composes a tree of them. Every mutator
11//! takes `&self`, borrows the inner `RefCell` mutably, mutates, drops the
12//! borrow, then bumps a `version: Signal<u64>` — the widget binds that
13//! signal at `BindingLevel::Relayout`, so any external change reflows the
14//! panes with no rebuild.
15//!
16//! ## Source of truth: pixel sizes
17//!
18//! Each pane stores an absolute `stored_size` (logical px along the main
19//! axis). This is the user's intent. The widget projects it onto the
20//! current bounds every layout pass via the pure
21//! [`distribute`](super::distribute::distribute) function; a container
22//! resize never writes back, so drag positions survive resizes. Stored
23//! sizes change **only** on drag, programmatic mutation, or structural
24//! insert/remove.
25//!
26//! ## Borrow / observer contract
27//!
28//! `version.set` snapshots its observers and releases the signal's cell
29//! before invoking them, and every mutator drops its `RefCell` borrow
30//! before bumping. So the one rule (same as `SceneModel`) is: **an
31//! observer on [`version`](SplitterModel::version) must not mutate the
32//! model re-entrantly from inside its own callback.** The widget's
33//! observers only read the model and set their own signals, so they are
34//! safe.
35
36use std::cell::RefCell;
37use std::rc::Rc;
38
39use serde::{Deserialize, Serialize};
40use teksilo_core::signal::Signal;
41use teksilo_settings::Versioned;
42use teksilo_tokens::Orientation;
43
44/// Default gutter (handle) thickness in logical px. Resolved into the
45/// model at construction; override with [`SplitterModel::set_gutter_thickness`].
46pub const SPLITTER_GUTTER_THICKNESS: f32 = 6.0;
47/// Default minimum pane size in logical px.
48pub const SPLITTER_MIN_PANE_SIZE: f32 = 96.0;
49/// Default keyboard resize step in logical px (per arrow press).
50pub const SPLITTER_KEYBOARD_STEP: f32 = 24.0;
51/// Default drag-past-min snap-to-collapse threshold in logical px.
52pub const SPLITTER_SNAP_OFFSET: f32 = 30.0;
53
54// ---------------------------------------------------------------------
55// Pane descriptor (construction-time per-pane config)
56// ---------------------------------------------------------------------
57
58/// Per-pane configuration passed to [`SplitterModel::from_panes`] /
59/// [`SplitterModel::insert_pane`]. Public fields + [`Default`] so it can
60/// be built with struct-literal `..Default::default()` syntax, or via the
61/// fluent setters.
62#[derive(Debug, Clone)]
63pub struct PaneDescriptor {
64    /// Initial main-axis size in px. `None` ⇒ take an equal share (the
65    /// first layout equalizes via the stretch path).
66    pub initial_size: Option<f32>,
67    /// Hard compression floor in px.
68    pub min_size: f32,
69    /// Optional growth ceiling in px.
70    pub max_size: Option<f32>,
71    /// Container-resize slack weight (Qt `setStretchFactor`). `0.0` ⇒
72    /// rigid (keeps its size on resize); `>0` ⇒ absorbs slack ∝ weight.
73    pub stretch: f32,
74    /// Whether the user may collapse this pane (drag-snap / double-click /
75    /// keyboard). Programmatic [`set_collapsed`](SplitterModel::set_collapsed)
76    /// ignores this flag (it governs *interactive* collapse only, like Qt's
77    /// `childrenCollapsible`).
78    pub collapsible: bool,
79    /// Initial collapsed state.
80    pub collapsed: bool,
81    /// The main-axis size a collapsed pane folds down to (default `0` ⇒ fully
82    /// gone). Set this to keep a sliver visible while collapsed — e.g. an
83    /// accordion's header height, so the pane shrinks to just its header and
84    /// can be re-expanded from there. The pane restores to its prior size on
85    /// expand regardless.
86    pub collapsed_size: f32,
87    /// Whether the pane is present at all. Unlike `collapsed` (which folds
88    /// the pane but keeps its grabbable gutter), a hidden pane removes both
89    /// the pane *and* an adjacent gutter from the layout — it reads as
90    /// absent. Toggled reactively via
91    /// [`set_pane_visible`](SplitterModel::set_pane_visible).
92    pub visible: bool,
93}
94
95impl Default for PaneDescriptor {
96    fn default() -> Self {
97        Self {
98            initial_size: None,
99            min_size: SPLITTER_MIN_PANE_SIZE,
100            max_size: None,
101            stretch: 1.0,
102            collapsible: false,
103            collapsed: false,
104            collapsed_size: 0.0,
105            visible: true,
106        }
107    }
108}
109
110impl PaneDescriptor {
111    pub fn new() -> Self {
112        Self::default()
113    }
114    pub fn size(mut self, size: f32) -> Self {
115        self.initial_size = Some(size);
116        self
117    }
118    pub fn min_size(mut self, min: f32) -> Self {
119        self.min_size = min;
120        self
121    }
122    pub fn max_size(mut self, max: f32) -> Self {
123        self.max_size = Some(max);
124        self
125    }
126    pub fn stretch(mut self, stretch: f32) -> Self {
127        self.stretch = stretch;
128        self
129    }
130    pub fn collapsible(mut self, collapsible: bool) -> Self {
131        self.collapsible = collapsible;
132        self
133    }
134    pub fn collapsed(mut self, collapsed: bool) -> Self {
135        self.collapsed = collapsed;
136        self
137    }
138    /// Size a collapsed pane folds down to (default `0`). See
139    /// [`collapsed_size`](Self::collapsed_size).
140    pub fn collapsed_size(mut self, px: f32) -> Self {
141        self.collapsed_size = px.max(0.0);
142        self
143    }
144    pub fn visible(mut self, visible: bool) -> Self {
145        self.visible = visible;
146        self
147    }
148}
149
150// ---------------------------------------------------------------------
151// Internal pane entry + immutable snapshot for the sizing engine
152// ---------------------------------------------------------------------
153
154#[derive(Debug, Clone)]
155struct PaneEntry {
156    stored_size: f32,
157    min_size: f32,
158    max_size: Option<f32>,
159    stretch: f32,
160    collapsible: bool,
161    collapsed: bool,
162    collapsed_size: f32,
163    visible: bool,
164}
165
166impl PaneEntry {
167    fn from_descriptor(d: &PaneDescriptor, fallback_size: f32) -> Self {
168        let min = d.min_size.max(0.0);
169        // Enforce max ≥ min on the way in so `distribute` never has to
170        // resolve an impossible [min,max].
171        let max = d.max_size.map(|m| m.max(min));
172        let stored = d.initial_size.unwrap_or(fallback_size).max(0.0);
173        Self {
174            stored_size: stored,
175            min_size: min,
176            max_size: max,
177            stretch: d.stretch.max(0.0),
178            collapsible: d.collapsible,
179            collapsed: d.collapsed,
180            collapsed_size: d.collapsed_size.max(0.0),
181            visible: d.visible,
182        }
183    }
184}
185
186/// Immutable per-pane view handed to the pure `distribute` sizing
187/// function (the internal `splitter::distribute` engine).
188#[derive(Debug, Clone, Copy, PartialEq)]
189pub struct PaneSnapshot {
190    pub stored_size: f32,
191    pub min_size: f32,
192    pub max_size: Option<f32>,
193    pub stretch: f32,
194    pub collapsed: bool,
195    pub collapsed_size: f32,
196    pub visible: bool,
197}
198
199// ---------------------------------------------------------------------
200// Serde DTO (export / import — the persistence surface)
201// ---------------------------------------------------------------------
202
203/// Persistable per-pane layout state. Captures the user-controllable
204/// values (size + collapsed); structural config (min/max/stretch/
205/// collapsible) is app-declared and not serialized — Qt `saveState`
206/// parity.
207#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
208pub struct PaneState {
209    pub stored_size: f32,
210    pub collapsed: bool,
211}
212
213/// Full serializable snapshot of a [`SplitterModel`]'s sizes + collapsed
214/// flags. Round-trips through [`SplitterModel::export_state`] /
215/// [`import_state`](SplitterModel::import_state) and implements
216/// [`Versioned`] so apps persist it through
217/// `SettingsFile<SplitterState>` + `Migrator` (TOML).
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
219pub struct SplitterState {
220    #[serde(default = "default_version")]
221    pub version: u32,
222    #[serde(default)]
223    pub panes: Vec<PaneState>,
224}
225
226fn default_version() -> u32 {
227    SplitterState::CURRENT_VERSION
228}
229
230impl Default for SplitterState {
231    fn default() -> Self {
232        Self {
233            version: SplitterState::CURRENT_VERSION,
234            panes: Vec::new(),
235        }
236    }
237}
238
239impl Versioned for SplitterState {
240    const CURRENT_VERSION: u32 = 1;
241    fn version(&self) -> u32 {
242        self.version
243    }
244    fn set_version(&mut self, v: u32) {
245        self.version = v;
246    }
247}
248
249// ---------------------------------------------------------------------
250// The model handle
251// ---------------------------------------------------------------------
252
253struct SplitterModelInner {
254    panes: Vec<PaneEntry>,
255    orientation: Orientation,
256    gutter_thickness: f32,
257    keyboard_step_px: f32,
258    snap_offset: f32,
259    version: Signal<u64>,
260    /// `true` ⇒ the next collapse-flag change should *animate*; `false`
261    /// ⇒ snap instantly (drag-driven). Read-and-reset by the widget's
262    /// collapse effect via [`consume_animate_flag`](SplitterModel::consume_animate_flag).
263    animate_next_collapse: bool,
264}
265
266/// A shared, cloneable handle to a splitter's layout state. `Clone` =
267/// share-by-handle (cheap `Rc` bump).
268pub struct SplitterModel(Rc<RefCell<SplitterModelInner>>);
269
270impl Clone for SplitterModel {
271    fn clone(&self) -> Self {
272        Self(self.0.clone())
273    }
274}
275
276impl std::fmt::Debug for SplitterModel {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        match self.0.try_borrow() {
279            Ok(inner) => f
280                .debug_struct("SplitterModel")
281                .field("handles", &Rc::strong_count(&self.0))
282                .field("panes", &inner.panes.len())
283                .field("orientation", &inner.orientation)
284                .finish(),
285            Err(_) => f
286                .debug_struct("SplitterModel")
287                .field("handles", &Rc::strong_count(&self.0))
288                .field("panes", &"<borrowed>")
289                .finish(),
290        }
291    }
292}
293
294impl SplitterModel {
295    // ---- Construction -------------------------------------------------
296
297    /// `n` equal-share panes (each `stretch = 1`, `min = SPLITTER_MIN_PANE_SIZE`).
298    pub fn new(n: usize, orientation: Orientation) -> Self {
299        let panes = (0..n)
300            .map(|_| PaneEntry::from_descriptor(&PaneDescriptor::default(), 0.0))
301            .collect();
302        Self::from_inner(panes, orientation)
303    }
304
305    /// Build from explicit per-pane descriptors.
306    pub fn from_panes(panes: Vec<PaneDescriptor>, orientation: Orientation) -> Self {
307        let entries = panes
308            .iter()
309            .map(|d| PaneEntry::from_descriptor(d, d.initial_size.unwrap_or(0.0)))
310            .collect();
311        Self::from_inner(entries, orientation)
312    }
313
314    fn from_inner(panes: Vec<PaneEntry>, orientation: Orientation) -> Self {
315        Self(Rc::new(RefCell::new(SplitterModelInner {
316            panes,
317            orientation,
318            gutter_thickness: SPLITTER_GUTTER_THICKNESS,
319            keyboard_step_px: SPLITTER_KEYBOARD_STEP,
320            snap_offset: SPLITTER_SNAP_OFFSET,
321            version: Signal::new(0),
322            animate_next_collapse: true,
323        })))
324    }
325
326    /// Number of distinct handles to this model (1 = unshared).
327    pub fn handle_count(&self) -> usize {
328        Rc::strong_count(&self.0)
329    }
330
331    // ---- Version bump -------------------------------------------------
332
333    fn bump_version(&self) {
334        // Clone the signal out and drop the borrow before `set`, so an
335        // observer may safely read the model from its callback.
336        let version = self.0.borrow().version.clone();
337        version.set(version.get().wrapping_add(1));
338    }
339
340    // ---- Per-pane size mutators --------------------------------------
341
342    pub fn set_stored_size(&self, index: usize, size: f32) {
343        {
344            let mut inner = self.0.borrow_mut();
345            let Some(p) = inner.panes.get_mut(index) else {
346                return;
347            };
348            p.stored_size = size.max(0.0);
349        }
350        self.bump_version();
351    }
352
353    /// Like [`set_stored_size`](Self::set_stored_size) but **without** a version
354    /// bump — for writes made from inside a layout/effect pass that is already
355    /// relaying out (e.g. capturing the displayed size as the collapse
356    /// reference), where a bump would re-enter the effect.
357    pub fn set_stored_size_silent(&self, index: usize, size: f32) {
358        let mut inner = self.0.borrow_mut();
359        if let Some(p) = inner.panes.get_mut(index) {
360            p.stored_size = size.max(0.0);
361        }
362    }
363
364    /// Set both sides of handle `index` (panes `index` and `index+1`) in
365    /// one mutation — a single version bump, so a drag produces exactly
366    /// one relayout per move.
367    pub fn set_pair_sizes(&self, index: usize, size_a: f32, size_b: f32) {
368        {
369            let mut inner = self.0.borrow_mut();
370            if index + 1 >= inner.panes.len() {
371                return;
372            }
373            inner.panes[index].stored_size = size_a.max(0.0);
374            inner.panes[index + 1].stored_size = size_b.max(0.0);
375        }
376        self.bump_version();
377    }
378
379    pub fn set_min_size(&self, index: usize, min: f32) {
380        {
381            let mut inner = self.0.borrow_mut();
382            let Some(p) = inner.panes.get_mut(index) else {
383                return;
384            };
385            p.min_size = min.max(0.0);
386            // Keep max ≥ min.
387            if let Some(m) = p.max_size {
388                p.max_size = Some(m.max(p.min_size));
389            }
390        }
391        self.bump_version();
392    }
393
394    pub fn set_max_size(&self, index: usize, max: Option<f32>) {
395        {
396            let mut inner = self.0.borrow_mut();
397            let Some(p) = inner.panes.get_mut(index) else {
398                return;
399            };
400            p.max_size = max.map(|m| m.max(p.min_size));
401        }
402        self.bump_version();
403    }
404
405    pub fn set_stretch(&self, index: usize, stretch: f32) {
406        {
407            let mut inner = self.0.borrow_mut();
408            let Some(p) = inner.panes.get_mut(index) else {
409                return;
410            };
411            p.stretch = stretch.max(0.0);
412        }
413        self.bump_version();
414    }
415
416    pub fn set_collapsible(&self, index: usize, collapsible: bool) {
417        {
418            let mut inner = self.0.borrow_mut();
419            let Some(p) = inner.panes.get_mut(index) else {
420                return;
421            };
422            p.collapsible = collapsible;
423        }
424        self.bump_version();
425    }
426
427    // ---- Collapse mutators -------------------------------------------
428
429    /// Programmatically collapse/expand pane `index`, *animated*. Ignores
430    /// the `collapsible` flag (that flag only gates interactive triggers).
431    pub fn set_collapsed(&self, index: usize, collapsed: bool) {
432        self.set_collapsed_inner(index, collapsed, true);
433    }
434
435    /// Collapse/expand pane `index` *instantly* (no tween). Used by the
436    /// drag handlers — the pointer is already the motion.
437    pub fn set_collapsed_immediate(&self, index: usize, collapsed: bool) {
438        self.set_collapsed_inner(index, collapsed, false);
439    }
440
441    /// Toggle pane `index`'s collapsed state, animated.
442    pub fn toggle_collapsed(&self, index: usize) {
443        let current = self.is_collapsed(index);
444        self.set_collapsed(index, !current);
445    }
446
447    /// Set the size pane `index` folds down to when collapsed (default `0`).
448    /// See [`PaneDescriptor::collapsed_size`]. No version bump on its own — it
449    /// only affects the next collapse.
450    pub fn set_collapsed_size(&self, index: usize, px: f32) {
451        let mut inner = self.0.borrow_mut();
452        if let Some(p) = inner.panes.get_mut(index) {
453            p.collapsed_size = px.max(0.0);
454        }
455    }
456
457    fn set_collapsed_inner(&self, index: usize, collapsed: bool, animate: bool) {
458        {
459            let mut inner = self.0.borrow_mut();
460            let Some(p) = inner.panes.get_mut(index) else {
461                return;
462            };
463            if p.collapsed == collapsed {
464                return; // no-op — avoid a spurious version bump
465            }
466            p.collapsed = collapsed;
467            inner.animate_next_collapse = animate;
468        }
469        self.bump_version();
470    }
471
472    /// Show or hide pane `index` (animated). A hidden pane removes both the
473    /// pane and an adjacent gutter from the layout — it reads as absent,
474    /// unlike a collapsed pane (which keeps its grabbable gutter). The pane
475    /// must be pre-mounted in the `Splitter`; this is the reactive "add /
476    /// remove a pane from a fixed set" trick (no rebuild).
477    pub fn set_pane_visible(&self, index: usize, visible: bool) {
478        {
479            let mut inner = self.0.borrow_mut();
480            let Some(p) = inner.panes.get_mut(index) else {
481                return;
482            };
483            if p.visible == visible {
484                return;
485            }
486            p.visible = visible;
487            inner.animate_next_collapse = true;
488        }
489        self.bump_version();
490    }
491
492    pub fn is_pane_visible(&self, index: usize) -> bool {
493        self.0
494            .borrow()
495            .panes
496            .get(index)
497            .map(|p| p.visible)
498            .unwrap_or(false)
499    }
500
501    /// Read-and-reset the "animate the next collapse change?" latch. The
502    /// widget's collapse effect calls this once per version bump; it
503    /// resets to `true` so the default (programmatic) path animates.
504    pub fn consume_animate_flag(&self) -> bool {
505        let mut inner = self.0.borrow_mut();
506        let f = inner.animate_next_collapse;
507        inner.animate_next_collapse = true;
508        f
509    }
510
511    // ---- Structural mutators -----------------------------------------
512
513    /// Insert a pane at `index` (clamped to `[0, len]`). A `None`
514    /// `initial_size` takes the average of the existing panes' sizes; the
515    /// next layout rebalances. The app must rebuild the `Splitter` widget
516    /// to supply the new pane's content (retained-mode: changing a
517    /// container's child *set* is a rebuild; the model keeps the
518    /// persistent size/collapse state across it).
519    pub fn insert_pane(&self, index: usize, desc: PaneDescriptor) {
520        {
521            let mut inner = self.0.borrow_mut();
522            let idx = index.min(inner.panes.len());
523            let fallback = if inner.panes.is_empty() {
524                SPLITTER_MIN_PANE_SIZE
525            } else {
526                inner.panes.iter().map(|p| p.stored_size).sum::<f32>() / inner.panes.len() as f32
527            };
528            inner
529                .panes
530                .insert(idx, PaneEntry::from_descriptor(&desc, fallback));
531        }
532        self.bump_version();
533    }
534
535    /// Remove the pane at `index` (no-op if out of range). The app must
536    /// rebuild the `Splitter` widget to drop the corresponding content.
537    pub fn remove_pane(&self, index: usize) {
538        {
539            let mut inner = self.0.borrow_mut();
540            if index >= inner.panes.len() {
541                return;
542            }
543            inner.panes.remove(index);
544        }
545        self.bump_version();
546    }
547
548    /// Replace the metadata of pane `index` (keeps its current size unless
549    /// the descriptor specifies one).
550    pub fn replace_pane_desc(&self, index: usize, desc: PaneDescriptor) {
551        {
552            let mut inner = self.0.borrow_mut();
553            let Some(p) = inner.panes.get_mut(index) else {
554                return;
555            };
556            let fallback = p.stored_size;
557            *p = PaneEntry::from_descriptor(&desc, fallback);
558        }
559        self.bump_version();
560    }
561
562    // ---- Global mutators ---------------------------------------------
563
564    pub fn set_gutter_thickness(&self, thickness: f32) {
565        {
566            self.0.borrow_mut().gutter_thickness = thickness.max(1.0);
567        }
568        self.bump_version();
569    }
570
571    pub fn set_snap_offset(&self, offset: f32) {
572        {
573            self.0.borrow_mut().snap_offset = offset.max(0.0);
574        }
575        self.bump_version();
576    }
577
578    pub fn set_keyboard_step_px(&self, step: f32) {
579        {
580            self.0.borrow_mut().keyboard_step_px = step.max(1.0);
581        }
582        self.bump_version();
583    }
584
585    pub fn set_orientation(&self, orientation: Orientation) {
586        {
587            self.0.borrow_mut().orientation = orientation;
588        }
589        self.bump_version();
590    }
591
592    // ---- Queries ------------------------------------------------------
593
594    pub fn pane_count(&self) -> usize {
595        self.0.borrow().panes.len()
596    }
597    pub fn stored_size(&self, index: usize) -> f32 {
598        self.0
599            .borrow()
600            .panes
601            .get(index)
602            .map(|p| p.stored_size)
603            .unwrap_or(0.0)
604    }
605    pub fn min_size(&self, index: usize) -> f32 {
606        self.0
607            .borrow()
608            .panes
609            .get(index)
610            .map(|p| p.min_size)
611            .unwrap_or(0.0)
612    }
613    pub fn max_size(&self, index: usize) -> Option<f32> {
614        self.0.borrow().panes.get(index).and_then(|p| p.max_size)
615    }
616    pub fn stretch(&self, index: usize) -> f32 {
617        self.0
618            .borrow()
619            .panes
620            .get(index)
621            .map(|p| p.stretch)
622            .unwrap_or(0.0)
623    }
624    pub fn is_collapsible(&self, index: usize) -> bool {
625        self.0
626            .borrow()
627            .panes
628            .get(index)
629            .map(|p| p.collapsible)
630            .unwrap_or(false)
631    }
632    /// The size pane `index` folds to when collapsed (default `0`). See
633    /// [`PaneDescriptor::collapsed_size`].
634    pub fn collapsed_size(&self, index: usize) -> f32 {
635        self.0
636            .borrow()
637            .panes
638            .get(index)
639            .map(|p| p.collapsed_size)
640            .unwrap_or(0.0)
641    }
642    pub fn is_collapsed(&self, index: usize) -> bool {
643        self.0
644            .borrow()
645            .panes
646            .get(index)
647            .map(|p| p.collapsed)
648            .unwrap_or(false)
649    }
650    pub fn orientation(&self) -> Orientation {
651        self.0.borrow().orientation
652    }
653    pub fn gutter_thickness(&self) -> f32 {
654        self.0.borrow().gutter_thickness
655    }
656    pub fn snap_offset(&self) -> f32 {
657        self.0.borrow().snap_offset
658    }
659    pub fn keyboard_step_px(&self) -> f32 {
660        self.0.borrow().keyboard_step_px
661    }
662
663    /// The reactive version signal. The `Splitter` widget binds this at
664    /// `BindingLevel::Relayout`.
665    pub fn version(&self) -> Signal<u64> {
666        self.0.borrow().version.clone()
667    }
668
669    /// Immutable per-pane snapshot for the pure sizing engine.
670    pub fn pane_snapshots(&self) -> Vec<PaneSnapshot> {
671        self.0
672            .borrow()
673            .panes
674            .iter()
675            .map(|p| PaneSnapshot {
676                stored_size: p.stored_size,
677                min_size: p.min_size,
678                max_size: p.max_size,
679                stretch: p.stretch,
680                collapsed: p.collapsed,
681                collapsed_size: p.collapsed_size,
682                visible: p.visible,
683            })
684            .collect()
685    }
686
687    // ---- Import / export ---------------------------------------------
688
689    /// Snapshot the per-pane sizes + collapsed flags into a serializable
690    /// [`SplitterState`].
691    pub fn export_state(&self) -> SplitterState {
692        let inner = self.0.borrow();
693        SplitterState {
694            version: SplitterState::CURRENT_VERSION,
695            panes: inner
696                .panes
697                .iter()
698                .map(|p| PaneState {
699                    stored_size: p.stored_size,
700                    collapsed: p.collapsed,
701                })
702                .collect(),
703        }
704    }
705
706    /// Restore sizes + collapsed flags from a [`SplitterState`]. Returns
707    /// `false` (and changes nothing) if the pane count doesn't match — the
708    /// structural config must be reconstructed first. Restoration is
709    /// instant (collapsed panes don't animate open on load).
710    pub fn import_state(&self, state: &SplitterState) -> bool {
711        let ok = {
712            let mut inner = self.0.borrow_mut();
713            if state.panes.len() != inner.panes.len() {
714                false
715            } else {
716                for (p, s) in inner.panes.iter_mut().zip(&state.panes) {
717                    p.stored_size = s.stored_size.max(0.0);
718                    p.collapsed = s.collapsed;
719                }
720                inner.animate_next_collapse = false;
721                true
722            }
723        };
724        if ok {
725            self.bump_version();
726        }
727        ok
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734
735    #[test]
736    fn clone_shares_state() {
737        let a = SplitterModel::new(3, Orientation::Horizontal);
738        let b = a.clone();
739        assert_eq!(b.pane_count(), 3);
740        a.set_stored_size(0, 200.0);
741        assert_eq!(b.stored_size(0), 200.0);
742        assert_eq!(a.handle_count(), 2);
743    }
744
745    #[test]
746    fn version_bumps_on_mutation() {
747        let m = SplitterModel::new(2, Orientation::Horizontal);
748        let v = m.version();
749        let v0 = v.get();
750        m.set_stored_size(0, 150.0);
751        assert_ne!(v.get(), v0);
752        // No-op collapse change must NOT bump.
753        let v1 = v.get();
754        m.set_collapsed(0, false); // already false
755        assert_eq!(v.get(), v1);
756    }
757
758    #[test]
759    fn export_import_round_trips() {
760        let m = SplitterModel::new(3, Orientation::Horizontal);
761        m.set_stored_size(0, 120.0);
762        m.set_stored_size(1, 340.0);
763        m.set_collapsed(2, true);
764        let state = m.export_state();
765
766        let restored = SplitterModel::new(3, Orientation::Horizontal);
767        assert!(restored.import_state(&state));
768        assert_eq!(restored.stored_size(0), 120.0);
769        assert_eq!(restored.stored_size(1), 340.0);
770        assert!(restored.is_collapsed(2));
771    }
772
773    #[test]
774    fn import_rejects_pane_count_mismatch() {
775        let m = SplitterModel::new(3, Orientation::Horizontal);
776        let state = m.export_state();
777        let two = SplitterModel::new(2, Orientation::Horizontal);
778        assert!(!two.import_state(&state));
779    }
780
781    #[test]
782    fn insert_remove_change_count() {
783        let m = SplitterModel::new(2, Orientation::Horizontal);
784        m.insert_pane(1, PaneDescriptor::new().size(100.0));
785        assert_eq!(m.pane_count(), 3);
786        assert_eq!(m.stored_size(1), 100.0);
787        m.remove_pane(0);
788        assert_eq!(m.pane_count(), 2);
789    }
790
791    #[test]
792    fn max_size_enforced_ge_min() {
793        let m = SplitterModel::from_panes(
794            vec![PaneDescriptor::new().min_size(200.0).max_size(100.0)],
795            Orientation::Horizontal,
796        );
797        // max was clamped up to min.
798        assert_eq!(m.max_size(0), Some(200.0));
799    }
800
801    #[test]
802    fn animate_flag_consumed_and_resets() {
803        let m = SplitterModel::new(2, Orientation::Horizontal);
804        m.set_collapsed_immediate(0, true);
805        assert!(!m.consume_animate_flag()); // immediate path
806        // After consuming, it defaults back to animated.
807        assert!(m.consume_animate_flag());
808    }
809}