Skip to main content

nightshade_api/web/
palette.rs

1//! The command palette component, driven by the [`CommandRegistry`](crate::web::command::CommandRegistry).
2
3use leptos::prelude::*;
4
5use crate::web::command::{Command, use_commands};
6use crate::web::keymap::pretty_binding;
7
8fn fuzzy_score(query: &str, target: &str) -> Option<i32> {
9    if query.is_empty() {
10        return Some(0);
11    }
12    let target_lower = target.to_lowercase();
13    let query_lower = query.to_lowercase();
14    let mut needle = query_lower.chars().peekable();
15    let mut score = 0;
16    let mut streak = 0;
17    let mut matched_any = false;
18    for (index, character) in target_lower.chars().enumerate() {
19        if let Some(want) = needle.peek().copied()
20            && want == character
21        {
22            needle.next();
23            matched_any = true;
24            streak += 1;
25            score += 1 + streak;
26            if index == 0 {
27                score += 4;
28            }
29        } else {
30            streak = 0;
31        }
32    }
33    if needle.peek().is_none() && matched_any {
34        Some(score)
35    } else {
36        None
37    }
38}
39
40fn option_id(index: usize) -> String {
41    format!("nightshade-palette-option-{index}")
42}
43
44fn resolve_level(root: Vec<Command>, path: &[String]) -> Vec<Command> {
45    let mut current = root;
46    for id in path {
47        match current.iter().find(|command| &command.id == id) {
48            Some(command) => current = command.children.clone(),
49            None => return Vec::new(),
50        }
51    }
52    current
53}
54
55/// Registry-driven command palette: fuzzy-ranked, shows recents when the query is empty, descends into submenus, and displays keybinding hints.
56///
57/// Toggle visibility through the `open` signal (typically bound to a shortcut).
58/// Arrow keys move the selection, Enter runs or opens the active command,
59/// Backspace pops a submenu level, and Escape closes.
60#[component]
61pub fn CommandPalette(open: RwSignal<bool>) -> impl IntoView {
62    let registry = use_commands();
63    let query = RwSignal::new(String::new());
64    let active = RwSignal::new(0usize);
65    let path = RwSignal::new(Vec::<String>::new());
66    let input_ref = NodeRef::<leptos::html::Input>::new();
67
68    let level = move || resolve_level(registry.commands(), &path.get());
69
70    let filtered = move || {
71        let needle = query.get();
72        let commands = level()
73            .into_iter()
74            .filter(Command::enabled)
75            .collect::<Vec<_>>();
76        if needle.is_empty() {
77            if path.get().is_empty() {
78                let recent = registry.recent();
79                let mut ordered = Vec::new();
80                for id in &recent {
81                    if let Some(command) = commands.iter().find(|command| &command.id == id) {
82                        ordered.push(command.clone());
83                    }
84                }
85                for command in &commands {
86                    if !recent.contains(&command.id) {
87                        ordered.push(command.clone());
88                    }
89                }
90                return ordered;
91            }
92            return commands;
93        }
94        let mut scored = commands
95            .into_iter()
96            .filter_map(|command| {
97                fuzzy_score(&needle, &command.title).map(|score| (score, command))
98            })
99            .collect::<Vec<_>>();
100        scored.sort_by(|(left, _), (right, _)| right.cmp(left));
101        scored.into_iter().map(|(_, command)| command).collect()
102    };
103
104    Effect::new(move |_| {
105        let _ = query.get();
106        let _ = path.get();
107        active.set(0);
108    });
109
110    Effect::new(move |_| {
111        if open.get() {
112            path.set(Vec::new());
113            query.set(String::new());
114            if let Some(input) = input_ref.get() {
115                let _ = input.focus();
116            }
117        }
118    });
119
120    let select = Callback::new(move |index: usize| {
121        let Some(command) = filtered().into_iter().nth(index) else {
122            return;
123        };
124        if command.is_submenu() {
125            path.update(|stack| stack.push(command.id.clone()));
126            query.set(String::new());
127            active.set(0);
128            if let Some(input) = input_ref.get() {
129                let _ = input.focus();
130            }
131            return;
132        }
133        registry.run(&command.id);
134        open.set(false);
135        query.set(String::new());
136        path.set(Vec::new());
137    });
138
139    let on_key = move |event: web_sys::KeyboardEvent| match event.key().as_str() {
140        "ArrowDown" => {
141            event.prevent_default();
142            let count = filtered().len();
143            if count > 0 {
144                active.update(|index| *index = (*index + 1) % count);
145            }
146        }
147        "ArrowUp" => {
148            event.prevent_default();
149            let count = filtered().len();
150            if count > 0 {
151                active.update(|index| *index = (*index + count - 1) % count);
152            }
153        }
154        "Enter" => {
155            event.prevent_default();
156            select.run(active.get());
157        }
158        "Backspace" if query.with(String::is_empty) && !path.with(Vec::is_empty) => {
159            event.prevent_default();
160            path.update(|stack| {
161                stack.pop();
162            });
163        }
164        "Escape" => {
165            event.prevent_default();
166            open.set(false);
167        }
168        _ => {}
169    };
170
171    let active_descendant = move || {
172        let items = filtered();
173        if items.is_empty() {
174            String::new()
175        } else {
176            option_id(active.get().min(items.len() - 1))
177        }
178    };
179
180    let breadcrumb = move || {
181        let stack = path.get();
182        if stack.is_empty() {
183            return String::new();
184        }
185        let root = registry.commands();
186        let mut titles = Vec::new();
187        let mut current = root;
188        for id in &stack {
189            if let Some(command) = current.iter().find(|command| &command.id == id) {
190                titles.push(command.title.clone());
191                current = command.children.clone();
192            }
193        }
194        titles.join("  ›  ")
195    };
196
197    view! {
198        <Show when=move || open.get() fallback=|| ()>
199            <div class="nightshade-palette-scrim" on:click=move |_| open.set(false)>
200                <div class="nightshade-palette" on:click=|event| event.stop_propagation()>
201                    <Show when=move || !path.get().is_empty() fallback=|| ()>
202                        <div class="nightshade-palette-breadcrumb">{breadcrumb}</div>
203                    </Show>
204                    <input
205                        node_ref=input_ref
206                        class="nightshade-palette-input"
207                        type="text"
208                        role="combobox"
209                        aria-expanded="true"
210                        aria-controls="nightshade-palette-list"
211                        aria-autocomplete="list"
212                        aria-activedescendant=active_descendant
213                        placeholder="Type a command…"
214                        prop:value=move || query.get()
215                        on:input=move |event| query.set(event_target_value(&event))
216                        on:keydown=on_key
217                    />
218                    <div id="nightshade-palette-list" class="nightshade-palette-list" role="listbox">
219                        {move || {
220                            let items = filtered();
221                            if items.is_empty() {
222                                view! {
223                                    <div class="nightshade-palette-empty">"No matching commands"</div>
224                                }
225                                    .into_any()
226                            } else {
227                                items
228                                    .into_iter()
229                                    .enumerate()
230                                    .map(|(index, command)| {
231                                        let is_submenu = command.is_submenu();
232                                        let group = command.group.clone();
233                                        let keybinding = command
234                                            .keybinding
235                                            .as_deref()
236                                            .map(pretty_binding)
237                                            .unwrap_or_default();
238                                        let is_active = move || active.get() == index;
239                                        view! {
240                                            <div
241                                                id=option_id(index)
242                                                role="option"
243                                                aria-selected=move || is_active().to_string()
244                                                class=move || {
245                                                    if is_active() {
246                                                        "nightshade-palette-item active"
247                                                    } else {
248                                                        "nightshade-palette-item"
249                                                    }
250                                                }
251                                                on:click=move |_| select.run(index)
252                                            >
253                                                <span class="nightshade-palette-title">
254                                                    {command.title}
255                                                    {(!group.is_empty())
256                                                        .then(|| {
257                                                            view! {
258                                                                <span class="nightshade-palette-group">{group}</span>
259                                                            }
260                                                        })}
261                                                </span>
262                                                {if is_submenu {
263                                                    view! { <span class="hint">"›"</span> }.into_any()
264                                                } else if !keybinding.is_empty() {
265                                                    view! {
266                                                        <kbd class="nightshade-palette-kbd">{keybinding}</kbd>
267                                                    }
268                                                        .into_any()
269                                                } else {
270                                                    ().into_any()
271                                                }}
272                                            </div>
273                                        }
274                                    })
275                                    .collect_view()
276                                    .into_any()
277                            }
278                        }}
279                    </div>
280                </div>
281            </div>
282        </Show>
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::fuzzy_score;
289
290    #[test]
291    fn fuzzy_matches_subsequences_case_insensitively() {
292        assert!(fuzzy_score("sc", "Spawn Cube").is_some());
293        assert!(fuzzy_score("", "anything").is_some());
294        assert!(fuzzy_score("cube", "spawn cube").is_some());
295    }
296
297    #[test]
298    fn fuzzy_rejects_out_of_order_or_missing_characters() {
299        assert!(fuzzy_score("cs", "Spawn Cube").is_none());
300        assert!(fuzzy_score("xyz", "Spawn Cube").is_none());
301    }
302
303    #[test]
304    fn prefix_matches_outrank_scattered_ones() {
305        let prefix = fuzzy_score("spa", "Spawn Cube").expect("prefix matches");
306        let scattered = fuzzy_score("spa", "Set Parallax").expect("scattered matches");
307        assert!(prefix > scattered);
308    }
309}