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}