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