Skip to main content

blockworx_kernel/
palette.rs

1//! The command palette's rows: what a typed query reaches, how it is scored,
2//! and what order the answers come back in.
3//!
4//! The palette is a text field over four sources — the frame's
5//! [`CommandSet`], every block in the document (`find`), the blocks on the
6//! current level (`expand`), the log (`rev`) — plus two verbs whose argument
7//! is typed rather than picked (`go <path>`, `camera <x> <y> <w> <h>`).
8//! Matching and ranking come from `nucleo-matcher`, the Helix editor's
9//! scorer.
10//!
11//! Results are typed and grouped by their [`Source`]: what a row *is*
12//! decides which run it joins, so a block named like a command cannot be
13//! mistaken for one. Numeric queries ("everything wider than 8 cells") are an
14//! open question and are not answered here.
15//!
16//! None of this is a drawing, and a front end that scored its own rows would
17//! answer one query two ways.
18
19use blockworx_doc::id::BlockId;
20use blockworx_editor::path::BlockPath;
21use blockworx_geom::{Rect, grid::grid_pos};
22use blockworx_store::history;
23use blockworx_tools::commands::{Act, CommandId, CommandSet};
24use blockworx_tools::tool::Action;
25use nucleo_matcher::{
26    Config, Matcher, Utf32Str,
27    pattern::{CaseMatching, Normalization, Pattern},
28};
29
30use crate::chrome::NavTree;
31
32/// The verb that turns the rest of a query into a content path.
33const GO_VERB: &str = "go ";
34
35/// The verb that turns the rest of a query into a camera rect.
36const CAMERA_VERB: &str = "camera ";
37
38/// Bounds on a typed camera's size, in grid cells. A zero or negative extent
39/// has no framing at all, and a huge one zooms so far out the diagram
40/// vanishes — both are typos, not intentions.
41const CAMERA_MIN_CELLS: i32 = 1;
42const CAMERA_MAX_CELLS: i32 = 5_000;
43
44/// Ranked rows shown for a non-empty query; the tail is cut, an empty query
45/// lists every available command instead.
46pub const MAX_MATCHES: usize = 12;
47
48/// What the palette searches besides the command set: the navigator's tree
49/// (every block, for `find`; this level's blocks, for `expand`; the names a
50/// `go <path>` resolves through) and the log (every commit, for `rev`).
51#[derive(Clone, Copy)]
52pub struct Sources<'a> {
53    pub tree: &'a NavTree,
54    pub revs: &'a [history::Row],
55}
56
57/// One command as the palette reads it: what a row would invoke, and the
58/// words it is matched and listed by.
59///
60/// The registry's own `Command` carries an `Act` that cannot cross the
61/// front-end seam by value, so the palette is told the two fields it
62/// actually reads — which also lets a shell that holds the frame's commands
63/// as a model rather than as a `CommandSet` feed it.
64#[derive(Clone, Copy)]
65pub struct Offer<'a> {
66    pub id: CommandId,
67    pub label: &'a str,
68}
69
70impl<'a> From<&'a blockworx_tools::commands::Command> for Offer<'a> {
71    fn from(command: &'a blockworx_tools::commands::Command) -> Self {
72        Offer {
73            id: command.id,
74            label: &command.label,
75        }
76    }
77}
78
79/// What a row dispatches when picked: a registry command, navigation to a
80/// block found by name, descending into a block on the current level,
81/// jumping to a typed content path, framing a typed rect, or putting a past
82/// rev on the canvas.
83#[derive(Clone, PartialEq, Debug)]
84pub enum RowId {
85    Command(CommandId),
86    Block(BlockId),
87    Expand(BlockId),
88    GoToPath(BlockPath),
89    Camera(Rect),
90    Rev(blockworx_doc::rev::Rev),
91}
92
93/// Where a result came from. Read off the row's own id rather than carried
94/// beside it, so a row cannot be filed under a source it does not belong to.
95///
96/// The declaration order is the order the groups are listed in: a query that
97/// parses as a typed verb named its target exactly and everything else is a
98/// guess, so those lead; then what the session can *do*, then what the
99/// document holds, then what it has been.
100#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
101pub enum Source {
102    Typed,
103    Command,
104    Block,
105    Rev,
106}
107
108impl Source {
109    #[must_use]
110    pub fn heading(self) -> &'static str {
111        match self {
112            Source::Typed => "Go to",
113            Source::Command => "Commands",
114            Source::Block => "Blocks",
115            Source::Rev => "History",
116        }
117    }
118}
119
120impl RowId {
121    #[must_use]
122    pub fn source(&self) -> Source {
123        match self {
124            RowId::Command(_) => Source::Command,
125            RowId::Block(_) | RowId::Expand(_) => Source::Block,
126            RowId::GoToPath(_) | RowId::Camera(_) => Source::Typed,
127            RowId::Rev(_) => Source::Rev,
128        }
129    }
130
131    /// What picking this row does. A command is *taken* from the set, so one
132    /// the session withholds comes back as `None` and the palette closes on
133    /// nothing rather than invoking it.
134    #[must_use]
135    pub fn act(self, commands: &mut CommandSet) -> Option<Act> {
136        Some(match self {
137            RowId::Command(id) => return commands.take(id),
138            RowId::Block(block) => Action::NavSelect {
139                block,
140                extend: false,
141            }
142            .into(),
143            RowId::Expand(block) => Action::ExpandBlock(block).into(),
144            RowId::GoToPath(path) => Action::GoToPath(path).into(),
145            RowId::Camera(rect) => Action::FrameRect(rect).into(),
146            RowId::Rev(rev) => Action::ViewRev(rev).into(),
147        })
148    }
149}
150
151/// One answer to a query.
152pub struct Row {
153    pub id: RowId,
154    /// The command label, or `find`/`expand` plus a block label.
155    pub text: String,
156    /// The command's stable name, shown dimmed as the typeable spelling.
157    pub name: Option<&'static str>,
158    pub score: u32,
159}
160
161/// The rows a query yields: every available command on an empty query
162/// (registry order), otherwise commands, `expand <block>` entries for the
163/// current level, `find <block>` entries for the whole document and `rev`
164/// entries for the log, scored against label + name and ranked best-first
165/// (ties keep registry order).
166///
167/// Ranking picks *which* rows survive; [`Source`] decides the order they are
168/// listed in. Grouping after the cut rather than before is what keeps the two
169/// from fighting: the results are the same twelve either way, and no group
170/// can crowd another out of the list.
171#[must_use]
172pub fn rows(query: &str, commands: &[Offer<'_>], sources: Sources<'_>) -> Vec<Row> {
173    let query = query.trim();
174    if query.is_empty() {
175        return commands
176            .iter()
177            .map(|command| Row {
178                id: RowId::Command(command.id),
179                text: command.label.to_owned(),
180                name: Some(command.id.name()),
181                score: 0,
182            })
183            .collect();
184    }
185    let mut matcher = Matcher::new(Config::DEFAULT);
186    let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
187    let mut buf = Vec::new();
188    let mut score = |text: &str| pattern.score(Utf32Str::new(text, &mut buf), &mut matcher);
189    let mut rows: Vec<Row> = Vec::new();
190    // `go <path>` is typed, not picked from a list: whatever follows the verb
191    // is parsed as a content path, so a path copied off the canvas navigates
192    // straight back to that level. Only a path that resolves offers a row.
193    if let Some(target) = query
194        .strip_prefix(GO_VERB)
195        .and_then(|rest| sources.tree.path_of(rest))
196    {
197        rows.push(Row {
198            id: RowId::GoToPath(target),
199            text: query.to_owned(),
200            name: None,
201            score: u32::MAX,
202        });
203    }
204    if let Some(rect) = query.strip_prefix(CAMERA_VERB).and_then(parse_camera) {
205        rows.push(Row {
206            id: RowId::Camera(rect),
207            text: query.to_owned(),
208            name: None,
209            score: u32::MAX,
210        });
211    }
212    for command in commands {
213        let name = command.id.name();
214        if let Some(score) = score(&format!("{} {name}", command.label)) {
215            rows.push(Row {
216                id: RowId::Command(command.id),
217                text: command.label.to_owned(),
218                name: Some(name),
219                score,
220            });
221        }
222    }
223    for (id, label) in sources.tree.level_blocks() {
224        let text = format!("expand {label}");
225        if let Some(score) = score(&text) {
226            rows.push(Row {
227                id: RowId::Expand(id),
228                text,
229                name: None,
230                score,
231            });
232        }
233    }
234    for (id, label) in sources.tree.all_blocks() {
235        let text = format!("find {label}");
236        if let Some(score) = score(&text) {
237            rows.push(Row {
238                id: RowId::Block(id),
239                text,
240                name: None,
241                score,
242            });
243        }
244    }
245    for rev in sources.revs {
246        let text = rev_row(rev);
247        if let Some(score) = score(&text) {
248            rows.push(Row {
249                id: RowId::Rev(rev.rev),
250                text,
251                name: None,
252                score,
253            });
254        }
255    }
256    rows.sort_by_key(|row| std::cmp::Reverse(row.score));
257    rows.truncate(MAX_MATCHES);
258    // Stable, so ranking still orders the rows within each run.
259    rows.sort_by_key(|row| row.id.source());
260    rows
261}
262
263/// How a commit reads in the palette: the number a reader types and the label
264/// the history panel gives the same rev, so one commit is not named two ways
265/// across two surfaces.
266#[must_use]
267pub fn rev_row(row: &history::Row) -> String {
268    format!("rev {} \u{2014} {}", row.rev.get(), row.label)
269}
270
271/// Parse `camera <x> <y> <w> <h>` (grid cells) into the world rect it frames.
272/// `None` unless all four numbers are present and the size is within
273/// [`CAMERA_MIN_CELLS`]..=[`CAMERA_MAX_CELLS`], so a half-typed or nonsense
274/// camera simply offers no row to pick.
275fn parse_camera(rest: &str) -> Option<Rect> {
276    let values: Vec<i32> = rest
277        .split_whitespace()
278        .map(str::parse::<i32>)
279        .collect::<Result<_, _>>()
280        .ok()?;
281    let [x, y, w, h] = values[..] else {
282        return None;
283    };
284    let sane = |v: i32| (CAMERA_MIN_CELLS..=CAMERA_MAX_CELLS).contains(&v);
285    if !sane(w) || !sane(h) {
286        return None;
287    }
288    Some(Rect::from_min_size(
289        grid_pos(x, y),
290        grid_pos(w, h).to_vec2(),
291    ))
292}
293
294#[cfg(test)]
295mod tests {
296    use blockworx_doc::fixtures::block_id;
297    use blockworx_editor::edit::naming::InterfaceLock;
298    use blockworx_editor::shape::ShapeId;
299    use blockworx_editor::widget::test_fixtures::{self as fx, Scene};
300    use blockworx_geom::{Rect, pos2};
301    use blockworx_tools::commands::{CommandContext, History};
302    use blockworx_tools::resize_block::ResizeBlock;
303    use blockworx_tools::tool::Tool;
304
305    use super::*;
306
307    /// The navigator's tree over `indexed`, as a frame hands the palette.
308    fn tree_of(indexed: &blockworx_doc::document::IndexedDocument<'_>) -> NavTree {
309        NavTree::of(indexed, BlockPath::empty(), Vec::new())
310    }
311
312    /// A palette's whole input: the frame's commands plus the row sources.
313    struct Fixture {
314        set: CommandSet,
315        scene: Scene,
316        /// The log the `rev` rows are read out of, and the names hung on it.
317        repo: blockworx_doc::repo::Repo,
318        tags: blockworx_store::tags::Tags,
319    }
320
321    impl Fixture {
322        fn rows(&mut self, query: &str) -> Vec<Row> {
323            let Self {
324                set,
325                scene,
326                repo,
327                tags,
328            } = self;
329            let tree = tree_of(&scene.indexed());
330            let revs = history::rows(history::Journal::Session(repo.log()), tags);
331            let offers: Vec<Offer<'_>> = set.iter().map(Offer::from).collect();
332            rows(
333                query,
334                &offers,
335                Sources {
336                    tree: &tree,
337                    revs: &revs,
338                },
339            )
340        }
341    }
342
343    /// Two commits, each labelled differently, so a query can name one.
344    fn logged() -> blockworx_doc::repo::Repo {
345        let commits: Vec<blockworx_doc::commit::Commit> = ["Drew a block", "Moved the adder"]
346            .iter()
347            .enumerate()
348            .map(|(ndx, label)| {
349                blockworx_store::fixture::commit(
350                    label,
351                    vec![blockworx_store::fixture::block_create(
352                        u32::try_from(ndx).expect("two commits") + 10,
353                        &format!("p{ndx}"),
354                    )],
355                )
356            })
357            .collect();
358        blockworx_doc::repo::Repo::folding(&commits).expect("the fixture commits fold")
359    }
360
361    /// A document whose root holds `adder`, which in turn holds `carry`: one
362    /// block on the current level and one a level deeper.
363    fn block_selected_set() -> Fixture {
364        let b = block_id(1);
365        let mut scene = Scene::new(vec![
366            fx::block_in(
367                1,
368                blockworx_editor::path::Scope::Root,
369                Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 80.0)),
370            ),
371            fx::titled(1, "adder"),
372            fx::block_in(
373                2,
374                blockworx_editor::path::Scope::Block(b),
375                Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 40.0)),
376            ),
377            fx::titled(2, "carry"),
378        ]);
379        let tool: Tool = ResizeBlock::Selected {
380            shape: ShapeId::Rect(b),
381        }
382        .into();
383        let set = {
384            let drawing = scene.drawing();
385            CommandSet::available(&CommandContext {
386                tool: &tool,
387                data: &drawing,
388                history: History::empty(),
389                current_lock: InterfaceLock::Unlocked,
390                writability: blockworx_store::doc::Writability::Writable,
391                saving: blockworx_store::doc::Saving::Withheld,
392                viewing: blockworx_store::doc::Viewing::Head,
393            })
394        };
395        Fixture {
396            set,
397            scene,
398            repo: logged(),
399            tags: blockworx_store::tags::Tags::default(),
400        }
401    }
402
403    fn top_row_id(query: &str) -> RowId {
404        let mut fixture = block_selected_set();
405        let rows = fixture.rows(query);
406        assert!(
407            !rows.is_empty(),
408            "{query:?} should match at least one row of {} commands",
409            fixture.set.iter().count(),
410        );
411        rows[0].id.clone()
412    }
413
414    #[test]
415    fn an_empty_query_lists_every_command_in_registry_order() {
416        let mut fixture = block_selected_set();
417        let rows = fixture.rows("");
418        let ids: Vec<RowId> = rows.iter().map(|row| row.id.clone()).collect();
419        let expected: Vec<RowId> = fixture
420            .set
421            .iter()
422            .map(|command| RowId::Command(command.id))
423            .collect();
424        assert_eq!(ids, expected);
425    }
426
427    /// `camera x y w h` frames a rect typed in grid cells — the same numbers
428    /// the authoring footer prints for a level script.
429    #[test]
430    fn a_typed_camera_offers_a_row_and_rejects_nonsense() {
431        let mut fixture = block_selected_set();
432        let mut framed = |query: &str| {
433            fixture
434                .rows(query)
435                .into_iter()
436                .find_map(|row| match row.id {
437                    RowId::Camera(rect) => Some(rect),
438                    _ => None,
439                })
440        };
441        assert_eq!(
442            framed("camera 0 0 28 20"),
443            Some(Rect::from_min_size(
444                grid_pos(0, 0),
445                grid_pos(28, 20).to_vec2(),
446            ))
447        );
448        // Negative origins are fine — the canvas extends both ways.
449        assert!(framed("camera -12 -4 10 10").is_some());
450        // A size that frames nothing, or so much that the diagram vanishes.
451        assert_eq!(framed("camera 0 0 0 20"), None, "zero width");
452        assert_eq!(framed("camera 0 0 28 -20"), None, "negative height");
453        assert_eq!(framed("camera 0 0 28 999999"), None, "absurd height");
454        // Half-typed or misspelled: no row rather than a wrong framing.
455        assert_eq!(framed("camera 0 0 28"), None, "too few values");
456        assert_eq!(framed("camera 0 0 28 20 4"), None, "too many values");
457        assert_eq!(framed("camera a b c d"), None, "not numbers");
458    }
459
460    /// `go <path>` parses whatever follows the verb, so a content path copied
461    /// off the canvas navigates straight back — and leads the ranking.
462    #[test]
463    fn a_typed_content_path_offers_a_go_row() {
464        let mut fixture = block_selected_set();
465        let expected = tree_of(&fixture.scene.indexed())
466            .path_of("adder")
467            .expect("adder is a child of the root scope");
468        assert_eq!(
469            fixture.rows("go adder").first().map(|row| row.id.clone()),
470            Some(RowId::GoToPath(expected))
471        );
472        // A path that resolves to nothing offers no row to pick.
473        assert!(
474            !fixture
475                .rows("go nowhere")
476                .iter()
477                .any(|row| matches!(row.id, RowId::GoToPath(_)))
478        );
479    }
480
481    #[test]
482    fn typeable_names_rank_their_command_first() {
483        assert_eq!(top_row_id("fliplr"), RowId::Command(CommandId::FlipLr));
484        assert_eq!(top_row_id("lock"), RowId::Command(CommandId::Lock));
485        assert_eq!(
486            top_row_id("route"),
487            RowId::Command(CommandId::Arm(blockworx_tools::names::ToolName::Route))
488        );
489    }
490
491    #[test]
492    fn a_block_name_yields_a_find_row() {
493        let mut fixture = block_selected_set();
494        let matched = fixture.rows("adder");
495        assert!(
496            matched
497                .iter()
498                .any(|row| matches!(row.id, RowId::Block(_)) && row.text == "find adder"),
499            "no find row for the adder block"
500        );
501        // The word `find` itself reaches the block rows too.
502        let via_verb = fixture.rows("find add");
503        assert!(via_verb.iter().any(|row| matches!(row.id, RowId::Block(_))));
504    }
505
506    /// A block on the current level can be descended into, so it gets an
507    /// `expand` row; a block a level deeper is reachable by `find` only.
508    #[test]
509    fn only_blocks_on_the_current_level_get_an_expand_row() {
510        let mut fixture = block_selected_set();
511        let tree = tree_of(&fixture.scene.indexed());
512        assert!(tree.all_blocks().iter().any(|(_, at)| at == "carry"));
513        assert!(!tree.level_blocks().iter().any(|(_, at)| at == "carry"));
514
515        let matched = fixture.rows("expand");
516        let expanded: Vec<&str> = matched
517            .iter()
518            .filter(|row| matches!(row.id, RowId::Expand(_)))
519            .map(|row| row.text.as_str())
520            .collect();
521        assert_eq!(expanded, ["expand adder"]);
522    }
523
524    /// A commit is a result like any other: found by its number or by the
525    /// words the history panel gives it, and picked to put that rev on the
526    /// canvas.
527    #[test]
528    fn a_rev_is_found_by_its_number_and_by_its_label() {
529        let mut fixture = block_selected_set();
530        let picked = |rows: Vec<Row>| {
531            rows.into_iter()
532                .find_map(|row| match row.id {
533                    RowId::Rev(rev) => Some(rev),
534                    _ => None,
535                })
536                .map(|rev| rev.get())
537        };
538        assert_eq!(picked(fixture.rows("rev 2")), Some(2));
539        assert_eq!(picked(fixture.rows("Moved the adder")), Some(2));
540        assert_eq!(
541            picked(fixture.rows("camera 0 0 8 8")),
542            None,
543            "a typed camera dragged the whole log in behind it",
544        );
545    }
546
547    /// The results are grouped by source, and ranking orders the rows inside
548    /// each run rather than across the list. One query reaching three sources
549    /// proves both halves at once.
550    #[test]
551    fn results_are_listed_in_source_order() {
552        let mut fixture = block_selected_set();
553        let rows = fixture.rows("add");
554        let sources: Vec<Source> = rows.iter().map(|row| row.id.source()).collect();
555        for source in [Source::Command, Source::Block, Source::Rev] {
556            assert!(
557                sources.contains(&source),
558                "precondition: {source:?} answered nothing: {sources:?}",
559            );
560        }
561        assert!(
562            sources.is_sorted(),
563            "the sources are interleaved: {sources:?}",
564        );
565        let scores: Vec<u32> = rows
566            .iter()
567            .filter(|row| row.id.source() == Source::Command)
568            .map(|row| row.score)
569            .collect();
570        assert!(
571            scores.windows(2).all(|pair| pair[0] >= pair[1]),
572            "ranking stopped ordering the commands: {scores:?}",
573        );
574    }
575}