Skip to main content

blockworx/tools/
palette.rs

1//! The command palette: a Ctrl+K popup that fuzzy-searches the frame's
2//! [`CommandSet`], plus a `find <block>` row per document block, an
3//! `expand <block>` row per block on the current level, a `rev N` row per
4//! commit in the log, and a `go <path>` row for a typed content
5//! path — so every registered operation (and canvas navigation) is reachable
6//! by typing its name or label. Matching and ranking come from `nucleo-matcher` (the
7//! Helix editor's scorer); rows dispatch the same resolved [`Action`]s the
8//! overlay buttons do.
9//!
10//! Results are typed and grouped by their [`Source`] (spec §6): what a row
11//! *is* decides which run it joins, so a block named like a command cannot
12//! be mistaken for one. Numeric queries ("everything wider than 8 cells")
13//! are the spec's own open question and are not answered here.
14
15use nucleo_matcher::{
16    Config, Matcher, Utf32Str,
17    pattern::{CaseMatching, Normalization, Pattern},
18};
19
20use crate::{
21    path::BlockPath,
22    tools::{
23        commands::{CommandId, CommandSet},
24        nav_tree,
25        tool::Action,
26    },
27};
28use blockworx_doc::{document::IndexedDocument, id::BlockId};
29use blockworx_geom::Rect;
30
31type Doc<'a> = IndexedDocument<'a>;
32
33/// The verb that turns the rest of a query into a content path.
34const GO_VERB: &str = "go ";
35
36/// The verb that turns the rest of a query into a camera rect.
37const CAMERA_VERB: &str = "camera ";
38
39/// Bounds on a typed camera's size, in grid cells. A zero or negative extent
40/// has no framing at all, and a huge one zooms so far out the diagram vanishes
41/// — both are typos, not intentions.
42const CAMERA_MIN_CELLS: i32 = 1;
43const CAMERA_MAX_CELLS: i32 = 5_000;
44
45/// Parse `camera <x> <y> <w> <h>` (grid cells) into the world rect it frames.
46/// `None` unless all four numbers are present and the size is within
47/// [`CAMERA_MIN_CELLS`]..=[`CAMERA_MAX_CELLS`], so a half-typed or nonsense
48/// camera simply offers no row to pick.
49fn parse_camera(rest: &str) -> Option<Rect> {
50    let values: Vec<i32> = rest
51        .split_whitespace()
52        .map(str::parse::<i32>)
53        .collect::<Result<_, _>>()
54        .ok()?;
55    let [x, y, w, h] = values[..] else {
56        return None;
57    };
58    let sane = |v: i32| (CAMERA_MIN_CELLS..=CAMERA_MAX_CELLS).contains(&v);
59    if !sane(w) || !sane(h) {
60        return None;
61    }
62    Some(Rect::from_min_size(
63        crate::grid::grid_pos(x, y),
64        crate::grid::grid_pos(w, h).to_vec2(),
65    ))
66}
67
68const PALETTE_WIDTH: f32 = 420.0;
69const LIST_MAX_HEIGHT: f32 = 280.0;
70/// Ranked rows shown for a non-empty query; the tail is cut, an empty query
71/// lists every available command instead.
72const MAX_MATCHES: usize = 12;
73
74/// What the palette searches besides the command set: the document (every
75/// block, for `find`), the current path (this level's blocks, for `expand`)
76/// and the log (every commit, for `rev`).
77#[derive(Clone, Copy)]
78pub struct PaletteScope<'a> {
79    pub document: &'a Doc<'a>,
80    pub path: &'a BlockPath,
81    pub revs: &'a [blockworx_store::history::Row<'a>],
82}
83
84/// The open palette's state: the query text and the highlighted row.
85pub struct Palette {
86    query: String,
87    selected: usize,
88}
89
90/// What the palette's frame asks the app to do. The action is boxed so the
91/// stay-open/close cases don't carry `Action`'s footprint.
92pub enum PaletteOutcome {
93    /// Stay open.
94    Open,
95    /// Close without dispatching (Escape, click-away).
96    Close,
97    /// Dispatch the picked row's action and close.
98    Dispatch(Box<Action>),
99}
100
101/// What a row dispatches when picked: a registry command, navigation to a
102/// block found by name, descending into a block on the current level,
103/// jumping to a typed content path, or putting a past rev on the canvas.
104#[derive(Clone, PartialEq, Debug)]
105enum RowId {
106    Command(CommandId),
107    Block(BlockId),
108    Expand(BlockId),
109    GoToPath(BlockPath),
110    Camera(Rect),
111    Rev(blockworx_doc::rev::Rev),
112}
113
114/// Where a result came from (spec §6). Read off the row's own id rather than
115/// carried beside it, so a row cannot be filed under a source it does not
116/// belong to.
117///
118/// The declaration order is the order the groups are listed in: a query that
119/// parses as a typed verb named its target exactly and everything else is a
120/// guess, so those lead; then what the session can *do*, then what the
121/// document holds, then what it has been.
122#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
123enum Source {
124    Typed,
125    Command,
126    Block,
127    Rev,
128}
129
130impl Source {
131    fn heading(self) -> &'static str {
132        match self {
133            Source::Typed => "Go to",
134            Source::Command => "Commands",
135            Source::Block => "Blocks",
136            Source::Rev => "History",
137        }
138    }
139}
140
141impl RowId {
142    fn source(&self) -> Source {
143        match self {
144            RowId::Command(_) => Source::Command,
145            RowId::Block(_) | RowId::Expand(_) => Source::Block,
146            RowId::GoToPath(_) | RowId::Camera(_) => Source::Typed,
147            RowId::Rev(_) => Source::Rev,
148        }
149    }
150}
151
152struct Row {
153    id: RowId,
154    /// The command label, or `find`/`expand` plus a block label.
155    text: String,
156    /// The command's stable name, shown dimmed as the typeable spelling.
157    name: Option<&'static str>,
158    score: u32,
159}
160
161impl Palette {
162    pub fn new() -> Self {
163        Self {
164            query: String::new(),
165            selected: 0,
166        }
167    }
168
169    /// Show the palette for one frame. Keyboard: Up/Down move the highlight,
170    /// Enter picks it, Escape closes — consumed here so nothing else sees
171    /// them (the canvas already ignores keys while the palette's text field
172    /// holds focus).
173    pub fn show(
174        &mut self,
175        ctx: &egui::Context,
176        commands: &mut CommandSet,
177        scope: PaletteScope<'_>,
178        viewport: egui::Rect,
179    ) -> PaletteOutcome {
180        use egui::{Key, Modifiers};
181        let (up, down, enter, escape) = ctx.input_mut(|i| {
182            (
183                i.consume_key(Modifiers::NONE, Key::ArrowUp),
184                i.consume_key(Modifiers::NONE, Key::ArrowDown),
185                i.consume_key(Modifiers::NONE, Key::Enter),
186                i.consume_key(Modifiers::NONE, Key::Escape),
187            )
188        });
189        if escape {
190            return PaletteOutcome::Close;
191        }
192        let blocks = nav_tree::all_blocks(scope.document);
193        let level = nav_tree::level_blocks(scope.document, scope.path);
194        let rows = rows(&self.query, commands, &blocks, &level, scope);
195        if down {
196            self.selected += 1;
197        }
198        if up {
199            self.selected = self.selected.saturating_sub(1);
200        }
201        self.selected = self.selected.min(rows.len().saturating_sub(1));
202        // Scroll only on an actual move: an unconditional per-frame
203        // `scroll_to_me` restarts a scroll animation every frame, which pins
204        // the repaint delay at zero and keeps the list shimmering.
205        let scroll_to_selected = up || down;
206        let mut picked: Option<RowId> = None;
207        if enter {
208            picked = rows.get(self.selected).map(|r| r.id.clone());
209        }
210        let pos = egui::pos2(
211            viewport.center().x - PALETTE_WIDTH / 2.0,
212            viewport.top() + 0.1 * viewport.height(),
213        );
214        let area = egui::Area::new(egui::Id::new("command_palette"))
215            .order(egui::Order::Foreground)
216            .fixed_pos(pos)
217            .show(ctx, |ui| {
218                egui::Frame::popup(ui.style()).show(ui, |ui| {
219                    ui.set_width(PALETTE_WIDTH);
220                    let edit = ui.add(
221                        egui::TextEdit::singleline(&mut self.query)
222                            .hint_text("Type a command or block name…")
223                            .desired_width(f32::INFINITY),
224                    );
225                    edit.request_focus();
226                    if edit.changed() {
227                        self.selected = 0;
228                    }
229                    ui.separator();
230                    egui::ScrollArea::vertical()
231                        .max_height(LIST_MAX_HEIGHT)
232                        .show(ui, |ui| {
233                            // Each row: the label as the clickable body, the
234                            // command's typeable name dimmed on the right,
235                            // under the heading of the source it came from.
236                            let mut heading_shown: Option<Source> = None;
237                            for (i, row) in rows.iter().enumerate() {
238                                let source = row.id.source();
239                                if heading_shown != Some(source) {
240                                    heading_shown = Some(source);
241                                    crate::tools::overlay::group_heading(ui, source.heading());
242                                }
243                                let selected = i == self.selected;
244                                ui.horizontal(|ui| {
245                                    let response = ui.selectable_label(selected, &row.text);
246                                    if selected && scroll_to_selected {
247                                        response.scroll_to_me(None);
248                                    }
249                                    if response.clicked() {
250                                        picked = Some(row.id.clone());
251                                    }
252                                    if let Some(name) = row.name {
253                                        let annotation = match row.id {
254                                            RowId::Command(cid) => {
255                                                match crate::tools::commands::binding(cid) {
256                                                    Some(chord) => format!(
257                                                        "{name} · {}",
258                                                        ui.ctx().format_shortcut(chord)
259                                                    ),
260                                                    None => name.to_string(),
261                                                }
262                                            }
263                                            _ => name.to_string(),
264                                        };
265                                        ui.with_layout(
266                                            egui::Layout::right_to_left(egui::Align::Center),
267                                            |ui| ui.weak(annotation),
268                                        );
269                                    }
270                                });
271                            }
272                            if rows.is_empty() {
273                                ui.weak("No matching command or block");
274                            }
275                        });
276                });
277            });
278        if let Some(id) = picked {
279            let action = match id {
280                RowId::Command(cid) => commands.take(cid),
281                RowId::Block(rid) => Some(Action::NavSelect {
282                    block: rid,
283                    extend: false,
284                }),
285                RowId::Expand(rid) => Some(Action::ExpandBlock(rid)),
286                RowId::GoToPath(path) => Some(Action::GoToPath(path)),
287                RowId::Camera(rect) => Some(Action::Camera(rect)),
288                RowId::Rev(rev) => Some(Action::ViewRev(rev)),
289            };
290            return match action {
291                Some(action) => PaletteOutcome::Dispatch(Box::new(action)),
292                None => PaletteOutcome::Close,
293            };
294        }
295        if area.response.clicked_elsewhere() {
296            return PaletteOutcome::Close;
297        }
298        PaletteOutcome::Open
299    }
300}
301
302/// The rows a query yields: every available command on an empty query
303/// (registry order), otherwise commands, `expand <block>` entries for the
304/// current level, `find <block>` entries for the whole document and `rev`
305/// entries for the log, scored against label + name and ranked best-first
306/// (ties keep registry order).
307///
308/// Ranking picks *which* rows survive; [`Source`] decides the order they are
309/// listed in (§6). Grouping after the cut rather than before is what keeps
310/// the two from fighting: the results are the same twelve either way, and no
311/// group can crowd another out of the list.
312fn rows(
313    query: &str,
314    commands: &CommandSet,
315    blocks: &[(BlockId, String)],
316    level: &[(BlockId, String)],
317    scope: PaletteScope<'_>,
318) -> Vec<Row> {
319    let query = query.trim();
320    if query.is_empty() {
321        return commands
322            .iter()
323            .map(|c| Row {
324                id: RowId::Command(c.id),
325                text: c.label.to_string(),
326                name: Some(c.id.name()),
327                score: 0,
328            })
329            .collect();
330    }
331    let mut matcher = Matcher::new(Config::DEFAULT);
332    let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
333    let mut buf = Vec::new();
334    let mut score = |text: &str| pattern.score(Utf32Str::new(text, &mut buf), &mut matcher);
335    let mut rows: Vec<Row> = Vec::new();
336    // `go <path>` is typed, not picked from a list: whatever follows the verb
337    // is parsed as a content path, so a path copied off the canvas navigates
338    // straight back to that level. Only a path that resolves offers a row.
339    if let Some(target) = query
340        .strip_prefix(GO_VERB)
341        .and_then(|rest| crate::tools::content_path::parse(scope.document, rest))
342    {
343        rows.push(Row {
344            id: RowId::GoToPath(target),
345            text: query.to_owned(),
346            name: None,
347            score: u32::MAX,
348        });
349    }
350    if let Some(rect) = query.strip_prefix(CAMERA_VERB).and_then(parse_camera) {
351        rows.push(Row {
352            id: RowId::Camera(rect),
353            text: query.to_owned(),
354            name: None,
355            score: u32::MAX,
356        });
357    }
358    for c in commands.iter() {
359        let name = c.id.name();
360        if let Some(score) = score(&format!("{} {name}", c.label)) {
361            rows.push(Row {
362                id: RowId::Command(c.id),
363                text: c.label.to_string(),
364                name: Some(name),
365                score,
366            });
367        }
368    }
369    for (rid, label) in level {
370        let text = format!("expand {label}");
371        if let Some(score) = score(&text) {
372            rows.push(Row {
373                id: RowId::Expand(*rid),
374                text,
375                name: None,
376                score,
377            });
378        }
379    }
380    for (rid, label) in blocks {
381        let text = format!("find {label}");
382        if let Some(score) = score(&text) {
383            rows.push(Row {
384                id: RowId::Block(*rid),
385                text,
386                name: None,
387                score,
388            });
389        }
390    }
391    for rev in scope.revs {
392        let text = rev_row(rev);
393        if let Some(score) = score(&text) {
394            rows.push(Row {
395                id: RowId::Rev(rev.rev),
396                text,
397                name: None,
398                score,
399            });
400        }
401    }
402    rows.sort_by_key(|row| std::cmp::Reverse(row.score));
403    rows.truncate(MAX_MATCHES);
404    // Stable, so ranking still orders the rows within each run.
405    rows.sort_by_key(|row| row.id.source());
406    rows
407}
408
409/// How a commit reads in the palette: the number a reader types and the
410/// label the history panel gives the same rev, so one commit is not named
411/// two ways across two surfaces.
412fn rev_row(row: &blockworx_store::history::Row<'_>) -> String {
413    format!("rev {} \u{2014} {}", row.rev.get(), row.label)
414}
415
416#[cfg(all(test, feature = "kittest"))]
417mod kittest_visual {
418    use super::*;
419    use crate::path::Scope;
420    use crate::shell::picture::{Width, at_every_width, dress};
421    use crate::tools::commands::{CommandContext, History};
422    use crate::widget::test_fixtures::{self as fx, Scene};
423    use egui::vec2;
424    use egui_kittest::Harness;
425
426    /// One query reaching three sources (§6): the commands the session can
427    /// run, the blocks the document holds, and the revs it has been —
428    /// each run under the heading that says what it is, so a row's kind is
429    /// read off the list rather than guessed from its wording.
430    #[test]
431    fn shell_palette() {
432        at_every_width("shell_palette", picture);
433    }
434
435    #[expect(
436        clippy::expect_used,
437        reason = "a fixture that will not fold has no picture to take"
438    )]
439    fn picture(width: Width) -> Harness<'static> {
440        let mut scene = Scene::new(vec![
441            fx::block_in(
442                1,
443                Scope::Root,
444                Rect::from_min_max(
445                    blockworx_geom::pos2(0.0, 0.0),
446                    blockworx_geom::pos2(80.0, 80.0),
447                ),
448            ),
449            fx::titled(1, "adder"),
450        ]);
451        let repo = blockworx_doc::repo::Repo::folding(&blockworx_store::fixture::edits(2))
452            .expect("the edits fold");
453        let tags = blockworx_store::tags::Tags::default();
454        let mut palette = Palette::new();
455        "add".clone_into(&mut palette.query);
456        let size = vec2(width.points(), 460.0);
457        let viewport = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), size);
458        Harness::builder().with_size(size).build_ui(move |ui| {
459            let ctx = ui.ctx().clone();
460            dress(&ctx);
461            let tool: crate::tools::tool::Tool = crate::tools::SelectTool.into();
462            let mut commands = {
463                let drawing = scene.drawing();
464                CommandSet::available(&CommandContext {
465                    tool: &tool,
466                    data: &drawing,
467                    history: History {
468                        undo: Some(crate::history::Kind::Doc),
469                        redo: None,
470                    },
471                    current_lock: crate::edit::naming::InterfaceLock::Unlocked,
472                    writability: blockworx_store::doc::Writability::Writable,
473                    saving: blockworx_store::doc::Saving::Withheld,
474                    viewing: blockworx_store::doc::Viewing::Head,
475                })
476            };
477            let indexed = scene.indexed();
478            let revs = blockworx_store::history::rows(
479                blockworx_store::history::Journal::Session(repo.log()),
480                &tags,
481            );
482            let here = BlockPath::empty();
483            let _ = palette.show(
484                &ctx,
485                &mut commands,
486                PaletteScope {
487                    document: &indexed,
488                    path: &here,
489                    revs: &revs,
490                },
491                viewport,
492            );
493        })
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::canvas::convert::IntoEgui as _;
501    use crate::edit::naming::InterfaceLock;
502    use crate::path::Scope;
503    use crate::tools::commands::{CommandContext, CommandSet, History};
504    use crate::tools::resize_block::ResizeBlock;
505    use crate::tools::tool::Tool;
506    use crate::widget::test_fixtures::{self as fx, Scene};
507    use blockworx_doc::fixtures::block_id;
508    use blockworx_geom::{Rect, pos2, vec2};
509
510    /// Blocks paired with their palette labels.
511    type Labeled = Vec<(BlockId, String)>;
512
513    /// A palette's whole input: the frame's commands plus the row sources.
514    struct Fixture {
515        set: CommandSet,
516        scene: Scene,
517        blocks: Labeled,
518        level: Labeled,
519        /// The log the `rev` rows are read out of, and the names hung on it.
520        repo: blockworx_doc::repo::Repo,
521        tags: blockworx_store::tags::Tags,
522    }
523
524    impl Fixture {
525        fn rows(&mut self, query: &str) -> Vec<Row> {
526            let Self {
527                set,
528                scene,
529                blocks,
530                level,
531                repo,
532                tags,
533            } = self;
534            let indexed = scene.indexed();
535            let revs = blockworx_store::history::rows(
536                blockworx_store::history::Journal::Session(repo.log()),
537                tags,
538            );
539            let here = BlockPath::empty();
540            rows(
541                query,
542                set,
543                blocks,
544                level,
545                PaletteScope {
546                    document: &indexed,
547                    path: &here,
548                    revs: &revs,
549                },
550            )
551        }
552    }
553
554    /// Three commits, each labelled differently, so a query can name one.
555    fn logged() -> blockworx_doc::repo::Repo {
556        let commits: Vec<blockworx_doc::commit::Commit> = ["Drew a block", "Moved the adder"]
557            .iter()
558            .enumerate()
559            .map(|(ndx, label)| {
560                blockworx_store::fixture::commit(
561                    label,
562                    vec![blockworx_store::fixture::block_create(
563                        u32::try_from(ndx).expect("two commits") + 10,
564                        &format!("p{ndx}"),
565                    )],
566                )
567            })
568            .collect();
569        blockworx_doc::repo::Repo::folding(&commits).expect("the fixture commits fold")
570    }
571
572    /// A document whose root holds `adder`, which in turn holds `carry`: one
573    /// block on the current level and one a level deeper.
574    fn block_selected_set() -> Fixture {
575        let b = block_id(1);
576        let mut scene = Scene::new(vec![
577            fx::block_in(
578                1,
579                Scope::Root,
580                Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 80.0)),
581            ),
582            fx::titled(1, "adder"),
583            fx::block_in(
584                2,
585                Scope::Block(b),
586                Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 40.0)),
587            ),
588            fx::titled(2, "carry"),
589        ]);
590        let tool: Tool = ResizeBlock::Selected {
591            shape: crate::shape::ShapeId::Rect(b),
592        }
593        .into();
594        let set = {
595            let drawing = scene.drawing();
596            CommandSet::available(&CommandContext {
597                tool: &tool,
598                data: &drawing,
599                history: History::empty(),
600                current_lock: InterfaceLock::Unlocked,
601                writability: blockworx_store::doc::Writability::Writable,
602                saving: blockworx_store::doc::Saving::Withheld,
603                viewing: blockworx_store::doc::Viewing::Head,
604            })
605        };
606        let path = BlockPath::empty();
607        let (blocks, level) = {
608            let indexed = scene.indexed();
609            (
610                nav_tree::all_blocks(&indexed),
611                nav_tree::level_blocks(&indexed, &path),
612            )
613        };
614        Fixture {
615            set,
616            scene,
617            blocks,
618            level,
619            repo: logged(),
620            tags: blockworx_store::tags::Tags::default(),
621        }
622    }
623
624    fn top_row_id(query: &str) -> RowId {
625        let mut fixture = block_selected_set();
626        let rows = fixture.rows(query);
627        assert!(
628            !rows.is_empty(),
629            "{query:?} should match at least one row of {} commands / {} blocks",
630            fixture.set.iter().count(),
631            fixture.blocks.len()
632        );
633        rows[0].id.clone()
634    }
635
636    /// An open, untouched palette must settle (see [`crate::canvas::settle`]).
637    /// A per-frame `scroll_to_me` once pinned the repaint delay at zero,
638    /// restarting a scroll animation every frame — the palette shimmered and
639    /// the app repainted continuously.
640    #[test]
641    fn an_idle_palette_settles() {
642        let mut palette = Palette::new();
643        let repo = logged();
644        let tags = blockworx_store::tags::Tags::default();
645        let mut scene = Scene::new(vec![
646            fx::block_in(
647                1,
648                Scope::Root,
649                Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 80.0)),
650            ),
651            fx::titled(1, "adder"),
652        ]);
653        let viewport = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1000.0, 800.0));
654        let settle = crate::canvas::settle::probe(30, |ui| {
655            let tool: Tool = crate::tools::SelectTool.into();
656            let path = BlockPath::empty();
657            let mut set = {
658                let drawing = scene.drawing();
659                CommandSet::available(&CommandContext {
660                    tool: &tool,
661                    data: &drawing,
662                    history: History::empty(),
663                    current_lock: InterfaceLock::Unlocked,
664                    writability: blockworx_store::doc::Writability::Writable,
665                    saving: blockworx_store::doc::Saving::Withheld,
666                    viewing: blockworx_store::doc::Viewing::Head,
667                })
668            };
669            let indexed = scene.indexed();
670            let revs = blockworx_store::history::rows(
671                blockworx_store::history::Journal::Session(repo.log()),
672                &tags,
673            );
674            let scope = PaletteScope {
675                document: &indexed,
676                path: &path,
677                revs: &revs,
678            };
679            let _ = palette.show(ui.ctx(), &mut set, scope, viewport);
680        });
681        crate::canvas::settle::assert_settles(&settle, 3);
682    }
683
684    #[test]
685    fn an_empty_query_lists_every_command_in_registry_order() {
686        let mut fixture = block_selected_set();
687        let rows = fixture.rows("");
688        let ids: Vec<RowId> = rows.iter().map(|r| r.id.clone()).collect();
689        let expected: Vec<RowId> = fixture.set.iter().map(|c| RowId::Command(c.id)).collect();
690        assert_eq!(ids, expected);
691    }
692
693    /// `camera x y w h` frames a rect typed in grid cells — the same numbers
694    /// the authoring footer prints for a level script.
695    #[test]
696    fn a_typed_camera_offers_a_row_and_rejects_nonsense() {
697        let mut fixture = block_selected_set();
698        let mut framed = |q: &str| {
699            fixture.rows(q).into_iter().find_map(|r| match r.id {
700                RowId::Camera(rect) => Some(rect),
701                _ => None,
702            })
703        };
704        assert_eq!(
705            framed("camera 0 0 28 20"),
706            Some(Rect::from_min_size(
707                crate::grid::grid_pos(0, 0),
708                crate::grid::grid_pos(28, 20).to_vec2(),
709            ))
710        );
711        // Negative origins are fine — the canvas extends both ways.
712        assert!(framed("camera -12 -4 10 10").is_some());
713        // A size that frames nothing, or so much that the diagram vanishes.
714        assert_eq!(framed("camera 0 0 0 20"), None, "zero width");
715        assert_eq!(framed("camera 0 0 28 -20"), None, "negative height");
716        assert_eq!(framed("camera 0 0 28 999999"), None, "absurd height");
717        // Half-typed or misspelled: no row rather than a wrong framing.
718        assert_eq!(framed("camera 0 0 28"), None, "too few values");
719        assert_eq!(framed("camera 0 0 28 20 4"), None, "too many values");
720        assert_eq!(framed("camera a b c d"), None, "not numbers");
721    }
722
723    /// `go <path>` parses whatever follows the verb, so a content path copied
724    /// off the canvas navigates straight back — and leads the ranking.
725    #[test]
726    fn a_typed_content_path_offers_a_go_row() {
727        let mut fixture = block_selected_set();
728        let expected = {
729            let indexed = fixture.scene.indexed();
730            crate::tools::content_path::parse(&indexed, "adder")
731                .expect("adder is a child of the root scope")
732        };
733        assert_eq!(
734            fixture.rows("go adder").first().map(|r| r.id.clone()),
735            Some(RowId::GoToPath(expected))
736        );
737        // A path that resolves to nothing offers no row to pick.
738        assert!(
739            !fixture
740                .rows("go nowhere")
741                .iter()
742                .any(|r| matches!(r.id, RowId::GoToPath(_)))
743        );
744    }
745
746    #[test]
747    fn typeable_names_rank_their_command_first() {
748        assert_eq!(top_row_id("fliplr"), RowId::Command(CommandId::FlipLr));
749        assert_eq!(top_row_id("lock"), RowId::Command(CommandId::Lock));
750        assert_eq!(
751            top_row_id("route"),
752            RowId::Command(CommandId::Arm(crate::tools::names::ToolName::Route))
753        );
754    }
755
756    #[test]
757    fn a_block_name_yields_a_find_row() {
758        let mut fixture = block_selected_set();
759        let matched = fixture.rows("adder");
760        assert!(
761            matched
762                .iter()
763                .any(|r| matches!(r.id, RowId::Block(_)) && r.text == "find adder"),
764            "no find row for the adder block"
765        );
766        // The word `find` itself reaches the block rows too.
767        let via_verb = fixture.rows("find add");
768        assert!(via_verb.iter().any(|r| matches!(r.id, RowId::Block(_))));
769    }
770
771    /// A block on the current level can be descended into, so it gets an
772    /// `expand` row; a block a level deeper is reachable by `find` only.
773    #[test]
774    fn only_blocks_on_the_current_level_get_an_expand_row() {
775        let mut fixture = block_selected_set();
776        // Precondition: `carry` is in the document but not on this level.
777        assert!(fixture.blocks.iter().any(|(_, l)| l == "carry"));
778        assert!(!fixture.level.iter().any(|(_, l)| l == "carry"));
779
780        let matched = fixture.rows("expand");
781        let expanded: Vec<&str> = matched
782            .iter()
783            .filter(|r| matches!(r.id, RowId::Expand(_)))
784            .map(|r| r.text.as_str())
785            .collect();
786        assert_eq!(expanded, ["expand adder"]);
787    }
788
789    /// A commit is a result like any other: found by its number or by the
790    /// words the history panel gives it, and picked to put that rev on the
791    /// canvas.
792    #[test]
793    fn a_rev_is_found_by_its_number_and_by_its_label() {
794        let mut fixture = block_selected_set();
795        let picked = |rows: Vec<Row>| {
796            rows.into_iter()
797                .find_map(|row| match row.id {
798                    RowId::Rev(rev) => Some(rev),
799                    _ => None,
800                })
801                .map(|rev| rev.get())
802        };
803        assert_eq!(picked(fixture.rows("rev 2")), Some(2));
804        assert_eq!(picked(fixture.rows("Moved the adder")), Some(2));
805        assert_eq!(
806            picked(fixture.rows("camera 0 0 8 8")),
807            None,
808            "a typed camera dragged the whole log in behind it",
809        );
810    }
811
812    /// §6: the results are grouped by source, and ranking orders the rows
813    /// inside each run rather than across the list. One query reaching three
814    /// sources proves both halves at once.
815    #[test]
816    fn results_are_listed_in_source_order() {
817        let mut fixture = block_selected_set();
818        let rows = fixture.rows("add");
819        let sources: Vec<Source> = rows.iter().map(|row| row.id.source()).collect();
820        for source in [Source::Command, Source::Block, Source::Rev] {
821            assert!(
822                sources.contains(&source),
823                "precondition: {source:?} answered nothing: {sources:?}",
824            );
825        }
826        assert!(
827            sources.is_sorted(),
828            "the sources are interleaved: {sources:?}",
829        );
830        let scores: Vec<u32> = rows
831            .iter()
832            .filter(|row| row.id.source() == Source::Command)
833            .map(|row| row.score)
834            .collect();
835        assert!(
836            scores.windows(2).all(|pair| pair[0] >= pair[1]),
837            "ranking stopped ordering the commands: {scores:?}",
838        );
839    }
840
841    /// The headings are drawn, in order, above the rows they name — through
842    /// real frames, so a heading that laid out but never painted fails here.
843    #[test]
844    fn every_run_of_rows_is_drawn_under_its_own_heading() {
845        let mut fixture = block_selected_set();
846        let mut palette = Palette::new();
847        palette.query = "add".to_owned();
848        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0));
849        let mut chrome = crate::tools::painted::Chrome::new(screen);
850        chrome.settle(|ui| {
851            // `show` finds the blocks itself; the fixture's own lists are
852            // for `rows`.
853            let Fixture {
854                set,
855                scene,
856                repo,
857                tags,
858                ..
859            } = &mut fixture;
860            let indexed = scene.indexed();
861            let revs = blockworx_store::history::rows(
862                blockworx_store::history::Journal::Session(repo.log()),
863                tags,
864            );
865            let here = BlockPath::empty();
866            let _ = palette.show(
867                ui.ctx(),
868                set,
869                PaletteScope {
870                    document: &indexed,
871                    path: &here,
872                    revs: &revs,
873                },
874                screen.egui(),
875            );
876        });
877        let mut headings = Vec::new();
878        for source in [Source::Command, Source::Block, Source::Rev] {
879            let at = chrome.rect(source.heading()).unwrap_or_else(|| {
880                panic!(
881                    "the palette never drew the {:?} heading: {:?}",
882                    source,
883                    chrome.texts(),
884                )
885            });
886            headings.push((source, at));
887        }
888        assert!(
889            headings
890                .windows(2)
891                .all(|pair| pair[0].1.top() < pair[1].1.top()),
892            "the headings are out of source order: {headings:?}",
893        );
894        let found = chrome
895            .rect("find adder")
896            .expect("the adder's find row never drew");
897        let (_, blocks) = headings[1];
898        let (_, revs) = headings[2];
899        assert!(
900            found.top() > blocks.top() && found.top() < revs.top(),
901            "the find row at {found:?} is not under the Blocks heading at {blocks:?}",
902        );
903    }
904}