1use 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
32const GO_VERB: &str = "go ";
34
35const CAMERA_VERB: &str = "camera ";
37
38const CAMERA_MIN_CELLS: i32 = 1;
42const CAMERA_MAX_CELLS: i32 = 5_000;
43
44pub const MAX_MATCHES: usize = 12;
47
48#[derive(Clone, Copy)]
52pub struct Sources<'a> {
53 pub tree: &'a NavTree,
54 pub revs: &'a [history::Row],
55}
56
57#[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#[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#[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 #[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
151pub struct Row {
153 pub id: RowId,
154 pub text: String,
156 pub name: Option<&'static str>,
158 pub score: u32,
159}
160
161#[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 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 rows.sort_by_key(|row| row.id.source());
260 rows
261}
262
263#[must_use]
267pub fn rev_row(row: &history::Row) -> String {
268 format!("rev {} \u{2014} {}", row.rev.get(), row.label)
269}
270
271fn 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 fn tree_of(indexed: &blockworx_doc::document::IndexedDocument<'_>) -> NavTree {
309 NavTree::of(indexed, BlockPath::empty(), Vec::new())
310 }
311
312 struct Fixture {
314 set: CommandSet,
315 scene: Scene,
316 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 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 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 #[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 assert!(framed("camera -12 -4 10 10").is_some());
450 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 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 #[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 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 let via_verb = fixture.rows("find add");
503 assert!(via_verb.iter().any(|row| matches!(row.id, RowId::Block(_))));
504 }
505
506 #[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 #[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 #[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}