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