Skip to main content

teksilo_widgets/docking/
state.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Serializable layout state for [`DockingModel`](super::DockingModel).
5//!
6//! Only **user-controllable** values are persisted (per-side size /
7//! visibility / presentation / selection and the full tab → arrangement
8//! tree, plus corner ownership). App-config — rail thickness, minimum sizes,
9//! content factories, header actions — is declared each run and reconstructed
10//! (Qt `saveState` parity). Drops into the framework persistence layer via
11//! [`teksilo_settings::Versioned`] + `SettingsFile<DockLayoutState>`.
12
13use serde::{Deserialize, Serialize};
14use teksilo_settings::Versioned;
15
16use crate::splitter::SplitterState;
17
18use super::geometry::CornerOwners;
19use super::model::TabPresentation;
20
21/// One tab's persisted state: its Splitter sizing + the dock id in each pane
22/// (one dock per Splitter pane).
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct DockTabState {
25    pub id: u64,
26    #[serde(default)]
27    pub splitter: SplitterState,
28    /// One dock id per Splitter pane.
29    #[serde(default)]
30    pub panes: Vec<u64>,
31    /// User-hidden activity ("Hide" / unchecked in the activities list).
32    #[serde(default)]
33    pub hidden: bool,
34}
35
36/// One side's persisted state.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct DockSideState {
39    pub presentation: TabPresentation,
40    pub size_px: f32,
41    pub visible: bool,
42    #[serde(default)]
43    pub selected_tab: usize,
44    #[serde(default)]
45    pub tabs: Vec<DockTabState>,
46    /// Activity-bar item size: `0` = configured/default, `1` = compact,
47    /// `2` = icon + 90°-rotated label.
48    #[serde(default)]
49    pub rail_size: usize,
50    /// Dock-tab display mode: `0` = text, `1` = icon, `2` = icon + text.
51    #[serde(default)]
52    pub tab_display: usize,
53}
54
55impl Default for DockSideState {
56    fn default() -> Self {
57        Self {
58            presentation: TabPresentation::Strip,
59            size_px: 240.0,
60            visible: false,
61            selected_tab: 0,
62            tabs: Vec::new(),
63            rail_size: 0,
64            tab_display: 0,
65        }
66    }
67}
68
69/// The full serializable snapshot of a [`DockingModel`](super::DockingModel).
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct DockLayoutState {
72    #[serde(default = "default_version")]
73    pub version: u32,
74    #[serde(default)]
75    pub leading: DockSideState,
76    #[serde(default)]
77    pub trailing: DockSideState,
78    #[serde(default)]
79    pub top: DockSideState,
80    #[serde(default)]
81    pub bottom: DockSideState,
82    #[serde(default)]
83    pub corners: CornerOwners,
84}
85
86fn default_version() -> u32 {
87    DockLayoutState::CURRENT_VERSION
88}
89
90impl Default for DockLayoutState {
91    fn default() -> Self {
92        Self {
93            version: DockLayoutState::CURRENT_VERSION,
94            leading: DockSideState::default(),
95            trailing: DockSideState::default(),
96            top: DockSideState::default(),
97            bottom: DockSideState::default(),
98            corners: CornerOwners::default(),
99        }
100    }
101}
102
103impl Versioned for DockLayoutState {
104    const CURRENT_VERSION: u32 = 1;
105    fn version(&self) -> u32 {
106        self.version
107    }
108    fn set_version(&mut self, v: u32) {
109        self.version = v;
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use teksilo_settings::{MigrationError, Migrator};
117
118    /// A representative non-default snapshot exercising every pane shape.
119    fn sample_state() -> DockLayoutState {
120        DockLayoutState {
121            version: DockLayoutState::CURRENT_VERSION,
122            leading: DockSideState {
123                presentation: TabPresentation::Rail,
124                size_px: 280.0,
125                visible: true,
126                selected_tab: 1,
127                tabs: vec![
128                    DockTabState {
129                        id: 1,
130                        splitter: SplitterState::default(),
131                        panes: vec![10],
132                        hidden: false,
133                    },
134                    DockTabState {
135                        id: 2,
136                        splitter: SplitterState::default(),
137                        // Two docks split side-by-side within one tab.
138                        panes: vec![20, 21],
139                        hidden: true,
140                    },
141                ],
142                rail_size: 2,
143                tab_display: 2,
144            },
145            trailing: DockSideState::default(),
146            top: DockSideState::default(),
147            bottom: DockSideState::default(),
148            corners: CornerOwners::default(),
149        }
150    }
151
152    #[test]
153    fn toml_round_trips_through_the_migrator() {
154        // The full persistence loop: serialize → migrate (no steps needed for
155        // a current-version payload) → deserialize, byte-for-byte equal.
156        let state = sample_state();
157        let value = toml::Value::try_from(&state).expect("serialize");
158        let restored = Migrator::<DockLayoutState>::new()
159            .run(value)
160            .expect("migrate");
161        assert_eq!(restored, state);
162    }
163
164    #[test]
165    fn missing_version_field_loads_as_current() {
166        // A pre-versioning file omits `version`; the migrator treats it as the
167        // baseline and serde stamps the current version on deserialize.
168        let state = sample_state();
169        let mut value = toml::Value::try_from(&state).unwrap();
170        value.as_table_mut().unwrap().remove("version");
171        let restored = Migrator::<DockLayoutState>::new().run(value).unwrap();
172        assert_eq!(restored.version, DockLayoutState::CURRENT_VERSION);
173        assert_eq!(restored.leading.tabs.len(), 2, "payload survives");
174    }
175
176    #[test]
177    fn newer_than_current_is_refused() {
178        // A file written by a future build must be refused, not silently
179        // truncated.
180        let mut value = toml::Value::try_from(sample_state()).unwrap();
181        value
182            .as_table_mut()
183            .unwrap()
184            .insert("version".into(), toml::Value::Integer(99));
185        let err = Migrator::<DockLayoutState>::new().run(value).unwrap_err();
186        assert!(
187            matches!(
188                err,
189                MigrationError::NewerThanCurrent {
190                    on_disk: 99,
191                    current: 1
192                }
193            ),
194            "expected NewerThanCurrent, got {err:?}"
195        );
196    }
197
198    #[test]
199    fn partial_legacy_toml_fills_additive_defaults() {
200        // Schema evolution adds optional fields over time; an older file with
201        // only a handful of keys must still load, the rest defaulting.
202        let src = r#"
203            [leading]
204            presentation = "Rail"
205            size_px = 300.0
206            visible = true
207        "#;
208        let value: toml::Value = toml::from_str(src).expect("parse");
209        let restored = Migrator::<DockLayoutState>::new().run(value).unwrap();
210        assert_eq!(restored.version, DockLayoutState::CURRENT_VERSION);
211        assert!(restored.leading.visible);
212        assert_eq!(restored.leading.size_px, 300.0);
213        assert!(restored.leading.tabs.is_empty(), "tabs default to empty");
214        assert_eq!(
215            restored.trailing,
216            DockSideState::default(),
217            "absent sides default"
218        );
219    }
220
221    #[test]
222    fn migration_step_promotes_a_v0_payload() {
223        // Simulate a v0 file that stored a side's size under the old key
224        // `size`; a 0→1 step renames it to `size_px`. Without the step the
225        // payload fails to deserialize (`size_px` is a required field), so this
226        // proves the step both fires and is load-bearing.
227        let mut value = toml::Value::try_from(sample_state()).unwrap();
228        {
229            let t = value.as_table_mut().unwrap();
230            t.insert("version".into(), toml::Value::Integer(0));
231            let leading = t.get_mut("leading").unwrap().as_table_mut().unwrap();
232            let size = leading.remove("size_px").unwrap();
233            leading.insert("size".into(), size);
234        }
235        let migrator = Migrator::<DockLayoutState>::new().step(0, |mut v| {
236            if let Some(leading) = v.get_mut("leading").and_then(|l| l.as_table_mut())
237                && let Some(size) = leading.remove("size")
238            {
239                leading.insert("size_px".into(), size);
240            }
241            Ok(v)
242        });
243        let restored = migrator.run(value).expect("migrate v0→v1");
244        assert_eq!(restored.version, 1);
245        assert_eq!(restored.leading.size_px, 280.0);
246    }
247}