1use serde::{Deserialize, Serialize};
2
3use crate::{AppWindowId, Axis, DockGraph, DockNode, DockNodeId, PanelKey};
4
5pub const DOCK_LAYOUT_VERSION: u32 = 2;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct DockLayout {
9 pub layout_version: u32,
10 pub windows: Vec<DockLayoutWindow>,
11 pub nodes: Vec<DockLayoutNode>,
12}
13
14impl DockLayout {
15 pub fn new(windows: Vec<DockLayoutWindow>, nodes: Vec<DockLayoutNode>) -> Self {
16 Self {
17 layout_version: DOCK_LAYOUT_VERSION,
18 windows,
19 nodes,
20 }
21 }
22
23 pub fn validate(&self) -> Result<(), DockLayoutValidationError> {
24 use std::collections::HashMap;
25
26 if self.layout_version != DOCK_LAYOUT_VERSION {
27 return Err(DockLayoutValidationError {
28 kind: DockLayoutValidationErrorKind::UnsupportedVersion {
29 expected: DOCK_LAYOUT_VERSION,
30 found: self.layout_version,
31 },
32 });
33 }
34
35 let mut by_id: HashMap<u32, &DockLayoutNode> = HashMap::new();
36 for node in &self.nodes {
37 let id = match node {
38 DockLayoutNode::Split { id, .. } => *id,
39 DockLayoutNode::Tabs { id, .. } => *id,
40 };
41 if by_id.insert(id, node).is_some() {
42 return Err(DockLayoutValidationError {
43 kind: DockLayoutValidationErrorKind::DuplicateNodeId { id },
44 });
45 }
46 }
47
48 for (id, node) in &by_id {
49 match node {
50 DockLayoutNode::Tabs { tabs, active, .. } => {
51 if tabs.is_empty() {
52 return Err(DockLayoutValidationError {
53 kind: DockLayoutValidationErrorKind::EmptyTabs { id: *id },
54 });
55 }
56 if *active >= tabs.len() {
57 return Err(DockLayoutValidationError {
58 kind: DockLayoutValidationErrorKind::TabsActiveOutOfBounds {
59 id: *id,
60 active: *active,
61 len: tabs.len(),
62 },
63 });
64 }
65 }
66 DockLayoutNode::Split {
67 children,
68 fractions,
69 ..
70 } => {
71 if children.is_empty() {
72 return Err(DockLayoutValidationError {
73 kind: DockLayoutValidationErrorKind::EmptySplitChildren { id: *id },
74 });
75 }
76 if children.len() != fractions.len() {
77 return Err(DockLayoutValidationError {
78 kind: DockLayoutValidationErrorKind::SplitFractionsLenMismatch {
79 id: *id,
80 children_len: children.len(),
81 fractions_len: fractions.len(),
82 },
83 });
84 }
85 for (index, f) in fractions.iter().copied().enumerate() {
86 if !f.is_finite() {
87 return Err(DockLayoutValidationError {
88 kind: DockLayoutValidationErrorKind::SplitNonFiniteFraction {
89 id: *id,
90 index,
91 value: f,
92 },
93 });
94 }
95 if f < 0.0 {
96 return Err(DockLayoutValidationError {
97 kind: DockLayoutValidationErrorKind::SplitNegativeFraction {
98 id: *id,
99 index,
100 value: f,
101 },
102 });
103 }
104 }
105 }
106 }
107 }
108
109 for node in by_id.values() {
110 if let DockLayoutNode::Split { children, .. } = node {
111 for child in children {
112 if !by_id.contains_key(child) {
113 return Err(DockLayoutValidationError {
114 kind: DockLayoutValidationErrorKind::MissingNodeId { id: *child },
115 });
116 }
117 }
118 }
119 }
120
121 #[derive(Clone, Copy, PartialEq, Eq)]
122 enum Mark {
123 Visiting,
124 Done,
125 }
126 let mut marks: HashMap<u32, Mark> = HashMap::new();
127
128 for start in by_id.keys().copied() {
129 if marks.contains_key(&start) {
130 continue;
131 }
132
133 #[derive(Clone, Copy)]
134 enum Step {
135 Enter(u32),
136 Exit(u32),
137 }
138
139 let mut stack: Vec<Step> = vec![Step::Enter(start)];
140 while let Some(step) = stack.pop() {
141 match step {
142 Step::Enter(id) => {
143 if marks.get(&id) == Some(&Mark::Done) {
144 continue;
145 }
146 if marks.get(&id) == Some(&Mark::Visiting) {
147 return Err(DockLayoutValidationError {
148 kind: DockLayoutValidationErrorKind::CycleDetected { id },
149 });
150 }
151 marks.insert(id, Mark::Visiting);
152 stack.push(Step::Exit(id));
153
154 if let Some(DockLayoutNode::Split { children, .. }) = by_id.get(&id) {
155 for child in children.iter().rev().copied() {
156 stack.push(Step::Enter(child));
157 }
158 }
159 }
160 Step::Exit(id) => {
161 marks.insert(id, Mark::Done);
162 }
163 }
164 }
165 }
166
167 for w in &self.windows {
168 if !by_id.contains_key(&w.root) {
169 return Err(DockLayoutValidationError {
170 kind: DockLayoutValidationErrorKind::WindowRootMissing {
171 logical_window_id: w.logical_window_id.clone(),
172 root: w.root,
173 },
174 });
175 }
176 for f in &w.floatings {
177 if !by_id.contains_key(&f.root) {
178 return Err(DockLayoutValidationError {
179 kind: DockLayoutValidationErrorKind::FloatingRootMissing {
180 logical_window_id: w.logical_window_id.clone(),
181 root: f.root,
182 },
183 });
184 }
185 }
186 }
187
188 Ok(())
189 }
190}
191
192#[derive(Debug, Clone, PartialEq)]
193pub struct DockLayoutValidationError {
194 pub kind: DockLayoutValidationErrorKind,
195}
196
197#[derive(Debug, Clone, PartialEq)]
198pub enum DockLayoutValidationErrorKind {
199 UnsupportedVersion {
200 expected: u32,
201 found: u32,
202 },
203 DuplicateNodeId {
204 id: u32,
205 },
206 MissingNodeId {
207 id: u32,
208 },
209 CycleDetected {
210 id: u32,
211 },
212 EmptyTabs {
213 id: u32,
214 },
215 TabsActiveOutOfBounds {
216 id: u32,
217 active: usize,
218 len: usize,
219 },
220 EmptySplitChildren {
221 id: u32,
222 },
223 SplitFractionsLenMismatch {
224 id: u32,
225 children_len: usize,
226 fractions_len: usize,
227 },
228 SplitNonFiniteFraction {
229 id: u32,
230 index: usize,
231 value: f32,
232 },
233 SplitNegativeFraction {
234 id: u32,
235 index: usize,
236 value: f32,
237 },
238 WindowRootMissing {
239 logical_window_id: String,
240 root: u32,
241 },
242 FloatingRootMissing {
243 logical_window_id: String,
244 root: u32,
245 },
246}
247
248impl std::fmt::Display for DockLayoutValidationError {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 write!(f, "dock layout validation error: {:?}", self.kind)
251 }
252}
253
254impl std::error::Error for DockLayoutValidationError {}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct DockLayoutWindow {
258 pub logical_window_id: String,
259 pub root: u32,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub placement: Option<DockWindowPlacement>,
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
263 pub floatings: Vec<DockLayoutFloatingWindow>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct DockLayoutFloatingWindow {
268 pub root: u32,
270 pub rect: DockRect,
272}
273
274#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
275pub struct DockRect {
276 pub x: f32,
277 pub y: f32,
278 pub w: f32,
279 pub h: f32,
280}
281
282impl DockRect {
283 pub fn from_rect(rect: crate::Rect) -> Self {
284 Self {
285 x: rect.origin.x.0,
286 y: rect.origin.y.0,
287 w: rect.size.width.0,
288 h: rect.size.height.0,
289 }
290 }
291
292 pub fn to_rect(self) -> crate::Rect {
293 crate::Rect::new(
294 crate::Point::new(crate::Px(self.x), crate::Px(self.y)),
295 crate::Size::new(crate::Px(self.w), crate::Px(self.h)),
296 )
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct DockWindowPlacement {
302 pub width: u32,
303 pub height: u32,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub x: Option<i32>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub y: Option<i32>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub monitor_hint: Option<String>,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(tag = "kind")]
314pub enum DockLayoutNode {
315 #[serde(rename = "split")]
316 Split {
317 id: u32,
318 axis: Axis,
319 children: Vec<u32>,
320 fractions: Vec<f32>,
321 },
322 #[serde(rename = "tabs")]
323 Tabs {
324 id: u32,
325 tabs: Vec<PanelKey>,
326 active: usize,
327 },
328}
329
330#[derive(Debug, Clone)]
331pub struct EditorDockLayoutSpec {
332 pub left_tabs: Vec<PanelKey>,
333 pub main_tabs: Vec<PanelKey>,
334 pub bottom_tabs: Vec<PanelKey>,
335 pub left_fraction: f32,
336 pub main_fraction: f32,
337 pub active_left: usize,
338 pub active_main: usize,
339 pub active_bottom: usize,
340}
341
342impl EditorDockLayoutSpec {
343 pub fn new(
344 left_tabs: Vec<PanelKey>,
345 main_tabs: Vec<PanelKey>,
346 bottom_tabs: Vec<PanelKey>,
347 ) -> Self {
348 Self {
349 left_tabs,
350 main_tabs,
351 bottom_tabs,
352 left_fraction: 0.26,
353 main_fraction: 0.72,
354 active_left: 0,
355 active_main: 0,
356 active_bottom: 0,
357 }
358 }
359
360 pub fn with_fractions(mut self, left_fraction: f32, main_fraction: f32) -> Self {
361 self.left_fraction = left_fraction;
362 self.main_fraction = main_fraction;
363 self
364 }
365}
366
367#[derive(Debug, Default)]
370pub struct DockLayoutBuilder {
371 graph: DockGraph,
372}
373
374impl DockLayoutBuilder {
375 pub fn new() -> Self {
376 Self {
377 graph: DockGraph::new(),
378 }
379 }
380
381 pub fn into_graph(self) -> DockGraph {
382 self.graph
383 }
384
385 pub fn tabs(&mut self, tabs: Vec<PanelKey>, active: usize) -> DockNodeId {
386 self.graph.insert_node(DockNode::Tabs { tabs, active })
387 }
388
389 pub fn split_h(
390 &mut self,
391 left: DockNodeId,
392 right: DockNodeId,
393 left_fraction: f32,
394 ) -> DockNodeId {
395 self.graph.insert_node(DockNode::Split {
396 axis: Axis::Horizontal,
397 children: vec![left, right],
398 fractions: vec![left_fraction, (1.0 - left_fraction).max(0.0)],
399 })
400 }
401
402 pub fn split_v(
403 &mut self,
404 top: DockNodeId,
405 bottom: DockNodeId,
406 top_fraction: f32,
407 ) -> DockNodeId {
408 self.graph.insert_node(DockNode::Split {
409 axis: Axis::Vertical,
410 children: vec![top, bottom],
411 fractions: vec![top_fraction, (1.0 - top_fraction).max(0.0)],
412 })
413 }
414
415 pub fn set_window_root(&mut self, window: AppWindowId, root: DockNodeId) {
416 self.graph.set_window_root(window, root);
417 }
418
419 pub fn default_editor_layout(window: AppWindowId, spec: EditorDockLayoutSpec) -> DockGraph {
423 let mut b = DockLayoutBuilder::new();
424 let left = b.tabs(spec.left_tabs, spec.active_left);
425 let top = b.tabs(spec.main_tabs, spec.active_main);
426 let bottom = b.tabs(spec.bottom_tabs, spec.active_bottom);
427 let right = b.split_v(top, bottom, spec.main_fraction);
428 let root = b.split_h(left, right, spec.left_fraction);
429 b.set_window_root(window, root);
430 b.into_graph()
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn builder_default_editor_layout_sets_window_root() {
440 let window = AppWindowId::default();
441 let spec = EditorDockLayoutSpec::new(
442 vec![
443 PanelKey::new("core.hierarchy"),
444 PanelKey::new("core.project"),
445 ],
446 vec![PanelKey::new("core.scene"), PanelKey::new("core.game")],
447 vec![
448 PanelKey::new("core.inspector"),
449 PanelKey::new("core.text_probe"),
450 ],
451 );
452 let graph = DockLayoutBuilder::default_editor_layout(window, spec);
453 assert!(graph.window_root(window).is_some());
454 }
455
456 #[test]
457 fn validate_rejects_duplicate_node_ids() {
458 let layout = DockLayout {
459 layout_version: DOCK_LAYOUT_VERSION,
460 windows: vec![DockLayoutWindow {
461 logical_window_id: "main".into(),
462 root: 1,
463 placement: None,
464 floatings: Vec::new(),
465 }],
466 nodes: vec![
467 DockLayoutNode::Tabs {
468 id: 1,
469 tabs: vec![PanelKey::new("core.a")],
470 active: 0,
471 },
472 DockLayoutNode::Tabs {
473 id: 1,
474 tabs: vec![PanelKey::new("core.b")],
475 active: 0,
476 },
477 ],
478 };
479
480 let err = layout.validate().expect_err("duplicate ids should fail");
481 assert!(matches!(
482 err.kind,
483 DockLayoutValidationErrorKind::DuplicateNodeId { id: 1 }
484 ));
485 }
486
487 #[test]
488 fn validate_rejects_cycles() {
489 let layout = DockLayout {
490 layout_version: DOCK_LAYOUT_VERSION,
491 windows: vec![DockLayoutWindow {
492 logical_window_id: "main".into(),
493 root: 1,
494 placement: None,
495 floatings: Vec::new(),
496 }],
497 nodes: vec![DockLayoutNode::Split {
498 id: 1,
499 axis: Axis::Horizontal,
500 children: vec![1],
501 fractions: vec![1.0],
502 }],
503 };
504
505 let err = layout.validate().expect_err("cycles should fail");
506 assert!(matches!(
507 err.kind,
508 DockLayoutValidationErrorKind::CycleDetected { id: 1 }
509 ));
510 }
511
512 #[test]
513 fn validate_rejects_tabs_active_out_of_bounds() {
514 let layout = DockLayout {
515 layout_version: DOCK_LAYOUT_VERSION,
516 windows: vec![DockLayoutWindow {
517 logical_window_id: "main".into(),
518 root: 1,
519 placement: None,
520 floatings: Vec::new(),
521 }],
522 nodes: vec![DockLayoutNode::Tabs {
523 id: 1,
524 tabs: vec![PanelKey::new("core.a")],
525 active: 2,
526 }],
527 };
528
529 let err = layout
530 .validate()
531 .expect_err("active out of bounds should fail");
532 assert!(matches!(
533 err.kind,
534 DockLayoutValidationErrorKind::TabsActiveOutOfBounds {
535 id: 1,
536 active: 2,
537 len: 1
538 }
539 ));
540 }
541}