1use 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
33const GO_VERB: &str = "go ";
35
36const CAMERA_VERB: &str = "camera ";
38
39const CAMERA_MIN_CELLS: i32 = 1;
43const CAMERA_MAX_CELLS: i32 = 5_000;
44
45fn 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;
70const MAX_MATCHES: usize = 12;
73
74#[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
84pub struct Palette {
86 query: String,
87 selected: usize,
88}
89
90pub enum PaletteOutcome {
93 Open,
95 Close,
97 Dispatch(Box<Action>),
99}
100
101#[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#[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 text: String,
156 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 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 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 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
302fn 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 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 rows.sort_by_key(|row| row.id.source());
406 rows
407}
408
409fn 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 #[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 type Labeled = Vec<(BlockId, String)>;
512
513 struct Fixture {
515 set: CommandSet,
516 scene: Scene,
517 blocks: Labeled,
518 level: Labeled,
519 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 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 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 #[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 #[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 assert!(framed("camera -12 -4 10 10").is_some());
713 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 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 #[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 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 let via_verb = fixture.rows("find add");
768 assert!(via_verb.iter().any(|r| matches!(r.id, RowId::Block(_))));
769 }
770
771 #[test]
774 fn only_blocks_on_the_current_level_get_an_expand_row() {
775 let mut fixture = block_selected_set();
776 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 #[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 #[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 #[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 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}