Skip to main content

lingxia_surface/
lib.rs

1//! `lingxia-surface` — the platform-agnostic core of the Adaptive Surface
2//! Layout model (see `docs/draft/adaptive-surface-layout.md`).
3//!
4//! Pure Rust, no UI: the Surface Graph, its invariants and state transitions,
5//! the two-axis derivation into `DerivedLayout`, and the Host arbitration
6//! pure function. Each platform skin binds the `DerivedLayout` output.
7
8mod arbitrate;
9mod graph;
10mod layout;
11mod manager;
12mod model;
13
14pub use arbitrate::{Decision, Policy, arbitrate};
15pub use graph::SurfaceGraph;
16pub use layout::{
17    Axis, BottomOwner, DEFAULT_HYSTERESIS, DerivedLayout, LayoutPresentationPlan, LayoutTree,
18    PlanAside, PlanFloat, SizeClass, SplitForm, SwitcherForm,
19};
20pub use manager::SurfaceManager;
21pub use model::{
22    Edge, FloatAnchor, FloatDismiss, FloatSpec, Placement, Role, Surface, SurfaceContent,
23    SurfaceId, SurfaceOwner, SurfaceState,
24};
25
26#[cfg(test)]
27mod tests {
28    use super::*;
29
30    fn main_s(id: &str) -> Surface {
31        Surface::entry(id, Role::Main, id)
32    }
33    fn aside_s(id: &str, edge: Edge) -> Surface {
34        let mut s = Surface::entry(id, Role::Aside, id);
35        s.placement.edge = Some(edge);
36        s
37    }
38    fn web_aside_s(id: &str, url: &str, edge: Edge) -> Surface {
39        let mut s = aside_s(id, edge);
40        s.content = SurfaceContent::Web {
41            url: url.to_string(),
42        };
43        s
44    }
45
46    // ---- invariants & state transitions (§1.3 / §1.5) ----
47
48    #[test]
49    fn empty_graph_is_valid() {
50        let g = SurfaceGraph::new();
51        assert!(g.is_valid());
52        assert_eq!(g.active_main_id, None);
53        assert_eq!(g.focused_surface_id, None);
54    }
55
56    #[test]
57    fn first_main_becomes_active_and_focused() {
58        let mut g = SurfaceGraph::new();
59        g.insert(main_s("home"));
60        assert_eq!(g.active_main_id.as_deref(), Some("home"));
61        assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
62        assert!(g.is_valid());
63    }
64
65    #[test]
66    fn aside_requires_a_main_invariant() {
67        // Construct an illegal graph directly and assert the checker catches it.
68        let mut g = SurfaceGraph::new();
69        g.insert(main_s("home"));
70        g.insert(aside_s("assistant", Edge::Right));
71        assert!(g.is_valid());
72        // Removing the only main cascades the aside closed (§1.5).
73        let removed = g.remove("home");
74        assert!(removed.contains(&"assistant".to_string()));
75        assert!(g.asides().is_empty());
76        assert_eq!(g.active_main_id, None);
77        assert!(g.is_valid());
78    }
79
80    #[test]
81    fn closing_active_main_picks_adjacent_successor() {
82        let mut g = SurfaceGraph::new();
83        g.insert(main_s("a"));
84        g.insert(main_s("b"));
85        g.insert(main_s("c"));
86        g.set_active_main("b");
87        g.remove("b");
88        // prefer the next main after the removed position.
89        assert_eq!(g.active_main_id.as_deref(), Some("c"));
90        assert!(g.is_valid());
91    }
92
93    #[test]
94    fn modal_float_restores_focus_on_close() {
95        let mut g = SurfaceGraph::new();
96        g.insert(main_s("home"));
97        assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
98        let mut modal = Surface::entry("dialog", Role::Float, "confirm");
99        modal.float = Some(FloatSpec {
100            modal: true,
101            ..Default::default()
102        });
103        g.insert(modal);
104        g.set_focus("dialog");
105        g.remove("dialog");
106        assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
107        assert!(g.is_valid());
108    }
109
110    // ---- two-axis derivation (§2 / §6) ----
111
112    #[test]
113    fn single_main_no_switcher_no_split() {
114        let mut g = SurfaceGraph::new();
115        g.insert(main_s("home"));
116        let d = g.derive_layout(SizeClass::Expanded);
117        assert_eq!(d.switcher_form, SwitcherForm::None);
118        assert_eq!(d.split_form, SplitForm::None);
119        assert!(matches!(d.layout_tree, Some(LayoutTree::Leaf { .. })));
120    }
121
122    #[test]
123    fn switcher_only_with_multiple_mains() {
124        let mut g = SurfaceGraph::new();
125        g.insert(main_s("a"));
126        assert_eq!(
127            g.derive_layout(SizeClass::Expanded).switcher_form,
128            SwitcherForm::None
129        );
130        g.insert(main_s("b"));
131        assert_eq!(
132            g.derive_layout(SizeClass::Expanded).switcher_form,
133            SwitcherForm::Sidebar
134        );
135    }
136
137    #[test]
138    fn aside_splits_on_expanded_fullscreen_on_compact() {
139        let mut g = SurfaceGraph::new();
140        g.insert(main_s("home"));
141        g.insert(aside_s("assistant", Edge::Right));
142        assert_eq!(
143            g.derive_layout(SizeClass::Expanded).split_form,
144            SplitForm::Split
145        );
146        assert_eq!(
147            g.derive_layout(SizeClass::Compact).split_form,
148            SplitForm::FullScreen
149        );
150    }
151
152    #[test]
153    fn compact_plan_keeps_existing_aside_desired() {
154        let mut g = SurfaceGraph::new();
155        g.insert(main_s("home"));
156        g.insert(aside_s("assistant", Edge::Right));
157
158        let plan = g.presentation_plan(SizeClass::Compact);
159        assert_eq!(plan.split_form, SplitForm::FullScreen);
160        assert!(plan.asides.iter().any(|aside| aside.id == "assistant"));
161        assert!(
162            plan.tree
163                .as_ref()
164                .is_some_and(|tree| tree.surface_ids().iter().any(|id| id == "assistant"))
165        );
166    }
167
168    #[test]
169    fn compact_bottom_owner_stays_app() {
170        let mut g = SurfaceGraph::new();
171        g.insert(main_s("a"));
172        // single main → app owns bottom
173        assert_eq!(
174            g.derive_layout(SizeClass::Compact).bottom_owner,
175            BottomOwner::App
176        );
177        g.insert(main_s("b"));
178        // compact has no separate switcher.
179        assert_eq!(
180            g.derive_layout(SizeClass::Compact).bottom_owner,
181            BottomOwner::App
182        );
183    }
184
185    #[test]
186    fn canonical_layout_validates() {
187        let mut g = SurfaceGraph::new();
188        g.insert(main_s("a"));
189        g.insert(main_s("b"));
190        g.insert(aside_s("assistant", Edge::Right));
191        let tree = g.canonical_layout(SizeClass::Expanded).unwrap();
192        tree.validate().expect("canonical tree must be valid");
193        // floats never appear in the tree.
194        g.insert(Surface::entry("toast", Role::Float, "toast"));
195        let ids = g
196            .canonical_layout(SizeClass::Expanded)
197            .unwrap()
198            .surface_ids();
199        assert!(!ids.contains(&"toast".to_string()));
200    }
201
202    // ---- sizeClass breakpoints + hysteresis (§6.1) ----
203
204    #[test]
205    fn breakpoints_align_to_material() {
206        assert_eq!(SizeClass::from_width(599.0), SizeClass::Compact);
207        assert_eq!(SizeClass::from_width(600.0), SizeClass::Medium);
208        assert_eq!(SizeClass::from_width(840.0), SizeClass::Medium);
209        assert_eq!(SizeClass::from_width(841.0), SizeClass::Expanded);
210    }
211
212    #[test]
213    fn hysteresis_holds_class_near_boundary() {
214        // sitting just under the 600 boundary, within margin, keeps Medium.
215        let held = SizeClass::resolve(Some(SizeClass::Medium), 590.0, DEFAULT_HYSTERESIS);
216        assert_eq!(held, SizeClass::Medium);
217        // clearly past the boundary switches.
218        let switched = SizeClass::resolve(Some(SizeClass::Medium), 500.0, DEFAULT_HYSTERESIS);
219        assert_eq!(switched, SizeClass::Compact);
220    }
221
222    // ---- arbitration (§3.4) ----
223
224    #[test]
225    fn aside_over_limit_replaces_oldest() {
226        let mut g = SurfaceGraph::new();
227        g.insert(main_s("home"));
228        g.insert(aside_s("a1", Edge::Right));
229        g.insert(aside_s("a2", Edge::Right));
230        // expanded max=2 → a third aside replaces the oldest same-edge one.
231        let (next, decision) = arbitrate(
232            &g,
233            aside_s("a3", Edge::Right),
234            &Policy::default(),
235            SizeClass::Expanded,
236        );
237        assert_eq!(decision, Decision::ReplacedExisting);
238        assert!(next.get("a1").is_none());
239        assert!(next.get("a3").is_some());
240        assert_eq!(next.asides().len(), 2);
241        assert!(next.is_valid());
242    }
243
244    #[test]
245    fn web_aside_is_singleton_replacing_existing() {
246        let mut g = SurfaceGraph::new();
247        g.insert(main_s("home"));
248        g.insert(web_aside_s("browser-1", "https://a.example", Edge::Right));
249        // A second browser aside replaces the first regardless of the generic
250        // cap (expanded allows 2 asides), and only on a different edge too.
251        let (next, decision) = arbitrate(
252            &g,
253            web_aside_s("browser-2", "https://b.example", Edge::Left),
254            &Policy::default(),
255            SizeClass::Expanded,
256        );
257        assert_eq!(decision, Decision::ReplacedExisting);
258        assert!(next.get("browser-1").is_none());
259        assert!(next.get("browser-2").is_some());
260        // exactly one web aside survives.
261        assert_eq!(
262            next.asides()
263                .iter()
264                .filter(|s| matches!(s.content, SurfaceContent::Web { .. }))
265                .count(),
266            1
267        );
268        assert!(next.is_valid());
269    }
270
271    #[test]
272    fn web_aside_coexists_with_a_page_aside() {
273        let mut g = SurfaceGraph::new();
274        g.insert(main_s("home"));
275        g.insert(aside_s("assistant", Edge::Right));
276        // A browser aside does NOT evict a non-web (declared/page) aside; both
277        // coexist under the expanded cap of 2.
278        let (next, decision) = arbitrate(
279            &g,
280            web_aside_s("browser-1", "https://a.example", Edge::Left),
281            &Policy::default(),
282            SizeClass::Expanded,
283        );
284        assert_eq!(decision, Decision::Accepted);
285        assert!(next.get("assistant").is_some());
286        assert!(next.get("browser-1").is_some());
287        assert_eq!(next.asides().len(), 2);
288        assert!(next.is_valid());
289    }
290
291    #[test]
292    fn aside_fullscreen_fallback_on_compact() {
293        let mut g = SurfaceGraph::new();
294        g.insert(main_s("home"));
295        let (next, decision) = arbitrate(
296            &g,
297            aside_s("assistant", Edge::Right),
298            &Policy::default(),
299            SizeClass::Compact,
300        );
301        assert_eq!(decision, Decision::FullScreenFallback);
302        // promoted to a main, no longer an aside.
303        assert_eq!(next.role_of("assistant"), Some(Role::Main));
304        assert_eq!(next.active_main_id.as_deref(), Some("assistant"));
305        assert_eq!(
306            next.presentation_plan(SizeClass::Compact)
307                .active_main_id
308                .as_deref(),
309            Some("assistant")
310        );
311        assert!(next.is_valid());
312    }
313
314    #[test]
315    fn aside_without_primary_promotes_to_main() {
316        // No main yet, expanded (room for asides) — an aside has nothing to
317        // dock to, so it must become the main, keeping the graph valid.
318        let g = SurfaceGraph::new();
319        let (next, decision) = arbitrate(
320            &g,
321            aside_s("assistant", Edge::Right),
322            &Policy::default(),
323            SizeClass::Expanded,
324        );
325        assert_eq!(decision, Decision::DowngradedRole);
326        assert_eq!(next.role_of("assistant"), Some(Role::Main));
327        assert!(next.is_valid());
328    }
329
330    #[test]
331    fn arbitrate_is_pure_and_keeps_graph_valid() {
332        let mut g = SurfaceGraph::new();
333        g.insert(main_s("home"));
334        let before = g.surfaces().len();
335        let (next, _) = arbitrate(
336            &g,
337            aside_s("x", Edge::Left),
338            &Policy::default(),
339            SizeClass::Expanded,
340        );
341        // original graph untouched (pure); result is valid.
342        assert_eq!(g.surfaces().len(), before);
343        assert!(next.is_valid());
344    }
345
346    // ---- serde round-trip (shared core <-> JSON for ui.json / FFI) ----
347
348    #[test]
349    fn surface_json_round_trip() {
350        let s = aside_s("assistant", Edge::Right);
351        let json = serde_json::to_string(&s).unwrap();
352        let back: Surface = serde_json::from_str(&json).unwrap();
353        assert_eq!(s, back);
354    }
355}