1use blockworx_kernel::palette::{Row, RowId, Source};
14use blockworx_kernel::{Event, palette};
15use blockworx_tools::commands::binding;
16use dioxus::prelude::*;
17use dioxus::web::WebEventExt as _;
18use wasm_bindgen::JsCast as _;
19use web_sys::HtmlElement;
20
21use crate::chords::spelled;
22use crate::shell::Shell;
23
24const FIELD: &str = "w-full border-b border-zinc-950/10 bg-transparent px-4 py-3.5 text-[15px] \
25 outline-none placeholder:text-zinc-400 dark:border-white/10";
26
27const HEADING: &str = "px-4 pb-1 pt-3 text-xs font-medium uppercase tracking-wide text-zinc-500 \
28 dark:text-zinc-400";
29
30#[component]
31pub fn Palette(shell: Shell, open: Signal<bool>, typed: Option<String>) -> Element {
32 let chrome = shell.chrome();
33 let mut query = use_signal(|| typed.unwrap_or_default());
34 let mut at = use_signal(|| 0_usize);
35 let token = shell.token();
36 let raise = use_hook(|| CopyValue::new(shell));
37 let run = use_callback(move |id: RowId| {
40 open.set(false);
41 query.set(String::new());
42 at.set(0);
43 match id {
44 RowId::Command(command) => raise.read().say(Event::Command(command)),
45 other => {
46 if let Some(action) = as_action(other) {
47 raise.read().say(Event::Action(action));
48 }
49 }
50 }
51 });
52 if !open() {
56 return rsx! {};
57 }
58 let rows = {
59 let read = chrome.read();
60 let offers: Vec<palette::Offer<'_>> = read
64 .commands
65 .live()
66 .map(|face| palette::Offer {
67 id: face.id,
68 label: &face.label,
69 })
70 .collect();
71 palette::rows(
72 &query(),
73 &offers,
74 palette::Sources {
75 tree: &read.nav_tree,
76 revs: &read.history,
77 },
78 )
79 };
80 let picked = at().min(rows.len().saturating_sub(1));
81 let ids: Vec<RowId> = rows.iter().map(|row| row.id.clone()).collect();
82 let mut heading: Option<Source> = None;
83 rsx! {
84 div {
85 class: "fixed inset-0 z-50 flex justify-center bg-zinc-950/20 pt-[10vh]",
86 "data-palette": "true",
87 onclick: move |_| open.set(false),
88 div {
89 class: "flex h-fit max-h-[70vh] w-[min(520px,92vw)] flex-col overflow-hidden \
90 rounded-2xl bg-white shadow-xl ring-1 ring-zinc-950/5 dark:bg-zinc-800 \
91 dark:ring-white/10",
92 role: "dialog",
93 "aria-modal": "true",
94 "aria-label": "Search",
95 onclick: move |event| event.stop_propagation(),
96 input {
97 class: FIELD,
98 value: "{query}",
99 placeholder: "Type a command or block name\u{2026}",
100 onmounted: move |event| {
104 if let Some(field) = event
105 .try_as_web_event()
106 .and_then(|element| element.dyn_into::<HtmlElement>().ok())
107 {
108 let _ = field.focus();
109 }
110 },
111 "aria-label": "Search",
112 "data-palette-query": "true",
113 oninput: move |event| {
114 query.set(event.value());
115 at.set(0);
116 },
117 onkeydown: move |event| match event.key() {
118 Key::Escape => open.set(false),
119 Key::ArrowDown => at.set(picked + 1),
120 Key::ArrowUp => at.set(picked.saturating_sub(1)),
121 Key::Enter => {
122 if let Some(id) = ids.get(picked) {
123 run.call(id.clone());
124 }
125 }
126 _ => {}
127 },
128 }
129 div { class: "min-h-0 flex-1 overflow-y-auto pb-2", role: "listbox",
130 if rows.is_empty() {
131 p { class: "px-4 py-3 text-sm text-zinc-500 dark:text-zinc-400",
132 "No matching command or block"
133 }
134 }
135 for (place , row) in rows.iter().enumerate() {
136 {
137 let source = row.id.source();
138 let first = heading != Some(source);
139 if first {
140 heading = Some(source);
141 }
142 let id = row.id.clone();
143 rsx! {
144 if first {
145 p { class: HEADING, "{source.heading()}" }
146 }
147 button {
148 class: if place == picked {
149 "flex w-full items-center gap-3 bg-sky-500/12 px-4 py-2 \
150 text-left text-sm dark:bg-sky-400/15"
151 } else {
152 "flex w-full items-center gap-3 px-4 py-2 text-left \
153 text-sm"
154 },
155 role: "option",
156 "aria-selected": if place == picked { "true" } else { "false" },
157 "data-row": "{stable(row)}",
158 onmouseenter: move |_| at.set(place),
159 onclick: move |_| run.call(id.clone()),
160 span { class: "min-w-0 flex-1 truncate", "{row.text}" }
161 if let Some(annotation) = annotated(row, token) {
162 span { class: "flex-none text-xs text-zinc-500 dark:text-zinc-400",
163 "{annotation}"
164 }
165 }
166 }
167 }
168 }
169 }
170 }
171 }
172 }
173 }
174}
175
176fn as_action(id: RowId) -> Option<blockworx_tools::tool::Action> {
181 use blockworx_tools::tool::Action;
182 Some(match id {
183 RowId::Command(_) => return None,
184 RowId::Block(block) => Action::NavSelect {
185 block,
186 extend: false,
187 },
188 RowId::Expand(block) => Action::ExpandBlock(block),
189 RowId::GoToPath(path) => Action::GoToPath(path),
190 RowId::Camera(rect) => Action::FrameRect(rect),
191 RowId::Rev(rev) => Action::ViewRev(rev),
192 })
193}
194
195fn annotated(row: &Row, token: crate::chords::Token) -> Option<String> {
199 let name = row.name?;
200 let RowId::Command(id) = row.id else {
201 return Some(name.to_owned());
202 };
203 Some(match binding(id) {
204 Some(chord) => format!("{name} \u{00b7} {}", spelled(token, *chord)),
205 None => name.to_owned(),
206 })
207}
208
209fn stable(row: &Row) -> String {
212 match &row.id {
213 RowId::Command(id) => id.name().to_owned(),
214 RowId::Block(block) => format!("find-{block}"),
215 RowId::Expand(block) => format!("expand-{block}"),
216 RowId::Rev(rev) => format!("rev-{}", rev.get()),
217 RowId::GoToPath(_) => "go".to_owned(),
218 RowId::Camera(_) => "camera".to_owned(),
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use blockworx_tools::commands::CommandId;
225
226 use super::*;
227 use blockworx_doc::fixtures::{block_id, rev};
228
229 fn row(id: RowId) -> Row {
230 Row {
231 id,
232 text: String::new(),
233 name: None,
234 score: 0,
235 }
236 }
237
238 #[test]
241 fn a_row_is_named_by_what_it_is() {
242 assert_eq!(stable(&row(RowId::Command(CommandId::FitView))), "fit");
243 assert_eq!(stable(&row(RowId::Block(block_id(3)))), "find-b3");
244 assert_eq!(stable(&row(RowId::Expand(block_id(3)))), "expand-b3");
245 assert_eq!(stable(&row(RowId::Rev(rev(7)))), "rev-7");
246 }
247
248 #[test]
252 fn a_command_row_spells_the_key_the_table_binds() {
253 let named = |id| {
254 annotated(
255 &Row {
256 name: Some(CommandId::name(id)),
257 ..row(RowId::Command(id))
258 },
259 crate::chords::Token::Control,
260 )
261 };
262 assert_eq!(
263 named(CommandId::FitView),
264 Some("fit \u{00b7} Ctrl+0".to_owned())
265 );
266 assert_eq!(named(CommandId::Import), Some("import".to_owned()));
267 assert_eq!(
268 annotated(&row(RowId::Rev(rev(2))), crate::chords::Token::Control),
269 None
270 );
271 }
272}