1use 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#[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 overlay_fallback_surface_id: Option<SurfaceId>,
31}
32
33impl SurfaceManager {
34 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 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 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 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 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 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 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 pub fn derive(&self) -> DerivedLayout {
418 self.graph.derive_layout(self.size_class)
419 }
420
421 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); assert_eq!(m.size_class(), SizeClass::Compact);
485 m.open(main_s("home"));
486 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 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 assert!(!m.set_width(900.0));
725 assert_eq!(m.size_class(), SizeClass::Expanded);
726 assert!(m.set_width(390.0));
728 assert_eq!(m.size_class(), SizeClass::Compact);
729 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 assert_eq!(m.derive().split_form, SplitForm::Split);
741 assert_eq!(m.graph().role_of("assistant"), Some(Role::Aside));
742 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 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}