1use serde::{Deserialize, Serialize};
14use teksilo_settings::Versioned;
15
16use crate::splitter::SplitterState;
17
18use super::geometry::CornerOwners;
19use super::model::TabPresentation;
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct DockTabState {
25 pub id: u64,
26 #[serde(default)]
27 pub splitter: SplitterState,
28 #[serde(default)]
30 pub panes: Vec<u64>,
31 #[serde(default)]
33 pub hidden: bool,
34}
35
36#[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 #[serde(default)]
49 pub rail_size: usize,
50 #[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#[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 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 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 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 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 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 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 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}