Skip to main content

nightshade_api/web/
command.rs

1//! Command model and registry backing the palette, keymap, and menus.
2//!
3//! A [`Command`] is a leaf action or a submenu of nested commands; the
4//! [`CommandRegistry`] stores them in reactive context, tracks recently run
5//! ids, and dispatches by id.
6
7use leptos::prelude::*;
8
9/// A single palette action or a submenu of nested commands.
10///
11/// Build leaves with [`Command::new`] and groupings with [`Command::submenu`],
12/// then chain the `with_*` builders to attach a group label, keybinding, or an
13/// `enabled` signal that gates whether the command can run.
14#[derive(Clone)]
15pub struct Command {
16    pub id: String,
17    pub title: String,
18    pub group: String,
19    pub keybinding: Option<String>,
20    pub run: Option<Callback<()>>,
21    pub children: Vec<Command>,
22    enabled: Signal<bool>,
23}
24
25impl Command {
26    /// Creates a runnable leaf command with the given id, title, and callback.
27    pub fn new(id: impl Into<String>, title: impl Into<String>, run: Callback<()>) -> Self {
28        Self {
29            id: id.into(),
30            title: title.into(),
31            group: String::new(),
32            keybinding: None,
33            run: Some(run),
34            children: Vec::new(),
35            enabled: Signal::derive(|| true),
36        }
37    }
38
39    /// Creates a non-runnable submenu command whose `children` open as a nested level.
40    pub fn submenu(
41        id: impl Into<String>,
42        title: impl Into<String>,
43        children: Vec<Command>,
44    ) -> Self {
45        Self {
46            id: id.into(),
47            title: title.into(),
48            group: String::new(),
49            keybinding: None,
50            run: None,
51            children,
52            enabled: Signal::derive(|| true),
53        }
54    }
55
56    /// Sets the group label shown alongside the command in the palette.
57    pub fn with_group(mut self, group: impl Into<String>) -> Self {
58        self.group = group.into();
59        self
60    }
61
62    /// Attaches a keybinding string (for example `"mod+k"`) used by the keymap and shown as a hint.
63    pub fn with_keybinding(mut self, keybinding: impl Into<String>) -> Self {
64        self.keybinding = Some(keybinding.into());
65        self
66    }
67
68    /// Alias for [`Command::with_group`], reading better when the group is used as a trailing hint.
69    pub fn with_hint(mut self, group: impl Into<String>) -> Self {
70        self.group = group.into();
71        self
72    }
73
74    /// Gates the command behind a reactive `enabled` signal; disabled commands are hidden and never run.
75    pub fn with_enabled(mut self, enabled: impl Into<Signal<bool>>) -> Self {
76        self.enabled = enabled.into();
77        self
78    }
79
80    /// Returns `true` when this command has children and opens as a submenu rather than running.
81    pub fn is_submenu(&self) -> bool {
82        !self.children.is_empty()
83    }
84
85    /// Reactively reads whether the command is currently enabled.
86    pub fn enabled(&self) -> bool {
87        self.enabled.get()
88    }
89
90    fn enabled_untracked(&self) -> bool {
91        self.enabled.get_untracked()
92    }
93}
94
95const RECENT_LIMIT: usize = 8;
96
97/// Reactive store of registered commands plus a bounded most-recently-run list.
98///
99/// It is `Copy` and lives in Leptos context; obtain it with [`use_commands`]
100/// after calling [`provide_command_registry`] near the app root.
101#[derive(Clone, Copy)]
102pub struct CommandRegistry {
103    commands: RwSignal<Vec<Command>>,
104    recent: RwSignal<Vec<String>>,
105}
106
107impl Default for CommandRegistry {
108    fn default() -> Self {
109        Self {
110            commands: RwSignal::new(Vec::new()),
111            recent: RwSignal::new(Vec::new()),
112        }
113    }
114}
115
116impl CommandRegistry {
117    /// Adds a command, replacing any existing one with the same id.
118    pub fn register(&self, command: Command) {
119        self.commands.update(|list| {
120            if let Some(slot) = list.iter_mut().find(|existing| existing.id == command.id) {
121                *slot = command;
122            } else {
123                list.push(command);
124            }
125        });
126    }
127
128    /// Registers each command in the iterator in order.
129    pub fn register_all(&self, commands: impl IntoIterator<Item = Command>) {
130        for command in commands {
131            self.register(command);
132        }
133    }
134
135    /// Removes the top-level command with the given id, if present.
136    pub fn unregister(&self, id: &str) {
137        self.commands
138            .update(|list| list.retain(|command| command.id != id));
139    }
140
141    /// Removes every registered command.
142    pub fn clear(&self) {
143        self.commands.update(Vec::clear);
144    }
145
146    /// Reactively reads the registered top-level commands.
147    pub fn commands(&self) -> Vec<Command> {
148        self.commands.get()
149    }
150
151    /// Reads the registered top-level commands without tracking reactivity.
152    pub fn commands_untracked(&self) -> Vec<Command> {
153        self.commands.get_untracked()
154    }
155
156    /// Searches the command tree, descending into submenus, for a command with the given id.
157    pub fn find(&self, id: &str) -> Option<Command> {
158        find_command(&self.commands.get_untracked(), id)
159    }
160
161    /// Reactively reads the most-recently-run command ids, newest first.
162    pub fn recent(&self) -> Vec<String> {
163        self.recent.get()
164    }
165
166    /// Runs the command with the given id, recording it as recent; returns `false` if it is missing, has no callback, or is disabled.
167    pub fn run(&self, id: &str) -> bool {
168        let Some(command) = self.find(id) else {
169            return false;
170        };
171        let Some(callback) = command.run else {
172            return false;
173        };
174        if !command.enabled_untracked() {
175            return false;
176        }
177        self.record(id);
178        callback.run(());
179        true
180    }
181
182    fn record(&self, id: &str) {
183        self.recent.update(|list| {
184            list.retain(|existing| existing != id);
185            list.insert(0, id.to_string());
186            list.truncate(RECENT_LIMIT);
187        });
188    }
189}
190
191fn find_command(commands: &[Command], id: &str) -> Option<Command> {
192    for command in commands {
193        if command.id == id {
194            return Some(command.clone());
195        }
196        if let Some(found) = find_command(&command.children, id) {
197            return Some(found);
198        }
199    }
200    None
201}
202
203/// Creates a [`CommandRegistry`], provides it as context, and returns it.
204pub fn provide_command_registry() -> CommandRegistry {
205    let registry = CommandRegistry::default();
206    provide_context(registry);
207    registry
208}
209
210/// Retrieves the [`CommandRegistry`] from context, or a fresh empty default if none was provided.
211pub fn use_commands() -> CommandRegistry {
212    use_context::<CommandRegistry>().unwrap_or_default()
213}
214
215#[cfg(test)]
216mod tests {
217    use super::Command;
218    use super::find_command;
219    use leptos::prelude::Callback;
220
221    fn leaf(id: &str) -> Command {
222        Command::new(id, id, Callback::new(|_| {}))
223    }
224
225    #[test]
226    fn find_command_descends_into_submenus() {
227        let tree = vec![
228            leaf("a"),
229            Command::submenu("group", "Group", vec![leaf("b"), leaf("c")]),
230        ];
231        assert!(find_command(&tree, "a").is_some());
232        assert!(find_command(&tree, "c").is_some());
233        assert!(find_command(&tree, "missing").is_none());
234    }
235}