1use crate::arbitrate::{OpenOutcome, Policy, arbitrate};
10use crate::graph::SurfaceGraph;
11use crate::layout::{DEFAULT_HYSTERESIS, DerivedLayout, LayoutPresentationPlan, SizeClass};
12use crate::model::{Surface, SurfaceId};
13
14#[derive(Debug, Clone)]
16pub struct SurfaceManager {
17 graph: SurfaceGraph,
18 policy: Policy,
19 width: f64,
20 sidebar_width: f64,
21 hysteresis: f64,
22 size_class: SizeClass,
23 overlay_fallback_surface_id: Option<SurfaceId>,
26}
27
28impl SurfaceManager {
29 pub fn new(width: f64) -> Self {
31 Self::with_policy(width, Policy::default())
32 }
33
34 pub fn with_policy(width: f64, policy: Policy) -> Self {
35 Self {
36 graph: SurfaceGraph::new(),
37 policy,
38 width,
39 sidebar_width: 0.0,
40 hysteresis: DEFAULT_HYSTERESIS,
41 size_class: SizeClass::from_width(width),
42 overlay_fallback_surface_id: None,
43 }
44 }
45
46 pub fn graph(&self) -> &SurfaceGraph {
47 &self.graph
48 }
49 pub fn size_class(&self) -> SizeClass {
50 self.size_class
51 }
52 pub fn width(&self) -> f64 {
53 self.width
54 }
55
56 fn workspace_width(&self) -> f64 {
57 (self.width - self.sidebar_width).max(0.0)
58 }
59
60 fn needs_overlay(&self, id: &str) -> bool {
61 self.graph.role_of(id) == Some(crate::model::Role::Aside)
62 && (self.size_class == SizeClass::Compact
63 || !self
64 .graph
65 .aside_slots_admitted(self.size_class, self.workspace_width(), &self.policy)
66 .into_iter()
67 .find(|slot| slot.children.iter().any(|child| child == id))
68 .is_some_and(|slot| slot.visible))
69 }
70
71 fn reconcile_overlay_fallback(&mut self) {
72 if self
73 .overlay_fallback_surface_id
74 .as_deref()
75 .is_some_and(|id| !self.needs_overlay(id))
76 {
77 self.overlay_fallback_surface_id = None;
78 }
79 }
80
81 pub fn set_width(&mut self, width: f64) -> bool {
84 self.width = width;
85 let next = SizeClass::resolve(Some(self.size_class), width, self.hysteresis);
86 let changed = next != self.size_class;
87 self.size_class = next;
88 self.reconcile_overlay_fallback();
89 changed
90 }
91
92 pub fn set_sidebar_width(&mut self, width: f64) -> bool {
95 let width = if width.is_finite() {
96 width.max(0.0).min(self.width)
97 } else {
98 0.0
99 };
100 let changed = (self.sidebar_width - width).abs() > f64::EPSILON;
101 self.sidebar_width = width;
102 self.reconcile_overlay_fallback();
103 changed
104 }
105
106 pub fn open(&mut self, request: Surface) -> OpenOutcome {
109 let (next, mut outcome) = arbitrate(&self.graph, request, &self.policy, self.size_class);
110 self.graph = next;
111 if outcome.resolved_role == crate::model::Role::Aside {
112 let admitted = self
113 .graph
114 .aside_slots_admitted(self.size_class, self.workspace_width(), &self.policy)
115 .into_iter()
116 .find(|slot| slot.children.contains(&outcome.resolved_surface_id))
117 .is_some_and(|slot| slot.visible);
118 outcome.overlay |= self.size_class == SizeClass::Compact || !admitted;
119 if outcome.overlay {
120 self.overlay_fallback_surface_id = Some(outcome.resolved_surface_id.clone());
121 } else {
122 self.overlay_fallback_surface_id = None;
123 }
124 }
125 outcome
126 }
127
128 pub fn close(&mut self, id: &str) -> Vec<SurfaceId> {
130 let removed = self.graph.remove(id);
131 if self
132 .overlay_fallback_surface_id
133 .as_ref()
134 .is_some_and(|fallback| removed.contains(fallback))
135 {
136 let focused = self
137 .graph
138 .focused_surface_id
139 .as_deref()
140 .filter(|focused| self.graph.role_of(focused) == Some(crate::model::Role::Aside))
141 .map(str::to_string);
142 self.overlay_fallback_surface_id =
143 focused.filter(|focused| self.needs_overlay(focused));
144 }
145 removed
146 }
147
148 pub fn set_active_main(&mut self, id: &str) -> bool {
149 let active = self.graph.set_active_main(id);
150 if active {
151 self.overlay_fallback_surface_id = None;
152 }
153 active
154 }
155 pub fn set_focus(&mut self, id: &str) -> bool {
156 let focused = self.graph.set_focus(id);
157 if focused {
158 self.overlay_fallback_surface_id = self.needs_overlay(id).then(|| id.to_string());
159 }
160 focused
161 }
162
163 pub fn show(&mut self, id: &str) -> bool {
164 let shown = self.graph.show(id);
165 if shown && self.graph.role_of(id) == Some(crate::model::Role::Aside) {
166 let admitted = self
167 .graph
168 .aside_slots_admitted(self.size_class, self.workspace_width(), &self.policy)
169 .into_iter()
170 .find(|slot| slot.children.iter().any(|child| child == id))
171 .is_some_and(|slot| slot.visible);
172 if self.size_class == SizeClass::Compact || !admitted {
173 self.overlay_fallback_surface_id = Some(id.to_string());
174 } else {
175 self.overlay_fallback_surface_id = None;
176 }
177 }
178 shown
179 }
180
181 pub fn hide(&mut self, id: &str) -> bool {
182 let hidden = self.graph.hide(id);
183 if hidden && self.overlay_fallback_surface_id.as_deref() == Some(id) {
184 let focused = self
185 .graph
186 .focused_surface_id
187 .as_deref()
188 .filter(|focused| self.graph.role_of(focused) == Some(crate::model::Role::Aside))
189 .map(str::to_string);
190 self.overlay_fallback_surface_id =
191 focused.filter(|focused| self.needs_overlay(focused));
192 }
193 hidden
194 }
195
196 pub fn derive(&self) -> DerivedLayout {
198 self.graph.derive_layout(self.size_class)
199 }
200
201 pub fn presentation_plan(&self) -> LayoutPresentationPlan {
206 let mut plan =
207 self.graph
208 .presentation_plan(self.size_class, self.workspace_width(), &self.policy);
209 if self.size_class == SizeClass::Compact {
210 for slot in plan.aside_slots.iter_mut().filter(|slot| slot.visible) {
211 slot.overlay = true;
212 }
213 }
214 if let Some(id) = self.overlay_fallback_surface_id.as_deref()
215 && let Some(slot) = plan
216 .aside_slots
217 .iter_mut()
218 .find(|slot| slot.children.iter().any(|child| child == id))
219 && (self.size_class == SizeClass::Compact || !slot.visible)
220 {
221 slot.visible = true;
222 slot.active_child = Some(id.to_string());
223 slot.overlay = true;
224 }
225 plan
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::Decision;
233 use crate::layout::{SplitForm, SwitcherForm};
234 use crate::model::{Edge, Role, Surface};
235
236 fn main_s(id: &str) -> Surface {
237 Surface::entry(id, Role::Main, id)
238 }
239 fn aside_s(id: &str, edge: Edge) -> Surface {
240 let mut s = Surface::entry(id, Role::Aside, id);
241 s.placement.edge = Some(edge);
242 s
243 }
244
245 #[test]
246 fn open_then_derive_on_expanded() {
247 let mut m = SurfaceManager::new(1200.0);
248 assert_eq!(m.size_class(), SizeClass::Expanded);
249 assert_eq!(m.open(main_s("home")), Decision::Accepted);
250 assert_eq!(
251 m.open(aside_s("assistant", Edge::Right)),
252 Decision::Accepted
253 );
254 let d = m.derive();
255 assert_eq!(d.split_form, SplitForm::Split);
256 assert!(m.graph().is_valid());
257 }
258
259 #[test]
260 fn aside_on_compact_overlays_without_host_switcher() {
261 let mut m = SurfaceManager::new(390.0); assert_eq!(m.size_class(), SizeClass::Compact);
263 m.open(main_s("home"));
264 assert_eq!(
267 m.open(aside_s("assistant", Edge::Right)),
268 Decision::FullScreenFallback
269 );
270 let d = m.derive();
271 assert_eq!(d.switcher_form, SwitcherForm::None);
272 assert_eq!(d.bottom_owner, crate::BottomOwner::App);
273 let slot = &m.presentation_plan().aside_slots[0];
274 assert!(slot.visible);
275 assert!(slot.overlay);
276 assert!(m.graph().is_valid());
277 }
278
279 #[test]
280 fn width_changes_recompute_physical_admission_within_a_size_class() {
281 let mut manager = SurfaceManager::new(1400.0);
282 manager.set_sidebar_width(184.0);
283 manager.open(main_s("home"));
284 manager.open(aside_s("lxapp", Edge::Right));
285 let mut browser = Surface::entry("browser", Role::Aside, "browser");
286 browser.content = crate::model::SurfaceContent::Web {
287 url: "https://example.com".to_string(),
288 reuse_by_url: true,
289 };
290 browser.placement.edge = Some(Edge::Right);
291 manager.open(browser);
292 let mut native = Surface::entry("terminal", Role::Aside, "terminal");
293 native.placement.edge = Some(Edge::Right);
294 manager.open(native);
295 assert_eq!(
296 manager
297 .presentation_plan()
298 .aside_slots
299 .iter()
300 .filter(|slot| slot.visible)
301 .count(),
302 3
303 );
304
305 assert!(!manager.set_width(900.0));
308 assert_eq!(manager.size_class(), SizeClass::Expanded);
309 assert_eq!(
310 manager
311 .presentation_plan()
312 .aside_slots
313 .iter()
314 .filter(|slot| slot.visible)
315 .count(),
316 1
317 );
318 }
319
320 #[test]
321 fn explicitly_opened_non_fitting_aside_overlays_until_it_can_dock() {
322 let mut manager = SurfaceManager::new(500.0);
323 manager.open(main_s("home"));
324 let outcome = manager.open(aside_s("assistant", Edge::Right));
325 assert!(outcome.overlay);
326 let slot = &manager.presentation_plan().aside_slots[0];
327 assert!(slot.visible);
328 assert!(slot.overlay);
329
330 manager.set_width(700.0);
331 let slot = &manager.presentation_plan().aside_slots[0];
332 assert!(slot.visible);
333 assert!(!slot.overlay);
334 }
335
336 #[test]
337 fn compact_focus_updates_the_overlay_tab() {
338 let mut manager = SurfaceManager::new(500.0);
339 manager.open(main_s("home"));
340 manager.open(aside_s("first", Edge::Right));
341 manager.open(aside_s("second", Edge::Right));
342
343 assert!(manager.set_focus("first"));
344 let slot = &manager.presentation_plan().aside_slots[0];
345 assert_eq!(slot.active_child.as_deref(), Some("first"));
346 }
347
348 #[test]
349 fn docked_fallback_does_not_reappear_after_later_resize() {
350 let policy = Policy {
351 main_min_width: 400.0,
352 aside_min_width: 240.0,
353 ..Policy::default()
354 };
355 let mut manager = SurfaceManager::with_policy(620.0, policy);
356 manager.open(main_s("home"));
357 let mut browser = Surface::entry("browser", Role::Aside, "browser");
358 browser.content = crate::model::SurfaceContent::Web {
359 url: "https://example.com".to_string(),
360 reuse_by_url: true,
361 };
362 browser.placement.edge = Some(Edge::Right);
363 assert!(manager.open(browser).overlay);
364
365 manager.set_width(1000.0);
366 manager.open(aside_s("chat", Edge::Right));
367 assert!(manager.set_focus("chat"));
368 manager.set_width(700.0);
369
370 let visible: Vec<_> = manager
371 .presentation_plan()
372 .aside_slots
373 .into_iter()
374 .filter(|slot| slot.visible)
375 .map(|slot| slot.kind)
376 .collect();
377 assert_eq!(visible, vec![crate::model::SlotKind::Lxapp]);
378 }
379
380 #[test]
381 fn live_sidebar_width_controls_physical_admission() {
382 let mut manager = SurfaceManager::new(900.0);
383 manager.open(main_s("home"));
384 manager.open(aside_s("chat", Edge::Right));
385 let mut browser = Surface::entry("browser", Role::Aside, "browser");
386 browser.content = crate::model::SurfaceContent::Web {
387 url: "https://example.com".to_string(),
388 reuse_by_url: true,
389 };
390 browser.placement.edge = Some(Edge::Right);
391 manager.open(browser);
392
393 assert_eq!(
394 manager
395 .presentation_plan()
396 .aside_slots
397 .iter()
398 .filter(|slot| slot.visible)
399 .count(),
400 2
401 );
402 manager.set_sidebar_width(300.0);
403 assert_eq!(
404 manager
405 .presentation_plan()
406 .aside_slots
407 .iter()
408 .filter(|slot| slot.visible)
409 .count(),
410 1
411 );
412 manager.set_sidebar_width(0.0);
413 assert_eq!(
414 manager
415 .presentation_plan()
416 .aside_slots
417 .iter()
418 .filter(|slot| slot.visible)
419 .count(),
420 2
421 );
422 }
423
424 #[test]
425 fn width_change_reports_sizeclass_flip_with_hysteresis() {
426 let mut m = SurfaceManager::new(1200.0);
427 assert!(!m.set_width(900.0));
429 assert_eq!(m.size_class(), SizeClass::Expanded);
430 assert!(m.set_width(390.0));
432 assert_eq!(m.size_class(), SizeClass::Compact);
433 assert!(!m.set_width(590.0));
435 assert_eq!(m.size_class(), SizeClass::Compact);
436 }
437
438 #[test]
439 fn resize_reflows_existing_aside_without_mutating_roles() {
440 let mut m = SurfaceManager::new(1200.0);
441 m.open(main_s("home"));
442 m.open(aside_s("assistant", Edge::Right));
443 assert_eq!(m.derive().split_form, SplitForm::Split);
445 assert_eq!(m.graph().role_of("assistant"), Some(Role::Aside));
446 m.set_width(390.0);
448 let d = m.derive();
449 assert_eq!(d.split_form, SplitForm::FullScreen);
450 assert_eq!(d.switcher_form, SwitcherForm::None);
451 let slot = &m.presentation_plan().aside_slots[0];
452 assert!(slot.visible);
453 assert!(slot.overlay);
454 assert_eq!(m.graph().role_of("assistant"), Some(Role::Aside));
456 m.set_width(1200.0);
457 assert_eq!(m.derive().split_form, SplitForm::Split);
458 }
459}