Skip to main content

lingxia_surface/
graph.rs

1//! The Surface Graph: single source of truth, invariants, state transitions,
2//! and the two-axis derivation into `DerivedLayout`.
3
4use serde::{Deserialize, Serialize};
5
6use crate::content::SlotKind;
7use crate::layout::{
8    Axis, BottomOwner, DerivedLayout, LayoutPresentationPlan, LayoutTree, PlanAside, SizeClass,
9    SplitForm, SwitcherForm,
10};
11use crate::model::{Role, Surface, SurfaceId, SurfaceState};
12use crate::switcher::CloseOutcome;
13
14/// One window's graph. Surfaces are kept in insertion order so that
15/// "adjacent main" succession and "oldest aside" replacement are deterministic.
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct SurfaceGraph {
19    surfaces: Vec<Surface>,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    root_main_id: Option<SurfaceId>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub active_main_id: Option<SurfaceId>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub focused_surface_id: Option<SurfaceId>,
26    /// Focus snapshots pushed when a modal float opens, popped on its close.
27    #[serde(default, skip)]
28    modal_focus_stack: Vec<Option<SurfaceId>>,
29    /// Aside slot kinds in least- to most-recently-used order. Kept separate
30    /// from `surfaces` so focus/reopen affects admission without reordering tabs.
31    #[serde(default)]
32    aside_slot_mru: Vec<SlotKind>,
33    /// Aside children in least- to most-recently-used order. This is separate
34    /// from insertion order so hiding an active child can reveal the most
35    /// recently used sibling without reordering the slot's tabs.
36    #[serde(default)]
37    aside_child_mru: Vec<SurfaceId>,
38    /// Slot kinds the user collapsed from the shell. Collapsing only stops the
39    /// slot from being admitted — every child stays alive, and opening or
40    /// focusing a child in it brings the slot straight back.
41    #[serde(default)]
42    collapsed_slots: Vec<SlotKind>,
43}
44
45impl SurfaceGraph {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    pub fn surfaces(&self) -> &[Surface] {
51        &self.surfaces
52    }
53
54    pub fn get(&self, id: &str) -> Option<&Surface> {
55        self.surfaces.iter().find(|s| s.id == id)
56    }
57
58    pub fn role_of(&self, id: &str) -> Option<Role> {
59        self.get(id).map(|s| s.role)
60    }
61
62    pub fn root_main_id(&self) -> Option<&str> {
63        self.root_main_id.as_deref()
64    }
65
66    pub fn is_root_main(&self, id: &str) -> bool {
67        self.root_main_id.as_deref() == Some(id)
68    }
69
70    pub(crate) fn replace_mains(&mut self, mains: Vec<Surface>) {
71        debug_assert!(mains.iter().all(|surface| surface.role == Role::Main));
72        let old_main_ids: std::collections::HashSet<_> = self.main_ids().into_iter().collect();
73        self.surfaces
74            .retain(|surface| !old_main_ids.contains(&surface.id));
75        self.root_main_id = None;
76        self.active_main_id = None;
77
78        let mut retained = std::mem::take(&mut self.surfaces);
79        self.surfaces = mains;
80        self.surfaces.append(&mut retained);
81        if self.mains().is_empty() {
82            self.surfaces.retain(|surface| surface.role != Role::Aside);
83        }
84        self.converge_after_insert();
85        if self
86            .focused_surface_id
87            .as_deref()
88            .is_none_or(|id| self.get(id).is_none())
89        {
90            self.focused_surface_id = self.active_main_id.clone();
91        }
92        self.prune_aside_slot_mru();
93    }
94
95    pub fn mains(&self) -> Vec<&Surface> {
96        self.by_role(Role::Main)
97    }
98    pub fn asides(&self) -> Vec<&Surface> {
99        self.by_role(Role::Aside)
100    }
101    pub fn floats(&self) -> Vec<&Surface> {
102        self.by_role(Role::Float)
103    }
104
105    fn by_role(&self, role: Role) -> Vec<&Surface> {
106        self.surfaces.iter().filter(|s| s.role == role).collect()
107    }
108
109    fn main_ids(&self) -> Vec<SurfaceId> {
110        self.surfaces
111            .iter()
112            .filter(|s| s.role == Role::Main)
113            .map(|s| s.id.clone())
114            .collect()
115    }
116
117    /// Insert (or replace by id) a surface, then re-converge invariants.
118    pub fn insert(&mut self, surface: Surface) {
119        // Ordinary opens may update the root's content while it remains main,
120        // but only declaration replacement may choose a different root.
121        if self.is_root_main(&surface.id) && surface.role != Role::Main {
122            return;
123        }
124        let surface_id = surface.id.clone();
125        let modal = surface.is_modal_float();
126        let was_modal = self.get(&surface.id).is_some_and(Surface::is_modal_float);
127        let previous_slot = self
128            .get(&surface.id)
129            .filter(|existing| existing.role == Role::Aside)
130            .map(|existing| existing.content.slot_kind());
131        let next_slot = (surface.role == Role::Aside).then(|| surface.content.slot_kind());
132        if modal && !was_modal {
133            self.modal_focus_stack.push(self.focused_surface_id.clone());
134        } else if !modal && was_modal {
135            let _ = self.modal_focus_stack.pop();
136        }
137        if let Some(existing) = self.surfaces.iter_mut().find(|s| s.id == surface.id) {
138            *existing = surface;
139        } else {
140            self.surfaces.push(surface);
141        }
142        if let Some(previous) = previous_slot
143            && Some(previous) != next_slot
144            && !self
145                .asides()
146                .iter()
147                .any(|aside| aside.content.slot_kind() == previous)
148        {
149            self.aside_slot_mru.retain(|kind| *kind != previous);
150        }
151        if let Some(kind) = next_slot {
152            self.touch_aside_slot(kind);
153            self.touch_aside_child(&surface_id);
154        }
155        self.converge_after_insert();
156    }
157
158    fn touch_aside_child(&mut self, id: &str) {
159        self.aside_child_mru.retain(|entry| entry != id);
160        self.aside_child_mru.push(id.to_string());
161    }
162
163    fn touch_aside_slot(&mut self, kind: SlotKind) {
164        self.aside_slot_mru.retain(|entry| *entry != kind);
165        self.aside_slot_mru.push(kind);
166        // Content arriving in (or being focused inside) a collapsed slot must
167        // not land invisibly.
168        self.collapsed_slots.retain(|entry| *entry != kind);
169    }
170
171    pub fn slot_collapsed(&self, kind: SlotKind) -> bool {
172        self.collapsed_slots.contains(&kind)
173    }
174
175    /// Collapse or restore an aside slot. Returns whether the state changed.
176    pub fn set_slot_collapsed(&mut self, kind: SlotKind, collapsed: bool) -> bool {
177        if collapsed == self.slot_collapsed(kind) {
178            return false;
179        }
180        if collapsed {
181            self.collapsed_slots.push(kind);
182        } else {
183            self.collapsed_slots.retain(|entry| *entry != kind);
184        }
185        true
186    }
187
188    fn prune_aside_slot_mru(&mut self) {
189        let live: std::collections::HashSet<SlotKind> = self
190            .asides()
191            .iter()
192            .map(|aside| aside.content.slot_kind())
193            .collect();
194        self.aside_slot_mru.retain(|kind| live.contains(kind));
195        // A slot that lost its last child starts fresh (uncollapsed) next time.
196        self.collapsed_slots.retain(|kind| live.contains(kind));
197        let live_children: std::collections::HashSet<SurfaceId> = self
198            .asides()
199            .into_iter()
200            .map(|surface| surface.id.clone())
201            .collect();
202        self.aside_child_mru.retain(|id| live_children.contains(id));
203    }
204
205    fn converge_after_insert(&mut self) {
206        let first_main = self.main_ids().first().cloned();
207        if self
208            .root_main_id
209            .as_deref()
210            .is_none_or(|id| self.role_of(id) != Some(Role::Main))
211        {
212            self.root_main_id = first_main.clone();
213        }
214        if self
215            .active_main_id
216            .as_deref()
217            .is_none_or(|id| self.role_of(id) != Some(Role::Main))
218        {
219            self.active_main_id = first_main.clone();
220            if self.focused_surface_id.is_none() {
221                self.focused_surface_id = first_main;
222            }
223        }
224        // A freshly inserted last surface still focuses if nothing else did.
225        if self.focused_surface_id.is_none()
226            && let Some(s) = self.surfaces.last()
227        {
228            self.focused_surface_id = Some(s.id.clone());
229        }
230    }
231
232    /// Close one surface. The window's root main is a stable navigation anchor
233    /// and cannot be closed through ordinary surface lifecycle operations.
234    pub fn close(&mut self, id: &str) -> CloseOutcome {
235        if self.is_root_main(id) {
236            return CloseOutcome::RejectedRoot {
237                surface_id: id.to_string(),
238            };
239        }
240        let Some(pos) = self.surfaces.iter().position(|s| s.id == id) else {
241            return CloseOutcome::NotFound;
242        };
243        let removed = self.surfaces.remove(pos);
244        let removed_aside_kind = (removed.role == Role::Aside).then(|| removed.content.slot_kind());
245        let mut removed_ids = vec![removed.id.clone()];
246
247        // Succession for active main.
248        if self.active_main_id.as_deref() == Some(id) {
249            self.active_main_id = pick_successor_main(&self.surfaces, pos);
250        }
251
252        // Last main gone ⇒ all asides close (no primary, no companion).
253        if self.mains().is_empty() {
254            let aside_ids: Vec<SurfaceId> = self
255                .surfaces
256                .iter()
257                .filter(|s| s.role == Role::Aside)
258                .map(|s| s.id.clone())
259                .collect();
260            self.surfaces.retain(|s| s.role != Role::Aside);
261            removed_ids.extend(aside_ids);
262        }
263
264        // Modal float closing: restore the pre-popup focus snapshot.
265        if removed.is_modal_float()
266            && let Some(snapshot) = self.modal_focus_stack.pop()
267        {
268            self.focused_surface_id = snapshot;
269        }
270
271        // Focus fallback if the focused surface is gone.
272        if self
273            .focused_surface_id
274            .as_deref()
275            .is_none_or(|f| self.get(f).is_none())
276        {
277            self.focused_surface_id = removed_aside_kind
278                .and_then(|kind| {
279                    self.aside_child_mru.iter().rev().find_map(|candidate| {
280                        self.get(candidate).and_then(|surface| {
281                            (surface.state == SurfaceState::Mounted
282                                && surface.role == Role::Aside
283                                && surface.content.slot_kind() == kind)
284                                .then(|| surface.id.clone())
285                        })
286                    })
287                })
288                .or_else(|| self.focus_fallback());
289        }
290        self.prune_aside_slot_mru();
291        CloseOutcome::Closed {
292            removed: removed_ids,
293        }
294    }
295
296    /// Structurally remove every other non-root main.
297    ///
298    /// User-facing capability filtering belongs to `SurfaceManager`.
299    pub fn close_other_mains(&mut self, keeping: &str) -> Vec<SurfaceId> {
300        if self.role_of(keeping) != Some(Role::Main) {
301            return Vec::new();
302        }
303        let targets: Vec<_> = self
304            .main_ids()
305            .into_iter()
306            .filter(|id| id != keeping && !self.is_root_main(id))
307            .collect();
308        targets
309            .into_iter()
310            .flat_map(|id| self.close(&id).into_removed())
311            .collect()
312    }
313
314    /// Structurally remove non-root mains after `id` in stable switcher order.
315    ///
316    /// User-facing capability filtering belongs to `SurfaceManager`.
317    pub fn close_mains_after(&mut self, id: &str) -> Vec<SurfaceId> {
318        let mains = self.main_ids();
319        let Some(index) = mains.iter().position(|candidate| candidate == id) else {
320            return Vec::new();
321        };
322        let root_main_id = self.root_main_id.clone();
323        mains
324            .into_iter()
325            .skip(index + 1)
326            .filter(|candidate| root_main_id.as_ref() != Some(candidate))
327            .flat_map(|candidate| self.close(&candidate).into_removed())
328            .collect()
329    }
330
331    /// Focus fallback order: active main → an aside owned by it → none.
332    fn focus_fallback(&self) -> Option<SurfaceId> {
333        if let Some(active) = &self.active_main_id
334            && self.get(active).is_some()
335        {
336            return Some(active.clone());
337        }
338        self.asides().first().map(|s| s.id.clone())
339    }
340
341    /// Switch which main is primary. No-op if `id` is not an existing main.
342    pub fn set_active_main(&mut self, id: &str) -> bool {
343        if self.role_of(id) == Some(Role::Main) {
344            self.active_main_id = Some(id.to_string());
345            true
346        } else {
347            false
348        }
349    }
350
351    /// Focus any surface (any role).
352    pub fn set_focus(&mut self, id: &str) -> bool {
353        let aside_kind = self
354            .get(id)
355            .filter(|surface| surface.role == Role::Aside)
356            .map(|surface| surface.content.slot_kind());
357        if self.get(id).is_some() {
358            if let Some(surface) = self.surfaces.iter_mut().find(|surface| surface.id == id) {
359                surface.state = SurfaceState::Mounted;
360            }
361            self.focused_surface_id = Some(id.to_string());
362            if let Some(kind) = aside_kind {
363                self.touch_aside_slot(kind);
364                self.touch_aside_child(id);
365            }
366            true
367        } else {
368            false
369        }
370    }
371
372    /// Show a live surface without changing its identity. A main is selected;
373    /// aside/float visibility is represented by mounted state and focus.
374    pub fn show(&mut self, id: &str) -> bool {
375        let Some(role) = self.role_of(id) else {
376            return false;
377        };
378        if let Some(surface) = self.surfaces.iter_mut().find(|surface| surface.id == id) {
379            surface.state = SurfaceState::Mounted;
380        }
381        match role {
382            Role::Main => self.set_active_main(id),
383            Role::Aside | Role::Float => self.set_focus(id),
384        }
385    }
386
387    /// Hide a live aside/float while retaining it. Hiding the active child of
388    /// an aside slot selects that slot's most-recent visible sibling; when no
389    /// sibling remains the slot disappears and focus returns to the main.
390    pub fn hide(&mut self, id: &str) -> bool {
391        let Some(surface) = self.get(id) else {
392            return false;
393        };
394        if surface.role == Role::Main {
395            return false;
396        }
397        let role = surface.role;
398        let slot_kind = (role == Role::Aside).then(|| surface.content.slot_kind());
399        if let Some(surface) = self.surfaces.iter_mut().find(|surface| surface.id == id) {
400            surface.state = SurfaceState::Hidden;
401        }
402        if self.focused_surface_id.as_deref() == Some(id) {
403            let sibling = slot_kind.and_then(|kind| {
404                self.aside_child_mru.iter().rev().find_map(|candidate| {
405                    self.get(candidate).and_then(|surface| {
406                        (surface.id != id
407                            && surface.role == Role::Aside
408                            && surface.state == SurfaceState::Mounted
409                            && surface.content.slot_kind() == kind)
410                            .then(|| surface.id.clone())
411                    })
412                })
413            });
414            self.focused_surface_id = sibling.or_else(|| self.active_main_id.clone());
415        }
416        true
417    }
418
419    /// Group the asides into per-kind slots (lxapp / browser / native), in
420    /// first-open order, with tab order = open order. Admission marks the
421    /// most recently used slots visible — the size class caps the count
422    /// (expanded 3 / medium 1 / compact 0), and physical width caps it
423    /// further so the main keeps its minimum. Hidden slots stay alive and
424    /// are never evicted; widening the container brings them back.
425    ///
426    /// `width` is the container's workspace width (window minus sidebar) and
427    /// `policy` carries the min-size tokens. See [`Self::aside_slots`] for the
428    /// size-class-only convenience used by tests.
429    pub fn aside_slots_admitted(
430        &self,
431        size_class: SizeClass,
432        width: f64,
433        policy: &crate::arbitrate::Policy,
434    ) -> Vec<crate::layout::PlanAsideSlot> {
435        let max_visible = policy.max_asides(size_class);
436        let mut slots = self.aside_slots_recency(usize::MAX);
437        for slot in &mut slots {
438            slot.visible = false;
439        }
440        // §3.3 physical admission: reserve the main's minimum, then admit
441        // slots greedily in MRU order until the count ceiling is full.
442        // Left/right slots must keep their minimum width; top/bottom slots
443        // overlay the main's width and do not consume horizontal budget. A
444        // candidate that does not fit must not prevent an older fitting slot
445        // from being considered.
446        let candidates = self.slot_indices_by_recency(&slots);
447        let mut horizontal_used = policy.main_min_width;
448        let mut admitted = 0;
449        for i in candidates {
450            if admitted == max_visible {
451                break;
452            }
453            if slots[i].active_child.is_none() || slots[i].collapsed {
454                continue;
455            }
456            if size_class == SizeClass::Compact {
457                slots[i].visible = true;
458                admitted += 1;
459                continue;
460            }
461            let horizontal = !matches!(
462                slots[i].edge,
463                Some(crate::model::Edge::Top) | Some(crate::model::Edge::Bottom)
464            );
465            if horizontal && horizontal_used + policy.aside_min_width > width {
466                continue;
467            }
468            if horizontal {
469                horizontal_used += policy.aside_min_width;
470            }
471            slots[i].visible = true;
472            admitted += 1;
473        }
474        slots
475    }
476
477    /// Size-class-only admission (count ceiling, no physical width). Retained
478    /// for callers/tests that don't have a container width.
479    pub fn aside_slots(&self, size_class: SizeClass) -> Vec<crate::layout::PlanAsideSlot> {
480        self.aside_slots_recency(crate::arbitrate::Policy::default().max_asides(size_class))
481    }
482
483    /// Shared slot grouping + count-based (recency) admission. Both public
484    /// entry points build on this; `aside_slots_admitted` then layers the
485    /// physical width check on top.
486    fn aside_slots_recency(&self, max_visible: usize) -> Vec<crate::layout::PlanAsideSlot> {
487        let asides = self.asides();
488        let mut slots: Vec<crate::layout::PlanAsideSlot> = Vec::new();
489        // `asides()` preserves insertion order, so child pushes keep tab
490        // order == open order and slot order == first-open order.
491        for surface in &asides {
492            let kind = surface.content.slot_kind();
493            let slot = match slots.iter_mut().find(|slot| slot.kind == kind) {
494                Some(slot) => slot,
495                None => {
496                    slots.push(crate::layout::PlanAsideSlot {
497                        kind,
498                        edge: None,
499                        children: Vec::new(),
500                        active_child: None,
501                        visible: false,
502                        overlay: false,
503                        collapsed: self.slot_collapsed(kind),
504                    });
505                    slots.last_mut().expect("just pushed")
506                }
507            };
508            slot.children.push(surface.id.clone());
509            // The most recently placed child's explicit edge wins.
510            if surface.placement.edge.is_some() {
511                slot.edge = surface.placement.edge;
512            }
513        }
514        for slot in &mut slots {
515            // Active child: the focused surface when it lives in this slot,
516            // else the newest child.
517            slot.active_child = self
518                .focused_surface_id
519                .as_ref()
520                .filter(|id| {
521                    slot.children.contains(id)
522                        && self
523                            .get(id)
524                            .is_some_and(|surface| surface.state == SurfaceState::Mounted)
525                })
526                .cloned()
527                .or_else(|| {
528                    self.aside_child_mru.iter().rev().find_map(|id| {
529                        (slot.children.contains(id)
530                            && self
531                                .get(id)
532                                .is_some_and(|surface| surface.state == SurfaceState::Mounted))
533                        .then(|| id.clone())
534                    })
535                });
536        }
537        let admitted: Vec<usize> = self
538            .slot_indices_by_recency(&slots)
539            .into_iter()
540            .filter(|index| !slots[*index].collapsed)
541            .take(max_visible)
542            .collect();
543        for slot_index in admitted {
544            slots[slot_index].visible = true;
545        }
546        slots
547    }
548
549    /// Slot indices from most to least recently used. Current graphs always
550    /// have `aside_slot_mru`; the child insertion fallback keeps older
551    /// serialized graphs deterministic on first load.
552    fn slot_indices_by_recency(&self, slots: &[crate::layout::PlanAsideSlot]) -> Vec<usize> {
553        let asides = self.asides();
554        let mut indices: Vec<usize> = (0..slots.len()).collect();
555        indices.sort_by_key(|index| {
556            let slot = &slots[*index];
557            let explicit = self
558                .aside_slot_mru
559                .iter()
560                .position(|kind| *kind == slot.kind);
561            let fallback = asides
562                .iter()
563                .rposition(|surface| slot.children.contains(&surface.id))
564                .unwrap_or(0);
565            std::cmp::Reverse((explicit.is_some(), explicit.unwrap_or(fallback), fallback))
566        });
567        indices
568    }
569
570    /// Check the invariants. Returns the list of violations (empty = ok).
571    pub fn check_invariants(&self) -> Vec<String> {
572        let mut v = Vec::new();
573        let mains = self.mains().len();
574        let asides = self.asides().len();
575        if asides > 0 && mains == 0 {
576            v.push("asides>0 but mains==0 (no primary, no companion)".into());
577        }
578        match &self.active_main_id {
579            Some(id) if self.role_of(id) != Some(Role::Main) => {
580                v.push(format!("activeMainId '{id}' is not a main"));
581            }
582            None if mains > 0 => v.push("mains exist but activeMainId is None".into()),
583            _ => {}
584        }
585        match &self.root_main_id {
586            Some(id) if self.role_of(id) != Some(Role::Main) => {
587                v.push(format!("rootMainId '{id}' is not a main"));
588            }
589            None if mains > 0 => v.push("mains exist but rootMainId is None".into()),
590            _ => {}
591        }
592        if let Some(f) = &self.focused_surface_id
593            && self.get(f).is_none()
594        {
595            v.push(format!("focusedSurfaceId '{f}' does not exist"));
596        }
597        // Unique ids.
598        let mut seen = std::collections::HashSet::new();
599        for s in &self.surfaces {
600            if !seen.insert(&s.id) {
601                v.push(format!("duplicate surface id '{}'", s.id));
602            }
603        }
604        v
605    }
606
607    pub fn is_valid(&self) -> bool {
608        self.check_invariants().is_empty()
609    }
610
611    /// Two-axis derivation: produce the platform-agnostic `DerivedLayout`.
612    pub fn derive_layout(&self, size_class: SizeClass) -> DerivedLayout {
613        let main_count = self.mains().len();
614        let aside_count = self.asides().len();
615        let switcher_form = if size_class != SizeClass::Compact && main_count > 1 {
616            match size_class {
617                SizeClass::Expanded => SwitcherForm::Sidebar,
618                SizeClass::Medium => SwitcherForm::Rail,
619                SizeClass::Compact => SwitcherForm::None,
620            }
621        } else {
622            SwitcherForm::None
623        };
624
625        let split_form = if aside_count > 0 {
626            match size_class {
627                SizeClass::Expanded => SplitForm::Split,
628                SizeClass::Medium => SplitForm::Collapsible,
629                // compact has no side-by-side: asides present full-screen.
630                SizeClass::Compact => SplitForm::FullScreen,
631            }
632        } else {
633            SplitForm::None
634        };
635
636        DerivedLayout {
637            size_class,
638            switcher_form,
639            split_form,
640            bottom_owner: BottomOwner::App,
641            layout_tree: self.canonical_layout(size_class),
642        }
643    }
644
645    /// Flatten the graph + derivation into the stable, skin-bindable
646    /// [`LayoutPresentationPlan`]: the primary mains, asides (with edge +
647    /// preferred size), floats, and the full tree. `width` is the container
648    /// workspace width and `policy` the admission tokens, so slot visibility
649    /// respects both the size-class ceiling and the physical fit (§3.3).
650    pub(crate) fn presentation_plan(
651        &self,
652        size_class: SizeClass,
653        width: f64,
654        policy: &crate::arbitrate::Policy,
655    ) -> LayoutPresentationPlan {
656        let derived = self.derive_layout(size_class);
657
658        let asides: Vec<PlanAside> = self
659            .asides()
660            .iter()
661            .filter(|s| s.state == SurfaceState::Mounted)
662            .map(|s| PlanAside {
663                id: s.id.clone(),
664                edge: s.placement.edge,
665                preferred_size: s.placement.preferred_size,
666            })
667            .collect();
668        let aside_slots = self.aside_slots_admitted(size_class, width, policy);
669
670        LayoutPresentationPlan {
671            size_class: derived.size_class,
672            bottom_owner: derived.bottom_owner,
673            switcher_form: derived.switcher_form,
674            split_form: derived.split_form,
675            mains: self.main_ids(),
676            // The active main the skin should attach to the primary content
677            // area. Mirrors `canonical_layout`'s `Tabs.activeId` fallback so the
678            // plan and the tree always agree on which main is primary.
679            active_main_id: self
680                .active_main_id
681                .clone()
682                .or_else(|| self.main_ids().first().cloned()),
683            main_switcher: crate::SurfaceSwitcherSnapshot::derive(
684                self,
685                &std::collections::HashMap::new(),
686            ),
687            asides,
688            aside_slots,
689            // Floats are popups above the layout and are valid at every size
690            // class (no compact gating), so they always appear in the plan. Each
691            // carries the float's render-relevant `FloatSpec`; a float surface
692            // missing its spec falls back to the default behavior.
693            floats: self
694                .floats()
695                .iter()
696                .filter(|s| s.state == SurfaceState::Mounted)
697                .map(|s| {
698                    let spec = s.float.clone().unwrap_or_default();
699                    crate::layout::PlanFloat {
700                        id: s.id.clone(),
701                        anchor: spec.anchor,
702                        dismiss: spec.dismiss,
703                        modal: spec.modal,
704                        close_button: spec.close_button,
705                    }
706                })
707                .collect(),
708            tree: derived.layout_tree,
709        }
710    }
711
712    /// Build the canonical authoritative `LayoutTree` from current state:
713    /// mains → tabs when needed, asides → split. Compact has no side-by-side
714    /// dock; asides share the main tree. Floats are never in the tree.
715    pub fn canonical_layout(&self, size_class: SizeClass) -> Option<LayoutTree> {
716        let main_ids = self.main_ids();
717        if main_ids.is_empty() {
718            return None;
719        }
720        let aside_ids: Vec<SurfaceId> = self
721            .asides()
722            .iter()
723            .filter(|surface| surface.state == SurfaceState::Mounted)
724            .map(|s| s.id.clone())
725            .collect();
726        let active = self
727            .active_main_id
728            .clone()
729            .unwrap_or_else(|| main_ids[0].clone());
730
731        let tabs_of = |ids: Vec<SurfaceId>| {
732            if ids.len() == 1 {
733                LayoutTree::Leaf {
734                    surface_id: ids[0].clone(),
735                }
736            } else {
737                LayoutTree::Tabs {
738                    active_id: active.clone(),
739                    children: ids,
740                }
741            }
742        };
743
744        // Compact: no split; asides share one full-screen tree with mains.
745        if size_class == SizeClass::Compact {
746            let mut ids = main_ids;
747            ids.extend(aside_ids);
748            return Some(tabs_of(ids));
749        }
750
751        let main_node = tabs_of(main_ids);
752        if aside_ids.is_empty() {
753            return Some(main_node);
754        }
755        let mut children = vec![main_node];
756        children.extend(
757            aside_ids
758                .into_iter()
759                .map(|id| LayoutTree::Leaf { surface_id: id }),
760        );
761        let n = children.len();
762        Some(LayoutTree::Split {
763            axis: Axis::Horizontal,
764            weights: vec![1.0 / n as f64; n],
765            children,
766        })
767    }
768}
769
770/// Pick the main that should become active after the one at `removed_pos` is
771/// gone: prefer the next main, else the previous, else none.
772fn pick_successor_main(surfaces: &[Surface], removed_pos: usize) -> Option<SurfaceId> {
773    surfaces
774        .iter()
775        .skip(removed_pos)
776        .find(|s| s.role == Role::Main)
777        .or_else(|| {
778            surfaces
779                .iter()
780                .take(removed_pos)
781                .rev()
782                .find(|s| s.role == Role::Main)
783        })
784        .map(|s| s.id.clone())
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    #[test]
792    fn replacing_modal_float_does_not_push_another_focus_snapshot() {
793        let mut graph = SurfaceGraph::new();
794        graph.insert(Surface::lxapp("home", Role::Main, "home"));
795        let mut modal = Surface::native("dialog", Role::Float, "dialog");
796        modal.float = Some(crate::model::FloatSpec {
797            modal: true,
798            ..Default::default()
799        });
800
801        graph.insert(modal.clone());
802        graph.set_focus("dialog");
803        graph.insert(modal);
804
805        assert_eq!(graph.modal_focus_stack.len(), 1);
806        graph.close("dialog");
807        assert_eq!(graph.focused_surface_id.as_deref(), Some("home"));
808        assert!(graph.modal_focus_stack.is_empty());
809    }
810
811    #[test]
812    fn inserting_another_role_cannot_replace_the_stable_root() {
813        let mut graph = SurfaceGraph::new();
814        graph.insert(Surface::native("terminal", Role::Main, "terminal"));
815        graph.insert(Surface::lxapp("tools", Role::Main, "tools"));
816
817        graph.insert(Surface::native("terminal", Role::Aside, "terminal"));
818
819        assert_eq!(graph.root_main_id(), Some("terminal"));
820        assert_eq!(graph.role_of("terminal"), Some(Role::Main));
821        assert!(graph.asides().is_empty());
822        assert!(graph.is_valid());
823    }
824}