1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub label: Option<String>,
62 #[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}