Skip to main content

lingxia_surface/
switcher.rs

1//! Platform-neutral projection of ordered `main` surfaces.
2//!
3//! Desktop skins may render this as sidebar tabs while compact skins may not
4//! render a switcher at all. The identity and lifecycle semantics stay the
5//! same in both cases.
6
7use 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}