Skip to main content

lingxia_surface/
layout.rs

1//! Layout output types: sizeClass, LayoutTree, and the per-platform
2//! `DerivedLayout` that skins bind to.
3
4use serde::{Deserialize, Serialize};
5
6use crate::model::SurfaceId;
7
8/// Available-width band. Aligned to Material breakpoints and computed from the
9/// *container* width, not the physical screen.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum SizeClass {
13    Compact,
14    Medium,
15    Expanded,
16}
17
18/// Compact `< 600`, Medium `600..=840`, Expanded `> 840`.
19pub const COMPACT_MAX: f64 = 600.0;
20pub const MEDIUM_MAX: f64 = 840.0;
21/// Default hysteresis margin to avoid breakpoint thrashing.
22pub const DEFAULT_HYSTERESIS: f64 = 24.0;
23
24impl SizeClass {
25    pub fn from_width(width: f64) -> Self {
26        if width < COMPACT_MAX {
27            SizeClass::Compact
28        } else if width <= MEDIUM_MAX {
29            SizeClass::Medium
30        } else {
31            SizeClass::Expanded
32        }
33    }
34
35    /// Resolve with hysteresis: only switch class when `width` clears the
36    /// boundary by `margin`, otherwise keep `prev` (prevents edge flicker).
37    pub fn resolve(prev: Option<SizeClass>, width: f64, margin: f64) -> Self {
38        let raw = SizeClass::from_width(width);
39        let Some(prev) = prev else { return raw };
40        if prev == raw {
41            return prev;
42        }
43        // Within the hysteresis band around a boundary, stick with prev.
44        let near_lower = (width - COMPACT_MAX).abs() < margin;
45        let near_upper = (width - MEDIUM_MAX).abs() < margin;
46        if near_lower || near_upper { prev } else { raw }
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum Axis {
53    Horizontal,
54    Vertical,
55}
56
57/// Authoritative layout produced by the Host (output), referencing surfaces
58/// only by id. Overlay/float surfaces never appear here.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60#[serde(tag = "kind", rename_all = "lowercase")]
61pub enum LayoutTree {
62    Leaf {
63        surface_id: SurfaceId,
64    },
65    Split {
66        axis: Axis,
67        children: Vec<LayoutTree>,
68        weights: Vec<f64>,
69    },
70    Tabs {
71        active_id: SurfaceId,
72        children: Vec<SurfaceId>,
73    },
74    /// Desktop free-floating pane that still lives inside this window's graph.
75    Freeform {
76        surface_id: SurfaceId,
77    },
78}
79
80impl LayoutTree {
81    /// Collect every `surfaceId` referenced by the tree.
82    pub fn surface_ids(&self) -> Vec<SurfaceId> {
83        let mut out = Vec::new();
84        self.collect_ids(&mut out);
85        out
86    }
87
88    fn collect_ids(&self, out: &mut Vec<SurfaceId>) {
89        match self {
90            LayoutTree::Leaf { surface_id } | LayoutTree::Freeform { surface_id } => {
91                out.push(surface_id.clone());
92            }
93            LayoutTree::Tabs { children, .. } => out.extend(children.iter().cloned()),
94            LayoutTree::Split { children, .. } => {
95                for c in children {
96                    c.collect_ids(out);
97                }
98            }
99        }
100    }
101
102    /// Structural invariants: tabs.activeId ∈ children, split weights match
103    /// children and are positive, splits have ≥2 children.
104    pub fn validate(&self) -> Result<(), String> {
105        match self {
106            LayoutTree::Leaf { .. } | LayoutTree::Freeform { .. } => Ok(()),
107            LayoutTree::Tabs {
108                active_id,
109                children,
110            } => {
111                if children.is_empty() {
112                    return Err("tabs node has no children".into());
113                }
114                if !children.contains(active_id) {
115                    return Err(format!("tabs.activeId '{active_id}' not in children"));
116                }
117                Ok(())
118            }
119            LayoutTree::Split {
120                children, weights, ..
121            } => {
122                if children.len() < 2 {
123                    return Err("split node needs >= 2 children".into());
124                }
125                if weights.len() != children.len() {
126                    return Err("split weights length != children length".into());
127                }
128                if weights.iter().any(|w| *w <= 0.0) {
129                    return Err("split weights must be > 0".into());
130                }
131                for c in children {
132                    c.validate()?;
133                }
134                Ok(())
135            }
136        }
137    }
138}
139
140/// How the main-switcher renders on this platform/size.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "lowercase")]
143pub enum SwitcherForm {
144    None,
145    Sidebar,
146    Rail,
147}
148
149/// How asides (the split axis) render. `sheet` belongs to `float`, not here.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub enum SplitForm {
153    None,
154    Split,
155    Collapsible,
156    FullScreen,
157}
158
159/// Who owns the bottom bar in compact.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "lowercase")]
162pub enum BottomOwner {
163    App,
164}
165
166/// Shared-core output bound by each platform skin. The pure core view: the
167/// resolved `sizeClass`/forms/`bottomOwner` and the authoritative
168/// `layoutTree`. The renderable, skin-bindable contract is
169/// [`LayoutPresentationPlan`] (derived from this graph), not this type.
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct DerivedLayout {
173    pub size_class: SizeClass,
174    pub switcher_form: SwitcherForm,
175    pub split_form: SplitForm,
176    pub bottom_owner: BottomOwner,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub layout_tree: Option<LayoutTree>,
179}
180
181/// One aside in the [`LayoutPresentationPlan`]: the surface id, the requested
182/// edge, and its preferred size. Skins read `split_form` to decide whether
183/// these asides dock beside the main or present full-screen on compact.
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct PlanAside {
187    pub id: SurfaceId,
188    /// Edge the aside docks to; `None` when no edge was placed (skin default).
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub edge: Option<crate::model::Edge>,
191    /// Preferred dock size in logical px; `None` lets the skin pick a default.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub preferred_size: Option<f64>,
194}
195
196/// One float in the [`LayoutPresentationPlan`]: the surface id plus the
197/// render-relevant `FloatSpec` semantics (anchor, dismiss, modal). Floats are
198/// popups above the layout and are never in the tree, so the skin reads this
199/// list to know which popups to show and how each behaves. The reconciler is
200/// the single authority for float visibility.
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct PlanFloat {
204    pub id: SurfaceId,
205    /// Where the popup anchors (screen vs. another surface).
206    pub anchor: crate::model::FloatAnchor,
207    /// How the popup is dismissed (tap-outside vs. manual).
208    pub dismiss: crate::model::FloatDismiss,
209    /// Whether the popup blocks input to layers below.
210    pub modal: bool,
211}
212
213/// The stable, complete render contract a skin binds. Unlike the pure-core
214/// [`DerivedLayout`], this flattens the graph into the renderable
215/// view any skin needs: ordered `mains`, `asides` (with edge + preferred size),
216/// `floats`, and the full id-only `tree`. Derived from
217/// the graph so the shared core output isn't bound to one skin's needs.
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub struct LayoutPresentationPlan {
221    pub size_class: SizeClass,
222    pub bottom_owner: BottomOwner,
223    pub switcher_form: SwitcherForm,
224    pub split_form: SplitForm,
225    /// Main surface ids, in stable order.
226    pub mains: Vec<SurfaceId>,
227    /// The currently-active main (the one occupying the primary content area).
228    /// Skins drive the active-main switch from this rather than inferring it
229    /// from the tree's `Tabs.activeId`. `None` only when there are no mains.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub active_main_id: Option<SurfaceId>,
232    /// Asides currently in the layout. `split_form` decides whether they dock
233    /// beside the main or present full-screen on compact.
234    pub asides: Vec<PlanAside>,
235    /// Floats currently open: popups above the layout (never in the tree),
236    /// each carrying its render-relevant `FloatSpec` semantics.
237    pub floats: Vec<PlanFloat>,
238    /// The full authoritative layout tree (ids only).
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub tree: Option<LayoutTree>,
241}