nightshade_api/web/
palette.rs1use 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#[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}