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