Skip to main content

lingxia_shell/
surface_menu.rs

1//! Cross-platform menu contract for switchable main surfaces.
2//!
3//! Content providers contribute resolved actions. The shell appends common
4//! management/lifecycle actions in a deterministic order. Platform SDKs render
5//! the snapshot and return an intent; they never execute provider behavior.
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub enum SurfaceMenuBuiltinAction {
12    Rename,
13    ResetTitle,
14    Close,
15    CloseOthers,
16    CloseAfter,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub enum LxappSurfaceMenuAction {
22    Restart,
23    CleanCacheRestart,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(
28    tag = "owner",
29    rename_all = "camelCase",
30    rename_all_fields = "camelCase"
31)]
32pub enum SurfaceMenuAction {
33    Information {},
34    Switcher {
35        action: SurfaceMenuBuiltinAction,
36    },
37    Lxapp {
38        action: LxappSurfaceMenuAction,
39    },
40    External {
41        namespace: String,
42        generation: u64,
43        action_id: String,
44    },
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub enum SurfaceMenuItemRole {
50    Normal,
51    Destructive,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct SurfaceMenuItem {
57    pub action: SurfaceMenuAction,
58    /// Built-ins are localized by semantic action. External actions carry
59    /// their provider-resolved label here.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub label: Option<String>,
62    /// Resolved local icon reference or a shared built-in icon name.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub icon: Option<String>,
65    pub enabled: bool,
66    pub role: SurfaceMenuItemRole,
67}
68
69impl SurfaceMenuItem {
70    pub fn information(label: impl Into<String>) -> Self {
71        Self {
72            action: SurfaceMenuAction::Information {},
73            label: Some(label.into()),
74            icon: None,
75            enabled: false,
76            role: SurfaceMenuItemRole::Normal,
77        }
78    }
79
80    pub fn lxapp(action: LxappSurfaceMenuAction) -> Self {
81        Self {
82            action: SurfaceMenuAction::Lxapp { action },
83            label: None,
84            icon: None,
85            enabled: true,
86            role: SurfaceMenuItemRole::Normal,
87        }
88    }
89
90    pub fn external(
91        namespace: impl Into<String>,
92        generation: u64,
93        action_id: impl Into<String>,
94        label: impl Into<String>,
95        icon: Option<String>,
96    ) -> Self {
97        Self {
98            action: SurfaceMenuAction::External {
99                namespace: namespace.into(),
100                generation,
101                action_id: action_id.into(),
102            },
103            label: Some(label.into()),
104            icon,
105            enabled: true,
106            role: SurfaceMenuItemRole::Normal,
107        }
108    }
109
110    fn built_in(action: SurfaceMenuBuiltinAction, role: SurfaceMenuItemRole) -> Self {
111        Self {
112            action: SurfaceMenuAction::Switcher { action },
113            label: None,
114            icon: None,
115            enabled: true,
116            role,
117        }
118    }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub enum SurfaceMenuSectionKind {
124    Content,
125    Management,
126    Lifecycle,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct SurfaceMenuSection {
132    pub kind: SurfaceMenuSectionKind,
133    pub items: Vec<SurfaceMenuItem>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct SurfaceMenuSnapshot {
139    pub revision: u64,
140    pub surface_id: String,
141    pub sections: Vec<SurfaceMenuSection>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct SurfaceMenuContext {
146    pub revision: u64,
147    pub surface_id: String,
148    pub closable: bool,
149    pub renameable: bool,
150    pub title_overridden: bool,
151    pub has_other_closable: bool,
152    pub has_closable_before: bool,
153    pub has_closable_after: bool,
154}
155
156pub fn compose_surface_menu(
157    context: SurfaceMenuContext,
158    content_groups: Vec<Vec<SurfaceMenuItem>>,
159) -> SurfaceMenuSnapshot {
160    let mut sections = Vec::with_capacity(3);
161    for items in content_groups.into_iter().filter(|items| !items.is_empty()) {
162        sections.push(SurfaceMenuSection {
163            kind: SurfaceMenuSectionKind::Content,
164            items,
165        });
166    }
167
168    let mut management = Vec::new();
169    if context.renameable {
170        management.push(SurfaceMenuItem::built_in(
171            SurfaceMenuBuiltinAction::Rename,
172            SurfaceMenuItemRole::Normal,
173        ));
174        if context.title_overridden {
175            management.push(SurfaceMenuItem::built_in(
176                SurfaceMenuBuiltinAction::ResetTitle,
177                SurfaceMenuItemRole::Normal,
178            ));
179        }
180    }
181    if !management.is_empty() {
182        sections.push(SurfaceMenuSection {
183            kind: SurfaceMenuSectionKind::Management,
184            items: management,
185        });
186    }
187
188    let mut lifecycle = Vec::new();
189    if context.closable {
190        lifecycle.push(SurfaceMenuItem::built_in(
191            SurfaceMenuBuiltinAction::Close,
192            SurfaceMenuItemRole::Destructive,
193        ));
194    }
195    if context.has_other_closable {
196        lifecycle.push(SurfaceMenuItem::built_in(
197            SurfaceMenuBuiltinAction::CloseOthers,
198            SurfaceMenuItemRole::Destructive,
199        ));
200    }
201    if context.has_closable_before && context.has_closable_after {
202        lifecycle.push(SurfaceMenuItem::built_in(
203            SurfaceMenuBuiltinAction::CloseAfter,
204            SurfaceMenuItemRole::Destructive,
205        ));
206    }
207    if !lifecycle.is_empty() {
208        sections.push(SurfaceMenuSection {
209            kind: SurfaceMenuSectionKind::Lifecycle,
210            items: lifecycle,
211        });
212    }
213
214    SurfaceMenuSnapshot {
215        revision: context.revision,
216        surface_id: context.surface_id,
217        sections,
218    }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct SurfaceMenuIntent {
224    pub revision: u64,
225    pub surface_id: String,
226    pub action: SurfaceMenuAction,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub value: Option<String>,
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn actions(snapshot: &SurfaceMenuSnapshot) -> Vec<SurfaceMenuAction> {
236        snapshot
237            .sections
238            .iter()
239            .flat_map(|section| section.items.iter())
240            .map(|item| item.action.clone())
241            .collect()
242    }
243
244    #[test]
245    fn root_deduplicates_equivalent_batch_actions() {
246        let snapshot = compose_surface_menu(
247            SurfaceMenuContext {
248                revision: 7,
249                surface_id: "home".into(),
250                closable: false,
251                renameable: false,
252                title_overridden: false,
253                has_other_closable: true,
254                has_closable_before: false,
255                has_closable_after: true,
256            },
257            Vec::new(),
258        );
259
260        let actions = actions(&snapshot);
261        assert!(!actions.contains(&SurfaceMenuAction::Switcher {
262            action: SurfaceMenuBuiltinAction::Close,
263        }));
264        assert!(actions.contains(&SurfaceMenuAction::Switcher {
265            action: SurfaceMenuBuiltinAction::CloseOthers,
266        }));
267        assert!(!actions.contains(&SurfaceMenuAction::Switcher {
268            action: SurfaceMenuBuiltinAction::CloseAfter,
269        }));
270    }
271
272    #[test]
273    fn provider_actions_precede_shell_management_and_lifecycle() {
274        let more_action = SurfaceMenuItem::external(
275            "lxapp:home",
276            12,
277            "feedback",
278            "Feedback",
279            Some("lx://bundle/feedback.svg".into()),
280        );
281        let snapshot = compose_surface_menu(
282            SurfaceMenuContext {
283                revision: 9,
284                surface_id: "terminal".into(),
285                closable: true,
286                renameable: true,
287                title_overridden: true,
288                has_other_closable: true,
289                has_closable_before: true,
290                has_closable_after: false,
291            },
292            vec![vec![more_action]],
293        );
294
295        assert_eq!(
296            snapshot
297                .sections
298                .iter()
299                .map(|section| section.kind)
300                .collect::<Vec<_>>(),
301            vec![
302                SurfaceMenuSectionKind::Content,
303                SurfaceMenuSectionKind::Management,
304                SurfaceMenuSectionKind::Lifecycle,
305            ]
306        );
307        assert_eq!(snapshot.revision, 9);
308        assert_eq!(snapshot.surface_id, "terminal");
309    }
310
311    #[test]
312    fn middle_surface_keeps_distinct_batch_actions() {
313        let snapshot = compose_surface_menu(
314            SurfaceMenuContext {
315                revision: 10,
316                surface_id: "middle".into(),
317                closable: true,
318                renameable: false,
319                title_overridden: false,
320                has_other_closable: true,
321                has_closable_before: true,
322                has_closable_after: true,
323            },
324            Vec::new(),
325        );
326
327        let actions = actions(&snapshot);
328        assert!(actions.contains(&SurfaceMenuAction::Switcher {
329            action: SurfaceMenuBuiltinAction::CloseOthers,
330        }));
331        assert!(actions.contains(&SurfaceMenuAction::Switcher {
332            action: SurfaceMenuBuiltinAction::CloseAfter,
333        }));
334    }
335
336    #[test]
337    fn external_action_keeps_generation_for_stale_intent_rejection() {
338        let item = SurfaceMenuItem::external("browser", 41, "copy-link", "Copy Link", None);
339        let json = serde_json::to_value(&item).unwrap();
340
341        assert_eq!(json["action"]["owner"], "external");
342        assert_eq!(json["action"]["namespace"], "browser");
343        assert_eq!(json["action"]["generation"], 41);
344        assert_eq!(json["action"]["actionId"], "copy-link");
345    }
346
347    #[test]
348    fn lxapp_groups_keep_metadata_maintenance_and_more_actions_separate() {
349        let snapshot = compose_surface_menu(
350            SurfaceMenuContext {
351                revision: 3,
352                surface_id: "home".into(),
353                closable: false,
354                renameable: false,
355                title_overridden: false,
356                has_other_closable: false,
357                has_closable_before: false,
358                has_closable_after: false,
359            },
360            vec![
361                vec![SurfaceMenuItem::information("Showcase ยท 1.0.0 [DEV]")],
362                vec![
363                    SurfaceMenuItem::lxapp(LxappSurfaceMenuAction::Restart),
364                    SurfaceMenuItem::lxapp(LxappSurfaceMenuAction::CleanCacheRestart),
365                ],
366                vec![SurfaceMenuItem::external(
367                    "showcase", 2, "0", "Feedback", None,
368                )],
369            ],
370        );
371
372        assert_eq!(snapshot.sections.len(), 3);
373        assert!(snapshot.sections[0].items.iter().all(|item| !item.enabled));
374        assert!(matches!(
375            snapshot.sections[1].items[0].action,
376            SurfaceMenuAction::Lxapp {
377                action: LxappSurfaceMenuAction::Restart
378            }
379        ));
380        assert!(matches!(
381            snapshot.sections[2].items[0].action,
382            SurfaceMenuAction::External { .. }
383        ));
384    }
385}