nightshade_api/web/
command.rs1use leptos::prelude::*;
8
9#[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 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 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 pub fn with_group(mut self, group: impl Into<String>) -> Self {
58 self.group = group.into();
59 self
60 }
61
62 pub fn with_keybinding(mut self, keybinding: impl Into<String>) -> Self {
64 self.keybinding = Some(keybinding.into());
65 self
66 }
67
68 pub fn with_hint(mut self, group: impl Into<String>) -> Self {
70 self.group = group.into();
71 self
72 }
73
74 pub fn with_enabled(mut self, enabled: impl Into<Signal<bool>>) -> Self {
76 self.enabled = enabled.into();
77 self
78 }
79
80 pub fn is_submenu(&self) -> bool {
82 !self.children.is_empty()
83 }
84
85 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#[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 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 pub fn register_all(&self, commands: impl IntoIterator<Item = Command>) {
130 for command in commands {
131 self.register(command);
132 }
133 }
134
135 pub fn unregister(&self, id: &str) {
137 self.commands
138 .update(|list| list.retain(|command| command.id != id));
139 }
140
141 pub fn clear(&self) {
143 self.commands.update(Vec::clear);
144 }
145
146 pub fn commands(&self) -> Vec<Command> {
148 self.commands.get()
149 }
150
151 pub fn commands_untracked(&self) -> Vec<Command> {
153 self.commands.get_untracked()
154 }
155
156 pub fn find(&self, id: &str) -> Option<Command> {
158 find_command(&self.commands.get_untracked(), id)
159 }
160
161 pub fn recent(&self) -> Vec<String> {
163 self.recent.get()
164 }
165
166 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
203pub fn provide_command_registry() -> CommandRegistry {
205 let registry = CommandRegistry::default();
206 provide_context(registry);
207 registry
208}
209
210pub 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}