scarab_plugin_api/menu.rs
1//! Plugin menu system for the Scarab Dock
2//!
3//! This module provides types for plugins to define contextual menus that
4//! appear in the Scarab Dock interface. Menu items can trigger commands,
5//! remote actions, or spawn submenus for hierarchical navigation.
6//!
7//! ## Example
8//!
9//! ```rust
10//! use scarab_plugin_api::menu::{MenuItem, MenuAction};
11//!
12//! // Create a simple command menu item
13//! let item = MenuItem::new("Run Tests", MenuAction::Command("cargo test".to_string()))
14//! .with_icon("๐งช")
15//! .with_shortcut("Ctrl+T");
16//!
17//! // Create a submenu with multiple actions
18//! let build_menu = MenuItem::new(
19//! "Build",
20//! MenuAction::SubMenu(vec![
21//! MenuItem::new("Debug", MenuAction::Command("cargo build".to_string())),
22//! MenuItem::new("Release", MenuAction::Command("cargo build --release".to_string())),
23//! ])
24//! );
25//! ```
26
27use serde::{Deserialize, Serialize};
28
29/// A menu item that can be displayed in the Scarab Dock
30///
31/// Menu items represent actions that users can trigger from the dock interface.
32/// They support icons, keyboard shortcuts, and hierarchical organization through submenus.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct MenuItem {
35 /// Display label for the menu item
36 ///
37 /// This is the text shown to the user in the dock interface.
38 /// Keep it concise and descriptive (e.g., "Run Tests", "Open Settings").
39 pub label: String,
40
41 /// Optional icon or emoji for visual identification
42 ///
43 /// Can be a single emoji (e.g., "๐", "โ๏ธ") or an icon identifier.
44 /// Icons help users quickly identify menu items at a glance.
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub icon: Option<String>,
47
48 /// The action to perform when this menu item is selected
49 ///
50 /// Defines what happens when the user clicks or activates this menu item.
51 pub action: MenuAction,
52
53 /// Optional keyboard shortcut hint
54 ///
55 /// Display-only hint showing the keyboard shortcut (e.g., "Ctrl+T", "Alt+B").
56 /// Note: The actual shortcut handling must be implemented separately.
57 /// This field is purely informational for the UI.
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub shortcut: Option<String>,
60}
61
62impl MenuItem {
63 /// Create a new menu item with a label and action
64 ///
65 /// # Arguments
66 ///
67 /// * `label` - Display text for the menu item
68 /// * `action` - Action to perform when selected
69 ///
70 /// # Example
71 ///
72 /// ```rust
73 /// use scarab_plugin_api::menu::{MenuItem, MenuAction};
74 ///
75 /// let item = MenuItem::new(
76 /// "Deploy",
77 /// MenuAction::Command("./deploy.sh".to_string())
78 /// );
79 /// ```
80 pub fn new(label: impl Into<String>, action: MenuAction) -> Self {
81 Self {
82 label: label.into(),
83 icon: None,
84 action,
85 shortcut: None,
86 }
87 }
88
89 /// Add an icon or emoji to this menu item
90 ///
91 /// # Arguments
92 ///
93 /// * `icon` - Icon identifier or emoji character
94 ///
95 /// # Example
96 ///
97 /// ```rust
98 /// use scarab_plugin_api::menu::{MenuItem, MenuAction};
99 ///
100 /// let item = MenuItem::new("Build", MenuAction::Command("make".to_string()))
101 /// .with_icon("๐จ");
102 /// ```
103 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
104 self.icon = Some(icon.into());
105 self
106 }
107
108 /// Add a keyboard shortcut hint to this menu item
109 ///
110 /// Note: This is display-only. The shortcut must be registered separately
111 /// through the appropriate input handling mechanism.
112 ///
113 /// # Arguments
114 ///
115 /// * `shortcut` - Shortcut text (e.g., "Ctrl+B", "Alt+Shift+T")
116 ///
117 /// # Example
118 ///
119 /// ```rust
120 /// use scarab_plugin_api::menu::{MenuItem, MenuAction};
121 ///
122 /// let item = MenuItem::new("Save", MenuAction::Command("save".to_string()))
123 /// .with_shortcut("Ctrl+S");
124 /// ```
125 pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
126 self.shortcut = Some(shortcut.into());
127 self
128 }
129}
130
131/// Action to perform when a menu item is selected
132///
133/// Menu actions define what happens when a user interacts with a menu item.
134/// They can execute terminal commands, trigger plugin callbacks, or open submenus.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(tag = "type", content = "data")]
137pub enum MenuAction {
138 /// Execute a terminal command
139 ///
140 /// When selected, this action sends the command to the active PTY.
141 /// The command is executed as if the user typed it into the terminal.
142 ///
143 /// # Example
144 ///
145 /// ```rust
146 /// use scarab_plugin_api::menu::MenuAction;
147 ///
148 /// let action = MenuAction::Command("ls -la".to_string());
149 /// ```
150 Command(String),
151
152 /// Trigger a plugin remote action
153 ///
154 /// When selected, this action calls the plugin's `on_remote_command` hook
155 /// with the specified identifier. This allows plugins to implement custom
156 /// actions beyond simple command execution.
157 ///
158 /// # Example
159 ///
160 /// ```rust
161 /// use scarab_plugin_api::menu::MenuAction;
162 ///
163 /// // Will call on_remote_command("refresh_cache", ctx)
164 /// let action = MenuAction::Remote("refresh_cache".to_string());
165 /// ```
166 Remote(String),
167
168 /// Open a submenu with additional items
169 ///
170 /// When selected, this action displays a nested menu with the provided items.
171 /// Submenus can be nested arbitrarily deep, but keep hierarchies shallow
172 /// for better user experience (2-3 levels maximum recommended).
173 ///
174 /// # Example
175 ///
176 /// ```rust
177 /// use scarab_plugin_api::menu::{MenuItem, MenuAction};
178 ///
179 /// let submenu = MenuAction::SubMenu(vec![
180 /// MenuItem::new("Option 1", MenuAction::Command("cmd1".to_string())),
181 /// MenuItem::new("Option 2", MenuAction::Command("cmd2".to_string())),
182 /// ]);
183 /// ```
184 SubMenu(Vec<MenuItem>),
185}
186
187impl MenuAction {
188 /// Check if this action is a submenu
189 ///
190 /// # Returns
191 ///
192 /// `true` if this action opens a submenu, `false` otherwise
193 pub fn is_submenu(&self) -> bool {
194 matches!(self, MenuAction::SubMenu(_))
195 }
196
197 /// Check if this action executes a command
198 ///
199 /// # Returns
200 ///
201 /// `true` if this action executes a terminal command, `false` otherwise
202 pub fn is_command(&self) -> bool {
203 matches!(self, MenuAction::Command(_))
204 }
205
206 /// Check if this action triggers a remote callback
207 ///
208 /// # Returns
209 ///
210 /// `true` if this action calls a plugin remote handler, `false` otherwise
211 pub fn is_remote(&self) -> bool {
212 matches!(self, MenuAction::Remote(_))
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn test_menu_item_builder() {
222 let item = MenuItem::new("Test", MenuAction::Command("test".to_string()))
223 .with_icon("๐งช")
224 .with_shortcut("Ctrl+T");
225
226 assert_eq!(item.label, "Test");
227 assert_eq!(item.icon, Some("๐งช".to_string()));
228 assert_eq!(item.shortcut, Some("Ctrl+T".to_string()));
229 }
230
231 #[test]
232 fn test_menu_action_checks() {
233 let cmd = MenuAction::Command("ls".to_string());
234 assert!(cmd.is_command());
235 assert!(!cmd.is_remote());
236 assert!(!cmd.is_submenu());
237
238 let remote = MenuAction::Remote("action_id".to_string());
239 assert!(!remote.is_command());
240 assert!(remote.is_remote());
241 assert!(!remote.is_submenu());
242
243 let submenu = MenuAction::SubMenu(vec![]);
244 assert!(!submenu.is_command());
245 assert!(!submenu.is_remote());
246 assert!(submenu.is_submenu());
247 }
248
249 #[test]
250 fn test_nested_submenu() {
251 let nested = MenuItem::new(
252 "Root",
253 MenuAction::SubMenu(vec![MenuItem::new(
254 "Child",
255 MenuAction::SubMenu(vec![MenuItem::new(
256 "Grandchild",
257 MenuAction::Command("echo nested".to_string()),
258 )]),
259 )]),
260 );
261
262 if let MenuAction::SubMenu(items) = &nested.action {
263 assert_eq!(items.len(), 1);
264 assert_eq!(items[0].label, "Child");
265 } else {
266 panic!("Expected submenu");
267 }
268 }
269
270 #[test]
271 fn test_menu_item_with_remote_action() {
272 let item =
273 MenuItem::new("Test", MenuAction::Remote("test_action".to_string())).with_icon("๐ฏ");
274
275 assert_eq!(item.label, "Test");
276 assert_eq!(item.icon, Some("๐ฏ".to_string()));
277 assert!(item.action.is_remote());
278
279 if let MenuAction::Remote(id) = &item.action {
280 assert_eq!(id, "test_action");
281 } else {
282 panic!("Expected Remote action");
283 }
284 }
285}