1use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{SurfaceContent, SurfaceGraph, SurfaceIcon, SurfaceId, SurfacePresentation};
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(
15 tag = "kind",
16 rename_all = "lowercase",
17 rename_all_fields = "camelCase"
18)]
19pub enum SwitcherContentKind {
20 Lxapp { app_id: String },
21 Page { app_id: String },
22 Browser,
23 Native { capability: String },
24}
25
26impl From<&SurfaceContent> for SwitcherContentKind {
27 fn from(content: &SurfaceContent) -> Self {
28 match content {
29 SurfaceContent::Lxapp { app_id, .. } => Self::Lxapp {
30 app_id: app_id.clone(),
31 },
32 SurfaceContent::Page { app_id, .. } => Self::Page {
33 app_id: app_id.clone(),
34 },
35 SurfaceContent::Browser { .. } => Self::Browser,
36 SurfaceContent::Native { capability, .. } => Self::Native {
37 capability: capability.clone(),
38 },
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct SurfaceSwitcherItem {
46 pub surface_id: SurfaceId,
47 pub content: SwitcherContentKind,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub title: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub icon: Option<SurfaceIcon>,
52 pub active: bool,
53 pub root: bool,
54 pub closable: bool,
55 pub renameable: bool,
56 pub title_overridden: bool,
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct SurfaceSwitcherSnapshot {
62 pub revision: u64,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub root_surface_id: Option<SurfaceId>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub active_surface_id: Option<SurfaceId>,
67 pub items: Vec<SurfaceSwitcherItem>,
68}
69
70impl SurfaceSwitcherSnapshot {
71 pub(crate) fn derive(
72 graph: &SurfaceGraph,
73 presentations: &HashMap<SurfaceId, SurfacePresentation>,
74 ) -> Self {
75 let root_surface_id = graph.root_main_id().map(str::to_string);
76 let active_surface_id = graph.active_main_id.clone();
77 let items = graph
78 .mains()
79 .into_iter()
80 .map(|surface| {
81 let presentation = presentations
82 .get(&surface.id)
83 .cloned()
84 .unwrap_or_else(|| SurfacePresentation::for_content(&surface.content));
85 let root = root_surface_id.as_deref() == Some(surface.id.as_str());
86 SurfaceSwitcherItem {
87 surface_id: surface.id.clone(),
88 content: (&surface.content).into(),
89 title: presentation.title().map(str::to_string),
90 icon: presentation.icon,
91 active: active_surface_id.as_deref() == Some(surface.id.as_str()),
92 root,
93 closable: !root && presentation.capabilities.close,
94 renameable: presentation.capabilities.rename,
95 title_overridden: presentation.custom_title.is_some(),
96 }
97 })
98 .collect();
99 Self {
100 revision: 0,
101 root_surface_id,
102 active_surface_id,
103 items,
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum CloseOutcome {
110 Closed { removed: Vec<SurfaceId> },
111 RejectedRoot { surface_id: SurfaceId },
112 NotFound,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
116pub enum ReplaceMainsError {
117 #[error("surface '{surface_id}' is not a main")]
118 InvalidRole { surface_id: SurfaceId },
119 #[error("duplicate main surface id '{surface_id}'")]
120 DuplicateId { surface_id: SurfaceId },
121}
122
123impl CloseOutcome {
124 pub fn removed(&self) -> &[SurfaceId] {
125 match self {
126 Self::Closed { removed } => removed,
127 Self::RejectedRoot { .. } | Self::NotFound => &[],
128 }
129 }
130
131 pub fn into_removed(self) -> Vec<SurfaceId> {
132 match self {
133 Self::Closed { removed } => removed,
134 Self::RejectedRoot { .. } | Self::NotFound => Vec::new(),
135 }
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::{Role, Surface, SurfaceCapabilities, SurfaceManager, SurfacePresentation};
143
144 fn enable_rename(manager: &mut SurfaceManager, id: &str) {
145 let content = manager.graph().get(id).unwrap().content.clone();
146 let mut presentation = SurfacePresentation::for_content(&content);
147 presentation.capabilities = SurfaceCapabilities {
148 close: true,
149 rename: true,
150 };
151 assert!(manager.set_presentation(id, presentation));
152 }
153
154 #[test]
155 fn first_main_is_the_stable_non_closable_root() {
156 let mut manager = SurfaceManager::new(1200.0);
157 manager.open(Surface::browser(
158 "browser-root",
159 Role::Main,
160 "https://example.com",
161 ));
162 manager.open(Surface::native("terminal", Role::Main, "terminal"));
163
164 let snapshot = manager.switcher_snapshot();
165 assert_eq!(snapshot.root_surface_id.as_deref(), Some("browser-root"));
166 assert!(snapshot.items[0].root);
167 assert!(!snapshot.items[0].closable);
168 assert!(snapshot.items[1].closable);
169 assert_eq!(
170 manager.close("browser-root"),
171 CloseOutcome::RejectedRoot {
172 surface_id: "browser-root".into()
173 }
174 );
175 }
176
177 #[test]
178 fn lxapp_content_serializes_provider_identity_for_platform_skins() {
179 let content = SwitcherContentKind::Lxapp {
180 app_id: "lingxia-chat".into(),
181 };
182
183 assert_eq!(
184 serde_json::to_value(content).unwrap(),
185 serde_json::json!({ "kind": "lxapp", "appId": "lingxia-chat" })
186 );
187 }
188
189 #[test]
190 fn close_other_mains_crosses_content_kinds_but_preserves_root() {
191 let mut manager = SurfaceManager::new(1200.0);
192 manager.open(Surface::lxapp("home", Role::Main, "home"));
193 manager.open(Surface::browser(
194 "docs",
195 Role::Main,
196 "https://docs.example.com",
197 ));
198 manager.open(Surface::native("terminal", Role::Main, "terminal"));
199 manager.open(Surface::lxapp("tools", Role::Main, "tools"));
200
201 assert_eq!(manager.close_other_mains("terminal"), vec!["docs"]);
202 let snapshot = manager.switcher_snapshot();
203 assert_eq!(
204 snapshot
205 .items
206 .iter()
207 .map(|item| item.surface_id.as_str())
208 .collect::<Vec<_>>(),
209 vec!["home", "terminal", "tools"]
210 );
211 }
212
213 #[test]
214 fn custom_title_overrides_provider_updates_until_reset() {
215 let mut manager = SurfaceManager::new(1200.0);
216 manager.open(Surface::native("terminal", Role::Main, "terminal"));
217 enable_rename(&mut manager, "terminal");
218
219 assert!(manager.update_automatic_title("terminal", Some("~/github")));
220 assert!(manager.rename("terminal", Some("workspace")));
221 assert!(manager.update_automatic_title("terminal", Some("~/github/LingXia")));
222 assert_eq!(
223 manager.switcher_snapshot().items[0].title.as_deref(),
224 Some("workspace")
225 );
226
227 assert!(manager.rename("terminal", None));
228 assert_eq!(
229 manager.switcher_snapshot().items[0].title.as_deref(),
230 Some("~/github/LingXia")
231 );
232 }
233
234 #[test]
235 fn lxapp_titles_are_not_user_renameable() {
236 let mut manager = SurfaceManager::new(1200.0);
237 manager.open(Surface::lxapp("home", Role::Main, "home"));
238 manager.open(Surface::lxapp("tools", Role::Main, "tools"));
239
240 assert!(!manager.rename("home", Some("renamed")));
241 assert_eq!(
242 manager.switcher_snapshot().items[0].title.as_deref(),
243 Some("home")
244 );
245 assert!(!manager.switcher_snapshot().items[1].closable);
246 }
247
248 #[test]
249 fn close_after_uses_global_switcher_order() {
250 let mut manager = SurfaceManager::new(1200.0);
251 manager.open(Surface::lxapp("home", Role::Main, "home"));
252 manager.open(Surface::browser(
253 "docs",
254 Role::Main,
255 "https://docs.example.com",
256 ));
257 manager.open(Surface::native("terminal", Role::Main, "terminal"));
258
259 manager.open(Surface::lxapp("tools", Role::Main, "tools"));
260 assert_eq!(manager.close_mains_after("home"), vec!["docs", "terminal"]);
261 assert_eq!(manager.switcher_snapshot().items.len(), 2);
262 }
263
264 #[test]
265 fn declaration_replace_overrides_an_early_seeded_root_atomically() {
266 let mut manager = SurfaceManager::new(1200.0);
267 manager.open(Surface::lxapp("home", Role::Main, "home"));
268 let terminal = Surface::native("terminal", Role::Main, "terminal");
269 let browser = Surface::browser("browser", Role::Main, "https://example.com");
270
271 let snapshot = manager
272 .replace_mains(vec![
273 (
274 terminal.clone(),
275 SurfacePresentation::for_content(&terminal.content),
276 ),
277 (
278 browser.clone(),
279 SurfacePresentation::for_content(&browser.content),
280 ),
281 ])
282 .unwrap();
283
284 assert_eq!(snapshot.root_surface_id.as_deref(), Some("terminal"));
285 assert_eq!(snapshot.active_surface_id.as_deref(), Some("terminal"));
286 assert_eq!(
287 snapshot
288 .items
289 .iter()
290 .map(|item| item.surface_id.as_str())
291 .collect::<Vec<_>>(),
292 vec!["terminal", "browser"]
293 );
294 assert!(manager.graph().get("home").is_none());
295 }
296
297 #[test]
298 fn invalid_declaration_replace_is_atomic() {
299 let mut manager = SurfaceManager::new(1200.0);
300 manager.open(Surface::lxapp("home", Role::Main, "home"));
301 let before = manager.switcher_snapshot();
302 let duplicate = Surface::native("terminal", Role::Main, "terminal");
303
304 assert_eq!(
305 manager.replace_mains(vec![
306 (
307 duplicate.clone(),
308 SurfacePresentation::for_content(&duplicate.content),
309 ),
310 (
311 duplicate.clone(),
312 SurfacePresentation::for_content(&duplicate.content),
313 ),
314 ]),
315 Err(ReplaceMainsError::DuplicateId {
316 surface_id: "terminal".into()
317 })
318 );
319 assert_eq!(manager.switcher_snapshot(), before);
320 }
321
322 #[test]
323 fn closed_registered_main_can_be_opened_again() {
324 let mut manager = SurfaceManager::new(1200.0);
325 manager.open(Surface::lxapp("home", Role::Main, "home"));
326 let terminal = Surface::native("terminal", Role::Main, "terminal");
327 let presentation = SurfacePresentation::for_content(&terminal.content);
328 manager
329 .open_main(terminal.clone(), presentation.clone())
330 .unwrap();
331 assert!(matches!(
332 manager.close("terminal"),
333 CloseOutcome::Closed { .. }
334 ));
335
336 let snapshot = manager.open_main(terminal, presentation).unwrap();
337 assert_eq!(snapshot.active_surface_id.as_deref(), Some("terminal"));
338 assert_eq!(
339 snapshot
340 .items
341 .iter()
342 .map(|item| item.surface_id.as_str())
343 .collect::<Vec<_>>(),
344 vec!["home", "terminal"]
345 );
346 }
347
348 #[test]
349 fn opening_browser_main_never_reuses_an_aside_with_the_same_url() {
350 let mut manager = SurfaceManager::new(1200.0);
351 manager.open(Surface::lxapp("home", Role::Main, "home"));
352 manager.open(Surface::browser(
353 "browser-aside",
354 Role::Aside,
355 "https://example.com",
356 ));
357 let browser_main = Surface::browser("browser-main", Role::Main, "https://example.com");
358
359 let snapshot = manager
360 .open_main(
361 browser_main.clone(),
362 SurfacePresentation::for_content(&browser_main.content),
363 )
364 .unwrap();
365
366 assert_eq!(snapshot.active_surface_id.as_deref(), Some("browser-main"));
367 assert_eq!(manager.graph().role_of("browser-main"), Some(Role::Main));
368 assert_eq!(manager.graph().role_of("browser-aside"), Some(Role::Aside));
369 }
370
371 #[test]
372 fn native_surface_moves_between_aside_and_main_without_replacing_root() {
373 let mut manager = SurfaceManager::new(1200.0);
374 manager.open(Surface::lxapp("home", Role::Main, "home"));
375 manager.open(Surface::native("terminal", Role::Aside, "terminal"));
376
377 let terminal_main = Surface::native("terminal", Role::Main, "terminal");
378 let snapshot = manager
379 .open_main(
380 terminal_main.clone(),
381 SurfacePresentation::for_content(&terminal_main.content),
382 )
383 .unwrap();
384 assert_eq!(snapshot.root_surface_id.as_deref(), Some("home"));
385 assert_eq!(snapshot.active_surface_id.as_deref(), Some("terminal"));
386 assert_eq!(manager.graph().asides().len(), 0);
387
388 manager.open(Surface::native("terminal", Role::Aside, "terminal"));
389 let snapshot = manager.switcher_snapshot();
390 assert_eq!(snapshot.root_surface_id.as_deref(), Some("home"));
391 assert_eq!(snapshot.active_surface_id.as_deref(), Some("home"));
392 assert_eq!(snapshot.items.len(), 1);
393 assert_eq!(manager.graph().asides()[0].id, "terminal");
394 }
395
396 #[test]
397 fn native_root_cannot_move_to_aside() {
398 for with_other_main in [false, true] {
399 let mut manager = SurfaceManager::new(1200.0);
400 manager.open(Surface::native("terminal", Role::Main, "terminal"));
401 if with_other_main {
402 manager.open(Surface::lxapp("tools", Role::Main, "tools"));
403 }
404
405 let outcome = manager.open(Surface::native("terminal", Role::Aside, "terminal"));
406
407 assert_eq!(outcome.decision, crate::Decision::DowngradedRole);
408 assert_eq!(outcome.resolved_role, Role::Main);
409 assert_eq!(manager.graph().root_main_id(), Some("terminal"));
410 assert_eq!(manager.graph().role_of("terminal"), Some(Role::Main));
411 assert!(manager.graph().asides().is_empty());
412 assert!(manager.graph().is_valid());
413 }
414 }
415}