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, Surface, SurfaceId};
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}
26
27impl SurfaceGraph {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    pub fn surfaces(&self) -> &[Surface] {
33        &self.surfaces
34    }
35
36    pub fn get(&self, id: &str) -> Option<&Surface> {
37        self.surfaces.iter().find(|s| s.id == id)
38    }
39
40    pub fn role_of(&self, id: &str) -> Option<Role> {
41        self.get(id).map(|s| s.role)
42    }
43
44    pub fn mains(&self) -> Vec<&Surface> {
45        self.by_role(Role::Main)
46    }
47    pub fn asides(&self) -> Vec<&Surface> {
48        self.by_role(Role::Aside)
49    }
50    pub fn floats(&self) -> Vec<&Surface> {
51        self.by_role(Role::Float)
52    }
53
54    fn by_role(&self, role: Role) -> Vec<&Surface> {
55        self.surfaces.iter().filter(|s| s.role == role).collect()
56    }
57
58    fn main_ids(&self) -> Vec<SurfaceId> {
59        self.surfaces
60            .iter()
61            .filter(|s| s.role == Role::Main)
62            .map(|s| s.id.clone())
63            .collect()
64    }
65
66    /// Insert (or replace by id) a surface, then re-converge invariants.
67    pub fn insert(&mut self, surface: Surface) {
68        let modal = surface.is_modal_float();
69        if modal {
70            self.modal_focus_stack.push(self.focused_surface_id.clone());
71        }
72        if let Some(existing) = self.surfaces.iter_mut().find(|s| s.id == surface.id) {
73            *existing = surface;
74        } else {
75            self.surfaces.push(surface);
76        }
77        self.converge_after_insert();
78    }
79
80    fn converge_after_insert(&mut self) {
81        // First main becomes active + focused.
82        if self.active_main_id.is_none()
83            && let Some(first) = self.main_ids().first().cloned()
84        {
85            self.active_main_id = Some(first.clone());
86            if self.focused_surface_id.is_none() {
87                self.focused_surface_id = Some(first);
88            }
89        }
90        // A freshly inserted last surface still focuses if nothing else did.
91        if self.focused_surface_id.is_none()
92            && let Some(s) = self.surfaces.last()
93        {
94            self.focused_surface_id = Some(s.id.clone());
95        }
96    }
97
98    /// Remove a surface and re-converge per the transition rules.
99    /// Returns the ids actually removed (the target, plus cascaded asides).
100    pub fn remove(&mut self, id: &str) -> Vec<SurfaceId> {
101        let Some(pos) = self.surfaces.iter().position(|s| s.id == id) else {
102            return Vec::new();
103        };
104        let removed = self.surfaces.remove(pos);
105        let mut removed_ids = vec![removed.id.clone()];
106
107        // Succession for active main.
108        if self.active_main_id.as_deref() == Some(id) {
109            self.active_main_id = pick_successor_main(&self.surfaces, pos);
110        }
111
112        // Last main gone ⇒ all asides close (no primary, no companion).
113        if self.mains().is_empty() {
114            let aside_ids: Vec<SurfaceId> = self
115                .surfaces
116                .iter()
117                .filter(|s| s.role == Role::Aside)
118                .map(|s| s.id.clone())
119                .collect();
120            self.surfaces.retain(|s| s.role != Role::Aside);
121            removed_ids.extend(aside_ids);
122        }
123
124        // Modal float closing: restore the pre-popup focus snapshot.
125        if removed.is_modal_float()
126            && let Some(snapshot) = self.modal_focus_stack.pop()
127        {
128            self.focused_surface_id = snapshot;
129        }
130
131        // Focus fallback if the focused surface is gone.
132        if self
133            .focused_surface_id
134            .as_deref()
135            .is_none_or(|f| self.get(f).is_none())
136        {
137            self.focused_surface_id = self.focus_fallback();
138        }
139        removed_ids
140    }
141
142    /// Focus fallback order: active main → an aside owned by it → none.
143    fn focus_fallback(&self) -> Option<SurfaceId> {
144        if let Some(active) = &self.active_main_id
145            && self.get(active).is_some()
146        {
147            return Some(active.clone());
148        }
149        self.asides().first().map(|s| s.id.clone())
150    }
151
152    /// Switch which main is primary. No-op if `id` is not an existing main.
153    pub fn set_active_main(&mut self, id: &str) -> bool {
154        if self.role_of(id) == Some(Role::Main) {
155            self.active_main_id = Some(id.to_string());
156            true
157        } else {
158            false
159        }
160    }
161
162    /// Focus any surface (any role).
163    pub fn set_focus(&mut self, id: &str) -> bool {
164        if self.get(id).is_some() {
165            self.focused_surface_id = Some(id.to_string());
166            true
167        } else {
168            false
169        }
170    }
171
172    /// Check the invariants. Returns the list of violations (empty = ok).
173    pub fn check_invariants(&self) -> Vec<String> {
174        let mut v = Vec::new();
175        let mains = self.mains().len();
176        let asides = self.asides().len();
177        if asides > 0 && mains == 0 {
178            v.push("asides>0 but mains==0 (no primary, no companion)".into());
179        }
180        match &self.active_main_id {
181            Some(id) if self.role_of(id) != Some(Role::Main) => {
182                v.push(format!("activeMainId '{id}' is not a main"));
183            }
184            None if mains > 0 => v.push("mains exist but activeMainId is None".into()),
185            _ => {}
186        }
187        if let Some(f) = &self.focused_surface_id
188            && self.get(f).is_none()
189        {
190            v.push(format!("focusedSurfaceId '{f}' does not exist"));
191        }
192        // Unique ids.
193        let mut seen = std::collections::HashSet::new();
194        for s in &self.surfaces {
195            if !seen.insert(&s.id) {
196                v.push(format!("duplicate surface id '{}'", s.id));
197            }
198        }
199        v
200    }
201
202    pub fn is_valid(&self) -> bool {
203        self.check_invariants().is_empty()
204    }
205
206    /// Two-axis derivation: produce the platform-agnostic `DerivedLayout`.
207    pub fn derive_layout(&self, size_class: SizeClass) -> DerivedLayout {
208        let main_count = self.mains().len();
209        let aside_count = self.asides().len();
210        let switcher_form = if size_class != SizeClass::Compact && main_count > 1 {
211            match size_class {
212                SizeClass::Expanded => SwitcherForm::Sidebar,
213                SizeClass::Medium => SwitcherForm::Rail,
214                SizeClass::Compact => SwitcherForm::None,
215            }
216        } else {
217            SwitcherForm::None
218        };
219
220        let split_form = if aside_count > 0 {
221            match size_class {
222                SizeClass::Expanded => SplitForm::Split,
223                SizeClass::Medium => SplitForm::Collapsible,
224                // compact has no side-by-side: asides present full-screen.
225                SizeClass::Compact => SplitForm::FullScreen,
226            }
227        } else {
228            SplitForm::None
229        };
230
231        DerivedLayout {
232            size_class,
233            switcher_form,
234            split_form,
235            bottom_owner: BottomOwner::App,
236            layout_tree: self.canonical_layout(size_class),
237        }
238    }
239
240    /// Flatten the graph + derivation into the stable, skin-bindable
241    /// [`LayoutPresentationPlan`]: the primary mains, asides (with edge +
242    /// preferred size), floats, and the full tree.
243    pub fn presentation_plan(&self, size_class: SizeClass) -> LayoutPresentationPlan {
244        let derived = self.derive_layout(size_class);
245
246        let asides = self
247            .asides()
248            .iter()
249            .map(|s| PlanAside {
250                id: s.id.clone(),
251                edge: s.placement.edge,
252                preferred_size: s.placement.preferred_size,
253            })
254            .collect();
255
256        LayoutPresentationPlan {
257            size_class: derived.size_class,
258            bottom_owner: derived.bottom_owner,
259            switcher_form: derived.switcher_form,
260            split_form: derived.split_form,
261            mains: self.main_ids(),
262            // The active main the skin should attach to the primary content
263            // area. Mirrors `canonical_layout`'s `Tabs.activeId` fallback so the
264            // plan and the tree always agree on which main is primary.
265            active_main_id: self
266                .active_main_id
267                .clone()
268                .or_else(|| self.main_ids().first().cloned()),
269            asides,
270            // Floats are popups above the layout and are valid at every size
271            // class (no compact gating), so they always appear in the plan. Each
272            // carries the float's render-relevant `FloatSpec`; a float surface
273            // missing its spec falls back to the default behavior.
274            floats: self
275                .floats()
276                .iter()
277                .map(|s| {
278                    let spec = s.float.clone().unwrap_or_default();
279                    crate::layout::PlanFloat {
280                        id: s.id.clone(),
281                        anchor: spec.anchor,
282                        dismiss: spec.dismiss,
283                        modal: spec.modal,
284                    }
285                })
286                .collect(),
287            tree: derived.layout_tree,
288        }
289    }
290
291    /// Build the canonical authoritative `LayoutTree` from current state:
292    /// mains → tabs when needed, asides → split. Compact has no side-by-side
293    /// dock; asides share the main tree. Floats are never in the tree.
294    pub fn canonical_layout(&self, size_class: SizeClass) -> Option<LayoutTree> {
295        let main_ids = self.main_ids();
296        if main_ids.is_empty() {
297            return None;
298        }
299        let aside_ids: Vec<SurfaceId> = self.asides().iter().map(|s| s.id.clone()).collect();
300        let active = self
301            .active_main_id
302            .clone()
303            .unwrap_or_else(|| main_ids[0].clone());
304
305        let tabs_of = |ids: Vec<SurfaceId>| {
306            if ids.len() == 1 {
307                LayoutTree::Leaf {
308                    surface_id: ids[0].clone(),
309                }
310            } else {
311                LayoutTree::Tabs {
312                    active_id: active.clone(),
313                    children: ids,
314                }
315            }
316        };
317
318        // Compact: no split; asides share one full-screen tree with mains.
319        if size_class == SizeClass::Compact {
320            let mut ids = main_ids;
321            ids.extend(aside_ids);
322            return Some(tabs_of(ids));
323        }
324
325        let main_node = tabs_of(main_ids);
326        if aside_ids.is_empty() {
327            return Some(main_node);
328        }
329        let mut children = vec![main_node];
330        children.extend(
331            aside_ids
332                .into_iter()
333                .map(|id| LayoutTree::Leaf { surface_id: id }),
334        );
335        let n = children.len();
336        Some(LayoutTree::Split {
337            axis: Axis::Horizontal,
338            weights: vec![1.0 / n as f64; n],
339            children,
340        })
341    }
342}
343
344/// Pick the main that should become active after the one at `removed_pos` is
345/// gone: prefer the next main, else the previous, else none.
346fn pick_successor_main(surfaces: &[Surface], removed_pos: usize) -> Option<SurfaceId> {
347    surfaces
348        .iter()
349        .skip(removed_pos)
350        .find(|s| s.role == Role::Main)
351        .or_else(|| {
352            surfaces
353                .iter()
354                .take(removed_pos)
355                .rev()
356                .find(|s| s.role == Role::Main)
357        })
358        .map(|s| s.id.clone())
359}