Skip to main content

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}