Skip to main content

fret_core/dock/
layout.rs

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    /// Root node id within `nodes` for the floating dock tree (tabs/splits).
269    pub root: u32,
270    /// Floating window rect in logical pixels, relative to the host window's inner content origin.
271    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/// Convenience helpers to build a `DockGraph` (runtime dock tree) without manually calling
368/// `DockGraph::insert_node` everywhere.
369#[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    /// Builds a Unity-like editor default layout:
420    /// - left: (Hierarchy, Project, ...)
421    /// - right: top (Scene, Game, ...), bottom (Inspector, Console/Text Probe, ...)
422    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}