Skip to main content

lingxia_surface/
manager.rs

1//! `SurfaceManager` — the stateful per-window driver platforms bind to.
2//!
3//! Wraps a [`SurfaceGraph`] with the current size band and arbitration policy:
4//! open/close requests go through the pure arbiter, width changes resolve the
5//! `SizeClass` with hysteresis, and `derive()` produces the `DerivedLayout` the
6//! skin renders. All layout decisions stay in the shared core; the platform
7//! only maps legacy primitives in and binds the output.
8
9use crate::arbitrate::{OpenOutcome, Policy, arbitrate};
10use crate::graph::SurfaceGraph;
11use crate::layout::{DEFAULT_HYSTERESIS, DerivedLayout, LayoutPresentationPlan, SizeClass};
12use crate::model::{Surface, SurfaceId};
13
14/// One window's stateful surface driver.
15#[derive(Debug, Clone)]
16pub struct SurfaceManager {
17    graph: SurfaceGraph,
18    policy: Policy,
19    width: f64,
20    sidebar_width: f64,
21    hysteresis: f64,
22    size_class: SizeClass,
23    /// Most recently explicitly shown aside that could not be admitted as a
24    /// dock. It remains live and is projected over the main until hidden.
25    overlay_fallback_surface_id: Option<SurfaceId>,
26}
27
28impl SurfaceManager {
29    /// New manager for a container of `width` logical px, default policy.
30    pub fn new(width: f64) -> Self {
31        Self::with_policy(width, Policy::default())
32    }
33
34    pub fn with_policy(width: f64, policy: Policy) -> Self {
35        Self {
36            graph: SurfaceGraph::new(),
37            policy,
38            width,
39            sidebar_width: 0.0,
40            hysteresis: DEFAULT_HYSTERESIS,
41            size_class: SizeClass::from_width(width),
42            overlay_fallback_surface_id: None,
43        }
44    }
45
46    pub fn graph(&self) -> &SurfaceGraph {
47        &self.graph
48    }
49    pub fn size_class(&self) -> SizeClass {
50        self.size_class
51    }
52    pub fn width(&self) -> f64 {
53        self.width
54    }
55
56    fn workspace_width(&self) -> f64 {
57        (self.width - self.sidebar_width).max(0.0)
58    }
59
60    fn needs_overlay(&self, id: &str) -> bool {
61        self.graph.role_of(id) == Some(crate::model::Role::Aside)
62            && (self.size_class == SizeClass::Compact
63                || !self
64                    .graph
65                    .aside_slots_admitted(self.size_class, self.workspace_width(), &self.policy)
66                    .into_iter()
67                    .find(|slot| slot.children.iter().any(|child| child == id))
68                    .is_some_and(|slot| slot.visible))
69    }
70
71    fn reconcile_overlay_fallback(&mut self) {
72        if self
73            .overlay_fallback_surface_id
74            .as_deref()
75            .is_some_and(|id| !self.needs_overlay(id))
76        {
77            self.overlay_fallback_surface_id = None;
78        }
79    }
80
81    /// Update the container width. Returns `true` if the `SizeClass` changed
82    /// after hysteresis, i.e. when the skin must re-derive its layout.
83    pub fn set_width(&mut self, width: f64) -> bool {
84        self.width = width;
85        let next = SizeClass::resolve(Some(self.size_class), width, self.hysteresis);
86        let changed = next != self.size_class;
87        self.size_class = next;
88        self.reconcile_overlay_fallback();
89        changed
90    }
91
92    /// Report the platform's live sidebar allocation. Mobile/custom hosts use
93    /// zero; desktop shells update this during resize and collapse.
94    pub fn set_sidebar_width(&mut self, width: f64) -> bool {
95        let width = if width.is_finite() {
96            width.max(0.0).min(self.width)
97        } else {
98            0.0
99        };
100        let changed = (self.sidebar_width - width).abs() > f64::EPSILON;
101        self.sidebar_width = width;
102        self.reconcile_overlay_fallback();
103        changed
104    }
105
106    /// Open (or replace by id) a surface through the arbiter at the current size.
107    /// Always leaves the graph valid; returns the structured decision.
108    pub fn open(&mut self, request: Surface) -> OpenOutcome {
109        let (next, mut outcome) = arbitrate(&self.graph, request, &self.policy, self.size_class);
110        self.graph = next;
111        if outcome.resolved_role == crate::model::Role::Aside {
112            let admitted = self
113                .graph
114                .aside_slots_admitted(self.size_class, self.workspace_width(), &self.policy)
115                .into_iter()
116                .find(|slot| slot.children.contains(&outcome.resolved_surface_id))
117                .is_some_and(|slot| slot.visible);
118            outcome.overlay |= self.size_class == SizeClass::Compact || !admitted;
119            if outcome.overlay {
120                self.overlay_fallback_surface_id = Some(outcome.resolved_surface_id.clone());
121            } else {
122                self.overlay_fallback_surface_id = None;
123            }
124        }
125        outcome
126    }
127
128    /// Close a surface; returns the ids actually removed (target + cascades).
129    pub fn close(&mut self, id: &str) -> Vec<SurfaceId> {
130        let removed = self.graph.remove(id);
131        if self
132            .overlay_fallback_surface_id
133            .as_ref()
134            .is_some_and(|fallback| removed.contains(fallback))
135        {
136            let focused = self
137                .graph
138                .focused_surface_id
139                .as_deref()
140                .filter(|focused| self.graph.role_of(focused) == Some(crate::model::Role::Aside))
141                .map(str::to_string);
142            self.overlay_fallback_surface_id =
143                focused.filter(|focused| self.needs_overlay(focused));
144        }
145        removed
146    }
147
148    pub fn set_active_main(&mut self, id: &str) -> bool {
149        let active = self.graph.set_active_main(id);
150        if active {
151            self.overlay_fallback_surface_id = None;
152        }
153        active
154    }
155    pub fn set_focus(&mut self, id: &str) -> bool {
156        let focused = self.graph.set_focus(id);
157        if focused {
158            self.overlay_fallback_surface_id = self.needs_overlay(id).then(|| id.to_string());
159        }
160        focused
161    }
162
163    pub fn show(&mut self, id: &str) -> bool {
164        let shown = self.graph.show(id);
165        if shown && self.graph.role_of(id) == Some(crate::model::Role::Aside) {
166            let admitted = self
167                .graph
168                .aside_slots_admitted(self.size_class, self.workspace_width(), &self.policy)
169                .into_iter()
170                .find(|slot| slot.children.iter().any(|child| child == id))
171                .is_some_and(|slot| slot.visible);
172            if self.size_class == SizeClass::Compact || !admitted {
173                self.overlay_fallback_surface_id = Some(id.to_string());
174            } else {
175                self.overlay_fallback_surface_id = None;
176            }
177        }
178        shown
179    }
180
181    pub fn hide(&mut self, id: &str) -> bool {
182        let hidden = self.graph.hide(id);
183        if hidden && self.overlay_fallback_surface_id.as_deref() == Some(id) {
184            let focused = self
185                .graph
186                .focused_surface_id
187                .as_deref()
188                .filter(|focused| self.graph.role_of(focused) == Some(crate::model::Role::Aside))
189                .map(str::to_string);
190            self.overlay_fallback_surface_id =
191                focused.filter(|focused| self.needs_overlay(focused));
192        }
193        hidden
194    }
195
196    /// Derive the platform-agnostic layout output at the current size.
197    pub fn derive(&self) -> DerivedLayout {
198        self.graph.derive_layout(self.size_class)
199    }
200
201    /// Build the stable, skin-bindable [`LayoutPresentationPlan`] at the current
202    /// size — the renderable contract platforms reconcile against. Slot
203    /// admission respects both the size-class ceiling and the physical fit at
204    /// the current width (§3.3).
205    pub fn presentation_plan(&self) -> LayoutPresentationPlan {
206        let mut plan =
207            self.graph
208                .presentation_plan(self.size_class, self.workspace_width(), &self.policy);
209        if self.size_class == SizeClass::Compact {
210            for slot in plan.aside_slots.iter_mut().filter(|slot| slot.visible) {
211                slot.overlay = true;
212            }
213        }
214        if let Some(id) = self.overlay_fallback_surface_id.as_deref()
215            && let Some(slot) = plan
216                .aside_slots
217                .iter_mut()
218                .find(|slot| slot.children.iter().any(|child| child == id))
219            && (self.size_class == SizeClass::Compact || !slot.visible)
220        {
221            slot.visible = true;
222            slot.active_child = Some(id.to_string());
223            slot.overlay = true;
224        }
225        plan
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::Decision;
233    use crate::layout::{SplitForm, SwitcherForm};
234    use crate::model::{Edge, Role, Surface};
235
236    fn main_s(id: &str) -> Surface {
237        Surface::entry(id, Role::Main, id)
238    }
239    fn aside_s(id: &str, edge: Edge) -> Surface {
240        let mut s = Surface::entry(id, Role::Aside, id);
241        s.placement.edge = Some(edge);
242        s
243    }
244
245    #[test]
246    fn open_then_derive_on_expanded() {
247        let mut m = SurfaceManager::new(1200.0);
248        assert_eq!(m.size_class(), SizeClass::Expanded);
249        assert_eq!(m.open(main_s("home")), Decision::Accepted);
250        assert_eq!(
251            m.open(aside_s("assistant", Edge::Right)),
252            Decision::Accepted
253        );
254        let d = m.derive();
255        assert_eq!(d.split_form, SplitForm::Split);
256        assert!(m.graph().is_valid());
257    }
258
259    #[test]
260    fn aside_on_compact_overlays_without_host_switcher() {
261        let mut m = SurfaceManager::new(390.0); // phone width
262        assert_eq!(m.size_class(), SizeClass::Compact);
263        m.open(main_s("home"));
264        // Arbitration preserves the aside role and marks it as a full-screen
265        // overlay; compact still has no sidebar switcher.
266        assert_eq!(
267            m.open(aside_s("assistant", Edge::Right)),
268            Decision::FullScreenFallback
269        );
270        let d = m.derive();
271        assert_eq!(d.switcher_form, SwitcherForm::None);
272        assert_eq!(d.bottom_owner, crate::BottomOwner::App);
273        let slot = &m.presentation_plan().aside_slots[0];
274        assert!(slot.visible);
275        assert!(slot.overlay);
276        assert!(m.graph().is_valid());
277    }
278
279    #[test]
280    fn width_changes_recompute_physical_admission_within_a_size_class() {
281        let mut manager = SurfaceManager::new(1400.0);
282        manager.set_sidebar_width(184.0);
283        manager.open(main_s("home"));
284        manager.open(aside_s("lxapp", Edge::Right));
285        let mut browser = Surface::entry("browser", Role::Aside, "browser");
286        browser.content = crate::model::SurfaceContent::Web {
287            url: "https://example.com".to_string(),
288            reuse_by_url: true,
289        };
290        browser.placement.edge = Some(Edge::Right);
291        manager.open(browser);
292        let mut native = Surface::entry("terminal", Role::Aside, "terminal");
293        native.placement.edge = Some(Edge::Right);
294        manager.open(native);
295        assert_eq!(
296            manager
297                .presentation_plan()
298                .aside_slots
299                .iter()
300                .filter(|slot| slot.visible)
301                .count(),
302            3
303        );
304
305        // Both widths are Expanded. After the full sidebar is allocated, only
306        // one horizontal slot fits at a 900-wide client area.
307        assert!(!manager.set_width(900.0));
308        assert_eq!(manager.size_class(), SizeClass::Expanded);
309        assert_eq!(
310            manager
311                .presentation_plan()
312                .aside_slots
313                .iter()
314                .filter(|slot| slot.visible)
315                .count(),
316            1
317        );
318    }
319
320    #[test]
321    fn explicitly_opened_non_fitting_aside_overlays_until_it_can_dock() {
322        let mut manager = SurfaceManager::new(500.0);
323        manager.open(main_s("home"));
324        let outcome = manager.open(aside_s("assistant", Edge::Right));
325        assert!(outcome.overlay);
326        let slot = &manager.presentation_plan().aside_slots[0];
327        assert!(slot.visible);
328        assert!(slot.overlay);
329
330        manager.set_width(700.0);
331        let slot = &manager.presentation_plan().aside_slots[0];
332        assert!(slot.visible);
333        assert!(!slot.overlay);
334    }
335
336    #[test]
337    fn compact_focus_updates_the_overlay_tab() {
338        let mut manager = SurfaceManager::new(500.0);
339        manager.open(main_s("home"));
340        manager.open(aside_s("first", Edge::Right));
341        manager.open(aside_s("second", Edge::Right));
342
343        assert!(manager.set_focus("first"));
344        let slot = &manager.presentation_plan().aside_slots[0];
345        assert_eq!(slot.active_child.as_deref(), Some("first"));
346    }
347
348    #[test]
349    fn docked_fallback_does_not_reappear_after_later_resize() {
350        let policy = Policy {
351            main_min_width: 400.0,
352            aside_min_width: 240.0,
353            ..Policy::default()
354        };
355        let mut manager = SurfaceManager::with_policy(620.0, policy);
356        manager.open(main_s("home"));
357        let mut browser = Surface::entry("browser", Role::Aside, "browser");
358        browser.content = crate::model::SurfaceContent::Web {
359            url: "https://example.com".to_string(),
360            reuse_by_url: true,
361        };
362        browser.placement.edge = Some(Edge::Right);
363        assert!(manager.open(browser).overlay);
364
365        manager.set_width(1000.0);
366        manager.open(aside_s("chat", Edge::Right));
367        assert!(manager.set_focus("chat"));
368        manager.set_width(700.0);
369
370        let visible: Vec<_> = manager
371            .presentation_plan()
372            .aside_slots
373            .into_iter()
374            .filter(|slot| slot.visible)
375            .map(|slot| slot.kind)
376            .collect();
377        assert_eq!(visible, vec![crate::model::SlotKind::Lxapp]);
378    }
379
380    #[test]
381    fn live_sidebar_width_controls_physical_admission() {
382        let mut manager = SurfaceManager::new(900.0);
383        manager.open(main_s("home"));
384        manager.open(aside_s("chat", Edge::Right));
385        let mut browser = Surface::entry("browser", Role::Aside, "browser");
386        browser.content = crate::model::SurfaceContent::Web {
387            url: "https://example.com".to_string(),
388            reuse_by_url: true,
389        };
390        browser.placement.edge = Some(Edge::Right);
391        manager.open(browser);
392
393        assert_eq!(
394            manager
395                .presentation_plan()
396                .aside_slots
397                .iter()
398                .filter(|slot| slot.visible)
399                .count(),
400            2
401        );
402        manager.set_sidebar_width(300.0);
403        assert_eq!(
404            manager
405                .presentation_plan()
406                .aside_slots
407                .iter()
408                .filter(|slot| slot.visible)
409                .count(),
410            1
411        );
412        manager.set_sidebar_width(0.0);
413        assert_eq!(
414            manager
415                .presentation_plan()
416                .aside_slots
417                .iter()
418                .filter(|slot| slot.visible)
419                .count(),
420            2
421        );
422    }
423
424    #[test]
425    fn width_change_reports_sizeclass_flip_with_hysteresis() {
426        let mut m = SurfaceManager::new(1200.0);
427        // small nudge that stays expanded → no change reported.
428        assert!(!m.set_width(900.0));
429        assert_eq!(m.size_class(), SizeClass::Expanded);
430        // drop to phone width → flips to compact.
431        assert!(m.set_width(390.0));
432        assert_eq!(m.size_class(), SizeClass::Compact);
433        // hovering just under the 600 boundary keeps compact (hysteresis).
434        assert!(!m.set_width(590.0));
435        assert_eq!(m.size_class(), SizeClass::Compact);
436    }
437
438    #[test]
439    fn resize_reflows_existing_aside_without_mutating_roles() {
440        let mut m = SurfaceManager::new(1200.0);
441        m.open(main_s("home"));
442        m.open(aside_s("assistant", Edge::Right));
443        // expanded: real split, aside stays an aside.
444        assert_eq!(m.derive().split_form, SplitForm::Split);
445        assert_eq!(m.graph().role_of("assistant"), Some(Role::Aside));
446        // shrink to compact: same graph, layout re-flows to full-screen.
447        m.set_width(390.0);
448        let d = m.derive();
449        assert_eq!(d.split_form, SplitForm::FullScreen);
450        assert_eq!(d.switcher_form, SwitcherForm::None);
451        let slot = &m.presentation_plan().aside_slots[0];
452        assert!(slot.visible);
453        assert!(slot.overlay);
454        // role unchanged → widening back restores the split (reversible).
455        assert_eq!(m.graph().role_of("assistant"), Some(Role::Aside));
456        m.set_width(1200.0);
457        assert_eq!(m.derive().split_form, SplitForm::Split);
458    }
459}