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/// full client-area width, not the physical screen. `Ord` follows the declared
10/// order (Compact < Medium < Expanded).
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum SizeClass {
14    Compact,
15    Medium,
16    Expanded,
17}
18
19/// Compact `< 600`, Medium `600..=840`, Expanded `> 840`.
20pub const COMPACT_MAX: f64 = 600.0;
21pub const MEDIUM_MAX: f64 = 840.0;
22/// Default hysteresis margin to avoid breakpoint thrashing.
23pub const DEFAULT_HYSTERESIS: f64 = 24.0;
24
25impl SizeClass {
26    pub fn from_width(width: f64) -> Self {
27        if width < COMPACT_MAX {
28            SizeClass::Compact
29        } else if width <= MEDIUM_MAX {
30            SizeClass::Medium
31        } else {
32            SizeClass::Expanded
33        }
34    }
35
36    /// Resolve with hysteresis: only switch class when `width` clears the
37    /// boundary by `margin`, otherwise keep `prev` (prevents edge flicker).
38    pub fn resolve(prev: Option<SizeClass>, width: f64, margin: f64) -> Self {
39        let raw = SizeClass::from_width(width);
40        let Some(prev) = prev else { return raw };
41        if prev == raw {
42            return prev;
43        }
44        // Only an adjacent transition shares a hysteresis boundary. A jump
45        // across two classes must converge immediately.
46        let boundary =
47            match (prev, raw) {
48                (SizeClass::Compact, SizeClass::Medium)
49                | (SizeClass::Medium, SizeClass::Compact) => Some(COMPACT_MAX),
50                (SizeClass::Medium, SizeClass::Expanded)
51                | (SizeClass::Expanded, SizeClass::Medium) => Some(MEDIUM_MAX),
52                _ => None,
53            };
54        if boundary.is_some_and(|boundary| (width - boundary).abs() < margin) {
55            prev
56        } else {
57            raw
58        }
59    }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum Axis {
65    Horizontal,
66    Vertical,
67}
68
69/// Authoritative layout produced by the Host (output), referencing surfaces
70/// only by id. Overlay/float surfaces never appear here.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(tag = "kind", rename_all = "lowercase")]
73pub enum LayoutTree {
74    Leaf {
75        surface_id: SurfaceId,
76    },
77    Split {
78        axis: Axis,
79        children: Vec<LayoutTree>,
80        weights: Vec<f64>,
81    },
82    Tabs {
83        active_id: SurfaceId,
84        children: Vec<SurfaceId>,
85    },
86    /// Desktop free-floating pane that still lives inside this window's graph.
87    Freeform {
88        surface_id: SurfaceId,
89    },
90}
91
92impl LayoutTree {
93    /// Collect every `surfaceId` referenced by the tree.
94    pub fn surface_ids(&self) -> Vec<SurfaceId> {
95        let mut out = Vec::new();
96        self.collect_ids(&mut out);
97        out
98    }
99
100    fn collect_ids(&self, out: &mut Vec<SurfaceId>) {
101        match self {
102            LayoutTree::Leaf { surface_id } | LayoutTree::Freeform { surface_id } => {
103                out.push(surface_id.clone());
104            }
105            LayoutTree::Tabs { children, .. } => out.extend(children.iter().cloned()),
106            LayoutTree::Split { children, .. } => {
107                for c in children {
108                    c.collect_ids(out);
109                }
110            }
111        }
112    }
113
114    /// Structural invariants: tabs.activeId ∈ children, split weights match
115    /// children and are positive, splits have ≥2 children.
116    pub fn validate(&self) -> Result<(), String> {
117        match self {
118            LayoutTree::Leaf { .. } | LayoutTree::Freeform { .. } => Ok(()),
119            LayoutTree::Tabs {
120                active_id,
121                children,
122            } => {
123                if children.is_empty() {
124                    return Err("tabs node has no children".into());
125                }
126                if !children.contains(active_id) {
127                    return Err(format!("tabs.activeId '{active_id}' not in children"));
128                }
129                Ok(())
130            }
131            LayoutTree::Split {
132                children, weights, ..
133            } => {
134                if children.len() < 2 {
135                    return Err("split node needs >= 2 children".into());
136                }
137                if weights.len() != children.len() {
138                    return Err("split weights length != children length".into());
139                }
140                if weights.iter().any(|w| *w <= 0.0) {
141                    return Err("split weights must be > 0".into());
142                }
143                for c in children {
144                    c.validate()?;
145                }
146                Ok(())
147            }
148        }
149    }
150}
151
152/// How the main-switcher renders on this platform/size.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "lowercase")]
155pub enum SwitcherForm {
156    None,
157    Sidebar,
158    Rail,
159}
160
161/// How asides (the split axis) render. `sheet` belongs to `float`, not here.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub enum SplitForm {
165    None,
166    Split,
167    Collapsible,
168    FullScreen,
169}
170
171/// Who owns the bottom bar in compact.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "lowercase")]
174pub enum BottomOwner {
175    App,
176}
177
178/// Shared-core output bound by each platform skin. The pure core view: the
179/// resolved `sizeClass`/forms/`bottomOwner` and the authoritative
180/// `layoutTree`. The renderable, skin-bindable contract is
181/// [`LayoutPresentationPlan`] (derived from this graph), not this type.
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct DerivedLayout {
185    pub size_class: SizeClass,
186    pub switcher_form: SwitcherForm,
187    pub split_form: SplitForm,
188    pub bottom_owner: BottomOwner,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub layout_tree: Option<LayoutTree>,
191}
192
193/// One aside in the [`LayoutPresentationPlan`]: the surface id, the requested
194/// edge, and its preferred size. Skins read `split_form` to decide whether
195/// these asides dock beside the main or present full-screen on compact.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct PlanAside {
199    pub id: SurfaceId,
200    /// Edge the aside docks to; `None` when no edge was placed (skin default).
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub edge: Option<crate::model::Edge>,
203    /// Preferred dock size in logical px; `None` lets the skin pick a default.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub preferred_size: Option<f64>,
206}
207
208/// One aside slot in the [`LayoutPresentationPlan`]: the aside area holds at
209/// most one region per content kind (lxapp / browser / native); the region's
210/// contents are its tabs, in open order. Skins render ONE docked panel per
211/// slot, with a header tab strip when `children` has more than one entry.
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase")]
214pub struct PlanAsideSlot {
215    pub kind: crate::model::SlotKind,
216    /// Edge the slot docks to (the most recently placed child's edge wins).
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub edge: Option<crate::model::Edge>,
219    /// Tab order = open order; never reordered.
220    pub children: Vec<SurfaceId>,
221    /// The child the slot currently shows.
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub active_child: Option<SurfaceId>,
224    /// Admitted visible at this size class. Hidden slots stay alive and
225    /// reappear when the container widens — they are never evicted.
226    pub visible: bool,
227    /// Temporarily covers the main instead of consuming dock space. This is
228    /// the compact projection and the fallback for an explicitly opened aside
229    /// that physical admission cannot fit.
230    #[serde(default)]
231    pub overlay: bool,
232}
233
234/// One float in the [`LayoutPresentationPlan`]: the surface id plus the
235/// render-relevant `FloatSpec` semantics (anchor, dismiss, modal). Floats are
236/// popups above the layout and are never in the tree, so the skin reads this
237/// list to know which popups to show and how each behaves. The reconciler is
238/// the single authority for float visibility.
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct PlanFloat {
242    pub id: SurfaceId,
243    /// Where the popup anchors (screen vs. another surface).
244    pub anchor: crate::model::FloatAnchor,
245    /// How the popup is dismissed (tap-outside vs. manual).
246    pub dismiss: crate::model::FloatDismiss,
247    /// Whether the popup blocks input to layers below.
248    pub modal: bool,
249    /// Whether the popup renders the standard circular close control.
250    pub close_button: bool,
251}
252
253/// The stable, complete render contract a skin binds. Unlike the pure-core
254/// [`DerivedLayout`], this flattens the graph into the renderable
255/// view any skin needs: ordered `mains`, `asides` (with edge + preferred size),
256/// `floats`, and the full id-only `tree`. Derived from
257/// the graph so the shared core output isn't bound to one skin's needs.
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct LayoutPresentationPlan {
261    pub size_class: SizeClass,
262    pub bottom_owner: BottomOwner,
263    pub switcher_form: SwitcherForm,
264    pub split_form: SplitForm,
265    /// Main surface ids, in stable order.
266    pub mains: Vec<SurfaceId>,
267    /// The currently-active main (the one occupying the primary content area).
268    /// Skins drive the active-main switch from this rather than inferring it
269    /// from the tree's `Tabs.activeId`. `None` only when there are no mains.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub active_main_id: Option<SurfaceId>,
272    /// Asides currently in the layout. `split_form` decides whether they dock
273    /// beside the main or present full-screen on compact.
274    pub asides: Vec<PlanAside>,
275    /// Asides grouped into per-kind slots (lxapp / browser / native) with tab
276    /// order and admission visibility. Supersedes per-aside handling; `asides`
277    /// stays for skins that have not migrated yet.
278    #[serde(default)]
279    pub aside_slots: Vec<PlanAsideSlot>,
280    /// Floats currently open: popups above the layout (never in the tree),
281    /// each carrying its render-relevant `FloatSpec` semantics.
282    pub floats: Vec<PlanFloat>,
283    /// The full authoritative layout tree (ids only).
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub tree: Option<LayoutTree>,
286}