Skip to main content

blockworx_web/
palette.rs

1//! The command palette: ⌘K over the frame's whole vocabulary
2//! (`docs/cad-ui-spec.md` §9).
3//!
4//! Search, not filter: it is transient, it dismisses on a pick, and Escape
5//! closes it. Filter is the navigator's and behaves oppositely on both
6//! counts, which is why the two never share a control.
7//!
8//! What a query reaches, how it scores and what order the answers come back
9//! in is [`blockworx_kernel::palette`]'s — the same `nucleo-matcher` ranking
10//! the desktop's rows are drawn from. This is the dialog, the listbox and the
11//! keyboard that walks them.
12
13use 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    // Picking is one door, whichever pressed it: a click on a row and Enter
38    // on the highlighted one must not resolve a row two ways.
39    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    // Every hook above runs on every render, open or shut: Dioxus matches
53    // them by position, so a return taken before one of them would hand the
54    // next render somebody else's state.
55    if !open() {
56        return rsx! {};
57    }
58    let rows = {
59        let read = chrome.read();
60        // Only what a press would actually reach: a withheld command is
61        // drawn dead where it has a control of its own, but a row that does
62        // nothing is a row that lied.
63        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                    // `autofocus` only takes on a page's first load; the
101                    // palette is mounted long after that, so the field asks
102                    // for the keyboard itself.
103                    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
176/// What a row other than a command dispatches. A command goes through
177/// `Event::Command` instead, so the call it reaches resolves it against the
178/// session as it then stands rather than against the set the rows were drawn
179/// from.
180fn 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
195/// The typeable spelling on the right of a row, and the key that does the
196/// same thing where the table binds one — so a row cannot promise a key
197/// nothing binds.
198fn 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
209/// A row's own handle, for a script and for a snapshot: a command by its
210/// stable name, anything else by what it is and which one.
211fn 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    /// The handle a script names a row by is the row's identity, not its
239    /// wording — a block renamed keeps the same handle.
240    #[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    /// A command row spells the key that runs it beside its name; a row the
249    /// table binds nothing to promises no key, and a row with no typeable
250    /// name says nothing at all.
251    #[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}