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 std::collections::HashMap;
10
11use crate::arbitrate::{OpenOutcome, Policy, arbitrate};
12use crate::graph::SurfaceGraph;
13use crate::layout::{DEFAULT_HYSTERESIS, DerivedLayout, LayoutPresentationPlan, SizeClass};
14use crate::model::{Surface, SurfaceId};
15use crate::{CloseOutcome, ReplaceMainsError, Role, SurfacePresentation, SurfaceSwitcherSnapshot};
16
17/// One window's stateful surface driver.
18#[derive(Debug, Clone)]
19pub struct SurfaceManager {
20    graph: SurfaceGraph,
21    presentations: HashMap<SurfaceId, SurfacePresentation>,
22    revision: u64,
23    policy: Policy,
24    width: f64,
25    sidebar_width: f64,
26    hysteresis: f64,
27    size_class: SizeClass,
28    /// Most recently explicitly shown aside that could not be admitted as a
29    /// dock. It remains live and is projected over the main until hidden.
30    overlay_fallback_surface_id: Option<SurfaceId>,
31}
32
33impl SurfaceManager {
34    /// New manager for a container of `width` logical px, default policy.
35    pub fn new(width: f64) -> Self {
36        Self::with_policy(width, Policy::default())
37    }
38
39    pub fn with_policy(width: f64, policy: Policy) -> Self {
40        Self {
41            graph: SurfaceGraph::new(),
42            presentations: HashMap::new(),
43            revision: 0,
44            policy,
45            width,
46            sidebar_width: 0.0,
47            hysteresis: DEFAULT_HYSTERESIS,
48            size_class: SizeClass::from_width(width),
49            overlay_fallback_surface_id: None,
50        }
51    }
52
53    pub fn graph(&self) -> &SurfaceGraph {
54        &self.graph
55    }
56    pub fn size_class(&self) -> SizeClass {
57        self.size_class
58    }
59    pub fn width(&self) -> f64 {
60        self.width
61    }
62
63    fn workspace_width(&self) -> f64 {
64        (self.width - self.sidebar_width).max(0.0)
65    }
66
67    fn needs_overlay(&self, id: &str) -> bool {
68        self.graph.role_of(id) == Some(crate::model::Role::Aside)
69            && (self.size_class == SizeClass::Compact
70                || !self
71                    .graph
72                    .aside_slots_admitted(self.size_class, self.workspace_width(), &self.policy)
73                    .into_iter()
74                    .find(|slot| slot.children.iter().any(|child| child == id))
75                    .is_some_and(|slot| slot.visible))
76    }
77
78    fn reconcile_overlay_fallback(&mut self) {
79        if self
80            .overlay_fallback_surface_id
81            .as_deref()
82            .is_some_and(|id| !self.needs_overlay(id))
83        {
84            self.overlay_fallback_surface_id = None;
85        }
86    }
87
88    /// Update the container width. Returns `true` if the `SizeClass` changed
89    /// after hysteresis, i.e. when the skin must re-derive its layout.
90    pub fn set_width(&mut self, width: f64) -> bool {
91        self.width = width;
92        let next = SizeClass::resolve(Some(self.size_class), width, self.hysteresis);
93        let changed = next != self.size_class;
94        self.size_class = next;
95        self.reconcile_overlay_fallback();
96        changed
97    }
98
99    /// Report the platform's live sidebar allocation. Mobile/custom hosts use
100    /// zero; desktop shells update this during resize and collapse.
101    pub fn set_sidebar_width(&mut self, width: f64) -> bool {
102        let width = if width.is_finite() {
103            width.max(0.0).min(self.width)
104        } else {
105            0.0
106        };
107        let changed = (self.sidebar_width - width).abs() > f64::EPSILON;
108        self.sidebar_width = width;
109        self.reconcile_overlay_fallback();
110        changed
111    }
112
113    /// Open (or replace by id) a surface through the arbiter at the current size.
114    /// Always leaves the graph valid; returns the structured decision.
115    pub fn open(&mut self, request: Surface) -> OpenOutcome {
116        let requested_id = request.id.clone();
117        let default_presentation = SurfacePresentation::for_content(&request.content);
118        let content_changed = self
119            .graph
120            .get(&requested_id)
121            .is_some_and(|surface| surface.content != request.content);
122        let (next, mut outcome) = arbitrate(&self.graph, request, &self.policy, self.size_class);
123        self.graph = next;
124        if outcome.resolved_surface_id == requested_id {
125            if content_changed {
126                self.presentations
127                    .insert(requested_id, default_presentation);
128            } else {
129                self.presentations
130                    .entry(requested_id)
131                    .or_insert(default_presentation);
132            }
133        }
134        self.presentations
135            .retain(|id, _| self.graph.get(id).is_some());
136        self.bump_revision();
137        if outcome.resolved_role == crate::model::Role::Aside {
138            let admitted = self
139                .graph
140                .aside_slots_admitted(self.size_class, self.workspace_width(), &self.policy)
141                .into_iter()
142                .find(|slot| slot.children.contains(&outcome.resolved_surface_id))
143                .is_some_and(|slot| slot.visible);
144            outcome.overlay |= self.size_class == SizeClass::Compact || !admitted;
145            if outcome.overlay {
146                self.overlay_fallback_surface_id = Some(outcome.resolved_surface_id.clone());
147            } else {
148                self.overlay_fallback_surface_id = None;
149            }
150        }
151        outcome
152    }
153
154    pub fn close(&mut self, id: &str) -> CloseOutcome {
155        let outcome = self.graph.close(id);
156        let removed = outcome.removed();
157        if self
158            .overlay_fallback_surface_id
159            .as_ref()
160            .is_some_and(|fallback| removed.contains(fallback))
161        {
162            let focused = self
163                .graph
164                .focused_surface_id
165                .as_deref()
166                .filter(|focused| self.graph.role_of(focused) == Some(crate::model::Role::Aside))
167                .map(str::to_string);
168            self.overlay_fallback_surface_id =
169                focused.filter(|focused| self.needs_overlay(focused));
170        }
171        for id in removed {
172            self.presentations.remove(id);
173        }
174        if matches!(outcome, CloseOutcome::Closed { .. }) {
175            self.bump_revision();
176        }
177        outcome
178    }
179
180    pub fn replace_mains(
181        &mut self,
182        mains: Vec<(Surface, SurfacePresentation)>,
183    ) -> Result<SurfaceSwitcherSnapshot, ReplaceMainsError> {
184        let mut ids = std::collections::HashSet::with_capacity(mains.len());
185        for (surface, _) in &mains {
186            if surface.role != Role::Main {
187                return Err(ReplaceMainsError::InvalidRole {
188                    surface_id: surface.id.clone(),
189                });
190            }
191            if !ids.insert(surface.id.clone()) {
192                return Err(ReplaceMainsError::DuplicateId {
193                    surface_id: surface.id.clone(),
194                });
195            }
196        }
197        let old_main_ids: Vec<_> = self
198            .graph
199            .mains()
200            .into_iter()
201            .map(|surface| surface.id.clone())
202            .collect();
203        self.remove_presentations(&old_main_ids);
204
205        let (surfaces, presentations): (Vec<_>, Vec<_>) = mains.into_iter().unzip();
206        self.graph.replace_mains(surfaces);
207        for (surface, presentation) in self.graph.mains().into_iter().zip(presentations) {
208            self.presentations.insert(surface.id.clone(), presentation);
209        }
210        self.presentations
211            .retain(|id, _| self.graph.get(id).is_some());
212        self.bump_revision();
213        Ok(self.switcher_snapshot())
214    }
215
216    pub fn open_main(
217        &mut self,
218        surface: Surface,
219        presentation: SurfacePresentation,
220    ) -> Result<SurfaceSwitcherSnapshot, ReplaceMainsError> {
221        if surface.role != Role::Main {
222            return Err(ReplaceMainsError::InvalidRole {
223                surface_id: surface.id,
224            });
225        }
226        let surface_id = surface.id.clone();
227        // Main registration has a stricter contract than a general open: the
228        // requested identity must become that exact main. Bypass aside reuse
229        // and role arbitration so a future policy change cannot redirect the
230        // request while we publish presentation metadata under the old id.
231        self.graph.insert(surface);
232        self.presentations.insert(surface_id.clone(), presentation);
233        self.presentations
234            .retain(|id, _| self.graph.get(id).is_some());
235        self.bump_revision();
236        self.set_active_main(&surface_id);
237        Ok(self.switcher_snapshot())
238    }
239
240    pub fn close_other_mains(&mut self, keeping: &str) -> Vec<SurfaceId> {
241        if self.graph.role_of(keeping) != Some(Role::Main) {
242            return Vec::new();
243        }
244        let targets: Vec<_> = self
245            .switcher_snapshot()
246            .items
247            .into_iter()
248            .filter(|item| item.surface_id != keeping && item.closable)
249            .map(|item| item.surface_id)
250            .collect();
251        let removed = targets
252            .into_iter()
253            .flat_map(|id| self.graph.close(&id).into_removed())
254            .collect::<Vec<_>>();
255        self.remove_presentations(&removed);
256        if !removed.is_empty() {
257            self.bump_revision();
258        }
259        removed
260    }
261
262    pub fn close_mains_after(&mut self, id: &str) -> Vec<SurfaceId> {
263        let snapshot = self.switcher_snapshot();
264        let Some(index) = snapshot.items.iter().position(|item| item.surface_id == id) else {
265            return Vec::new();
266        };
267        let removed = snapshot
268            .items
269            .into_iter()
270            .skip(index + 1)
271            .filter(|item| item.closable)
272            .flat_map(|item| self.graph.close(&item.surface_id).into_removed())
273            .collect::<Vec<_>>();
274        self.remove_presentations(&removed);
275        if !removed.is_empty() {
276            self.bump_revision();
277        }
278        removed
279    }
280
281    fn remove_presentations(&mut self, ids: &[SurfaceId]) {
282        for id in ids {
283            self.presentations.remove(id);
284        }
285    }
286
287    pub fn set_presentation(&mut self, id: &str, presentation: SurfacePresentation) -> bool {
288        if self.graph.get(id).is_none() {
289            return false;
290        }
291        self.presentations.insert(id.to_string(), presentation);
292        self.bump_revision();
293        true
294    }
295
296    pub fn update_automatic_title(&mut self, id: &str, title: Option<&str>) -> bool {
297        let Some(presentation) = self.presentations.get_mut(id) else {
298            return false;
299        };
300        let title = title
301            .map(str::trim)
302            .filter(|title| !title.is_empty())
303            .map(str::to_string);
304        if presentation.automatic_title == title {
305            return false;
306        }
307        presentation.automatic_title = title;
308        self.bump_revision();
309        true
310    }
311
312    pub fn rename(&mut self, id: &str, title: Option<&str>) -> bool {
313        let Some(presentation) = self.presentations.get_mut(id) else {
314            return false;
315        };
316        if !presentation.capabilities.rename {
317            return false;
318        }
319        presentation.set_custom_title(title);
320        self.bump_revision();
321        true
322    }
323
324    pub fn switcher_snapshot(&self) -> SurfaceSwitcherSnapshot {
325        let mut snapshot = SurfaceSwitcherSnapshot::derive(&self.graph, &self.presentations);
326        snapshot.revision = self.revision;
327        snapshot
328    }
329
330    fn bump_revision(&mut self) {
331        self.revision = self.revision.wrapping_add(1);
332    }
333
334    pub fn set_active_main(&mut self, id: &str) -> bool {
335        let changed = self.graph.active_main_id.as_deref() != Some(id);
336        let active = self.graph.set_active_main(id);
337        if active {
338            self.overlay_fallback_surface_id = None;
339            if changed {
340                self.bump_revision();
341            }
342        }
343        active
344    }
345    pub fn set_focus(&mut self, id: &str) -> bool {
346        let focused = self.graph.set_focus(id);
347        if focused {
348            self.overlay_fallback_surface_id = self.needs_overlay(id).then(|| id.to_string());
349        }
350        focused
351    }
352
353    pub fn show(&mut self, id: &str) -> bool {
354        let role = self.graph.role_of(id);
355        let activates_main =
356            role == Some(Role::Main) && self.graph.active_main_id.as_deref() != Some(id);
357        let shown = self.graph.show(id);
358        if shown && role == Some(Role::Main) {
359            self.overlay_fallback_surface_id = None;
360            if activates_main {
361                // `SurfaceGraph::show` performs the selection. Mirror
362                // `set_active_main`'s observable switcher revision here.
363                self.bump_revision();
364            }
365        }
366        if shown && role == Some(Role::Aside) {
367            let admitted = self
368                .graph
369                .aside_slots_admitted(self.size_class, self.workspace_width(), &self.policy)
370                .into_iter()
371                .find(|slot| slot.children.iter().any(|child| child == id))
372                .is_some_and(|slot| slot.visible);
373            if self.size_class == SizeClass::Compact || !admitted {
374                self.overlay_fallback_surface_id = Some(id.to_string());
375            } else {
376                self.overlay_fallback_surface_id = None;
377            }
378        }
379        shown
380    }
381
382    /// Collapse or restore a whole aside slot. Collapsing is the shell's
383    /// "put this region away" gesture: nothing closes, so the overlay
384    /// fallback for a child of the slot is dropped along with the dock.
385    pub fn set_slot_collapsed(&mut self, kind: crate::SlotKind, collapsed: bool) -> bool {
386        if !self.graph.set_slot_collapsed(kind, collapsed) {
387            return false;
388        }
389        if collapsed
390            && let Some(id) = self.overlay_fallback_surface_id.clone()
391            && self
392                .graph
393                .get(&id)
394                .is_some_and(|surface| surface.content.slot_kind() == kind)
395        {
396            self.overlay_fallback_surface_id = None;
397        }
398        true
399    }
400
401    pub fn hide(&mut self, id: &str) -> bool {
402        let hidden = self.graph.hide(id);
403        if hidden && self.overlay_fallback_surface_id.as_deref() == Some(id) {
404            let focused = self
405                .graph
406                .focused_surface_id
407                .as_deref()
408                .filter(|focused| self.graph.role_of(focused) == Some(crate::model::Role::Aside))
409                .map(str::to_string);
410            self.overlay_fallback_surface_id =
411                focused.filter(|focused| self.needs_overlay(focused));
412        }
413        hidden
414    }
415
416    /// Derive the platform-agnostic layout output at the current size.
417    pub fn derive(&self) -> DerivedLayout {
418        self.graph.derive_layout(self.size_class)
419    }
420
421    /// Build the stable, skin-bindable [`LayoutPresentationPlan`] at the current
422    /// size — the renderable contract platforms reconcile against. Slot
423    /// admission respects both the size-class ceiling and the physical fit at
424    /// the current width (§3.3).
425    pub fn presentation_plan(&self) -> LayoutPresentationPlan {
426        let mut plan =
427            self.graph
428                .presentation_plan(self.size_class, self.workspace_width(), &self.policy);
429        plan.main_switcher = self.switcher_snapshot();
430        if self.size_class == SizeClass::Compact {
431            for slot in plan.aside_slots.iter_mut().filter(|slot| slot.visible) {
432                slot.overlay = true;
433            }
434        }
435        if let Some(id) = self.overlay_fallback_surface_id.as_deref()
436            && let Some(slot) = plan
437                .aside_slots
438                .iter_mut()
439                .find(|slot| slot.children.iter().any(|child| child == id))
440            && !slot.collapsed
441            && (self.size_class == SizeClass::Compact || !slot.visible)
442        {
443            slot.visible = true;
444            slot.active_child = Some(id.to_string());
445            slot.overlay = true;
446        }
447        plan
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::Decision;
455    use crate::layout::{SplitForm, SwitcherForm};
456    use crate::{Edge, Role, Surface, SurfaceContent};
457
458    fn main_s(id: &str) -> Surface {
459        Surface::lxapp(id, Role::Main, id)
460    }
461    fn aside_s(id: &str, edge: Edge) -> Surface {
462        let mut s = Surface::lxapp(id, Role::Aside, id);
463        s.placement.edge = Some(edge);
464        s
465    }
466
467    #[test]
468    fn open_then_derive_on_expanded() {
469        let mut m = SurfaceManager::new(1200.0);
470        assert_eq!(m.size_class(), SizeClass::Expanded);
471        assert_eq!(m.open(main_s("home")), Decision::Accepted);
472        assert_eq!(
473            m.open(aside_s("assistant", Edge::Right)),
474            Decision::Accepted
475        );
476        let d = m.derive();
477        assert_eq!(d.split_form, SplitForm::Split);
478        assert!(m.graph().is_valid());
479    }
480
481    #[test]
482    fn aside_on_compact_overlays_without_host_switcher() {
483        let mut m = SurfaceManager::new(390.0); // phone width
484        assert_eq!(m.size_class(), SizeClass::Compact);
485        m.open(main_s("home"));
486        // Arbitration preserves the aside role and marks it as a full-screen
487        // overlay; compact still has no sidebar switcher.
488        assert_eq!(
489            m.open(aside_s("assistant", Edge::Right)),
490            Decision::FullScreenFallback
491        );
492        let d = m.derive();
493        assert_eq!(d.switcher_form, SwitcherForm::None);
494        assert_eq!(d.bottom_owner, crate::BottomOwner::App);
495        let slot = &m.presentation_plan().aside_slots[0];
496        assert!(slot.visible);
497        assert!(slot.overlay);
498        assert!(m.graph().is_valid());
499    }
500
501    #[test]
502    fn collapsing_a_slot_keeps_its_children_alive() {
503        let mut m = SurfaceManager::new(1200.0);
504        m.open(main_s("home"));
505        m.open(aside_s("assistant", Edge::Right));
506        m.open(aside_s("notes", Edge::Right));
507
508        assert!(m.set_slot_collapsed(crate::SlotKind::Lxapp, true));
509        let slot = &m.presentation_plan().aside_slots[0];
510        assert!(!slot.visible);
511        assert!(slot.collapsed);
512        assert_eq!(slot.children.len(), 2);
513
514        assert!(m.set_slot_collapsed(crate::SlotKind::Lxapp, false));
515        let slot = &m.presentation_plan().aside_slots[0];
516        assert!(slot.visible);
517        assert_eq!(slot.children.len(), 2);
518    }
519
520    #[test]
521    fn a_collapsed_slot_reopens_when_content_arrives_or_is_focused() {
522        let mut m = SurfaceManager::new(1200.0);
523        m.open(main_s("home"));
524        m.open(aside_s("assistant", Edge::Right));
525
526        m.set_slot_collapsed(crate::SlotKind::Lxapp, true);
527        m.open(aside_s("notes", Edge::Right));
528        assert!(m.presentation_plan().aside_slots[0].visible);
529
530        m.set_slot_collapsed(crate::SlotKind::Lxapp, true);
531        assert!(m.set_focus("assistant"));
532        assert!(m.presentation_plan().aside_slots[0].visible);
533    }
534
535    #[test]
536    fn a_collapsed_slot_that_loses_its_last_child_starts_open_again() {
537        let mut m = SurfaceManager::new(1200.0);
538        m.open(main_s("home"));
539        m.open(aside_s("assistant", Edge::Right));
540        m.set_slot_collapsed(crate::SlotKind::Lxapp, true);
541        m.close("assistant");
542
543        m.open(aside_s("notes", Edge::Right));
544        assert!(m.presentation_plan().aside_slots[0].visible);
545    }
546
547    #[test]
548    fn a_collapsed_compact_slot_does_not_come_back_as_an_overlay() {
549        let mut m = SurfaceManager::new(390.0);
550        m.open(main_s("home"));
551        m.open(aside_s("assistant", Edge::Right));
552        assert!(m.presentation_plan().aside_slots[0].overlay);
553
554        m.set_slot_collapsed(crate::SlotKind::Lxapp, true);
555        let slot = &m.presentation_plan().aside_slots[0];
556        assert!(!slot.visible);
557        assert!(!slot.overlay);
558    }
559
560    #[test]
561    fn width_changes_recompute_physical_admission_within_a_size_class() {
562        let mut manager = SurfaceManager::new(1400.0);
563        manager.set_sidebar_width(184.0);
564        manager.open(main_s("home"));
565        manager.open(aside_s("lxapp", Edge::Right));
566        let mut browser = Surface::lxapp("browser", Role::Aside, "browser");
567        browser.content = SurfaceContent::Browser {
568            initial_url: "https://example.com".to_string(),
569            reuse_by_url: true,
570        };
571        browser.placement.edge = Some(Edge::Right);
572        manager.open(browser);
573        let mut native = Surface::native("terminal", Role::Aside, "terminal");
574        native.placement.edge = Some(Edge::Right);
575        manager.open(native);
576        assert_eq!(
577            manager
578                .presentation_plan()
579                .aside_slots
580                .iter()
581                .filter(|slot| slot.visible)
582                .count(),
583            3
584        );
585
586        // Both widths are Expanded. After the full sidebar is allocated, only
587        // one horizontal slot fits at a 900-wide client area.
588        assert!(!manager.set_width(900.0));
589        assert_eq!(manager.size_class(), SizeClass::Expanded);
590        assert_eq!(
591            manager
592                .presentation_plan()
593                .aside_slots
594                .iter()
595                .filter(|slot| slot.visible)
596                .count(),
597            1
598        );
599    }
600
601    #[test]
602    fn explicitly_opened_non_fitting_aside_overlays_until_it_can_dock() {
603        let mut manager = SurfaceManager::new(500.0);
604        manager.open(main_s("home"));
605        let outcome = manager.open(aside_s("assistant", Edge::Right));
606        assert!(outcome.overlay);
607        let slot = &manager.presentation_plan().aside_slots[0];
608        assert!(slot.visible);
609        assert!(slot.overlay);
610
611        manager.set_width(700.0);
612        let slot = &manager.presentation_plan().aside_slots[0];
613        assert!(slot.visible);
614        assert!(!slot.overlay);
615    }
616
617    #[test]
618    fn compact_focus_updates_the_overlay_tab() {
619        let mut manager = SurfaceManager::new(500.0);
620        manager.open(main_s("home"));
621        manager.open(aside_s("first", Edge::Right));
622        manager.open(aside_s("second", Edge::Right));
623
624        assert!(manager.set_focus("first"));
625        let slot = &manager.presentation_plan().aside_slots[0];
626        assert_eq!(slot.active_child.as_deref(), Some("first"));
627    }
628
629    #[test]
630    fn showing_an_existing_main_advances_the_switcher_revision() {
631        let mut manager = SurfaceManager::new(1200.0);
632        manager.open(main_s("home"));
633        manager.open(main_s("workspace"));
634        manager.set_active_main("workspace");
635        let before = manager.switcher_snapshot();
636
637        assert!(manager.show("home"));
638        let after = manager.switcher_snapshot();
639
640        assert_eq!(after.active_surface_id.as_deref(), Some("home"));
641        assert!(after.revision > before.revision);
642    }
643
644    #[test]
645    fn docked_fallback_does_not_reappear_after_later_resize() {
646        let policy = Policy {
647            main_min_width: 400.0,
648            aside_min_width: 240.0,
649            ..Policy::default()
650        };
651        let mut manager = SurfaceManager::with_policy(620.0, policy);
652        manager.open(main_s("home"));
653        let mut browser = Surface::lxapp("browser", Role::Aside, "browser");
654        browser.content = SurfaceContent::Browser {
655            initial_url: "https://example.com".to_string(),
656            reuse_by_url: true,
657        };
658        browser.placement.edge = Some(Edge::Right);
659        assert!(manager.open(browser).overlay);
660
661        manager.set_width(1000.0);
662        manager.open(aside_s("chat", Edge::Right));
663        assert!(manager.set_focus("chat"));
664        manager.set_width(700.0);
665
666        let visible: Vec<_> = manager
667            .presentation_plan()
668            .aside_slots
669            .into_iter()
670            .filter(|slot| slot.visible)
671            .map(|slot| slot.kind)
672            .collect();
673        assert_eq!(visible, vec![crate::SlotKind::Lxapp]);
674    }
675
676    #[test]
677    fn live_sidebar_width_controls_physical_admission() {
678        let mut manager = SurfaceManager::new(900.0);
679        manager.open(main_s("home"));
680        manager.open(aside_s("chat", Edge::Right));
681        let mut browser = Surface::lxapp("browser", Role::Aside, "browser");
682        browser.content = SurfaceContent::Browser {
683            initial_url: "https://example.com".to_string(),
684            reuse_by_url: true,
685        };
686        browser.placement.edge = Some(Edge::Right);
687        manager.open(browser);
688
689        assert_eq!(
690            manager
691                .presentation_plan()
692                .aside_slots
693                .iter()
694                .filter(|slot| slot.visible)
695                .count(),
696            2
697        );
698        manager.set_sidebar_width(300.0);
699        assert_eq!(
700            manager
701                .presentation_plan()
702                .aside_slots
703                .iter()
704                .filter(|slot| slot.visible)
705                .count(),
706            1
707        );
708        manager.set_sidebar_width(0.0);
709        assert_eq!(
710            manager
711                .presentation_plan()
712                .aside_slots
713                .iter()
714                .filter(|slot| slot.visible)
715                .count(),
716            2
717        );
718    }
719
720    #[test]
721    fn width_change_reports_sizeclass_flip_with_hysteresis() {
722        let mut m = SurfaceManager::new(1200.0);
723        // small nudge that stays expanded → no change reported.
724        assert!(!m.set_width(900.0));
725        assert_eq!(m.size_class(), SizeClass::Expanded);
726        // drop to phone width → flips to compact.
727        assert!(m.set_width(390.0));
728        assert_eq!(m.size_class(), SizeClass::Compact);
729        // hovering just under the 600 boundary keeps compact (hysteresis).
730        assert!(!m.set_width(590.0));
731        assert_eq!(m.size_class(), SizeClass::Compact);
732    }
733
734    #[test]
735    fn resize_reflows_existing_aside_without_mutating_roles() {
736        let mut m = SurfaceManager::new(1200.0);
737        m.open(main_s("home"));
738        m.open(aside_s("assistant", Edge::Right));
739        // expanded: real split, aside stays an aside.
740        assert_eq!(m.derive().split_form, SplitForm::Split);
741        assert_eq!(m.graph().role_of("assistant"), Some(Role::Aside));
742        // shrink to compact: same graph, layout re-flows to full-screen.
743        m.set_width(390.0);
744        let d = m.derive();
745        assert_eq!(d.split_form, SplitForm::FullScreen);
746        assert_eq!(d.switcher_form, SwitcherForm::None);
747        let slot = &m.presentation_plan().aside_slots[0];
748        assert!(slot.visible);
749        assert!(slot.overlay);
750        // role unchanged → widening back restores the split (reversible).
751        assert_eq!(m.graph().role_of("assistant"), Some(Role::Aside));
752        m.set_width(1200.0);
753        assert_eq!(m.derive().split_form, SplitForm::Split);
754    }
755}