1use 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#[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 #[serde(default, skip)]
24 modal_focus_stack: Vec<Option<SurfaceId>>,
25 #[serde(default)]
28 aside_slot_mru: Vec<SlotKind>,
29 #[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 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 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 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 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 if self.active_main_id.as_deref() == Some(id) {
166 self.active_main_id = pick_successor_main(&self.surfaces, pos);
167 }
168
169 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 if removed.is_modal_float()
183 && let Some(snapshot) = self.modal_focus_stack.pop()
184 {
185 self.focused_surface_id = snapshot;
186 }
187
188 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 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 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 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 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 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 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 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 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 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 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 if surface.placement.edge.is_some() {
390 slot.edge = surface.placement.edge;
391 }
392 }
393 for slot in &mut slots {
394 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 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 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 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 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 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 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 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: 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 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 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
636fn 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}