authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 21:56:01-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 01:50:33-07:00
log464da86b45523af24588c5558b41fff36ed753b2
treebe9abf5ed79efff54914ced10e7f1af604f44fa2
parente173ad0c64864c7d5c3c1cfb7cc5e5c6fa931707
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: the palette's commands include every context menu action on what is shown

Clover: a notebook can be renamed from its menu but not from the keyboard. The palette's commands now list what the context menus of the page shown, its section, the group holding it, its notebook and the page's text at the caret do, named with what they act on (Rename Page, Section Color, Move Notebook Up, Copy Link to Paragraph), disabled where the menu disables them. Both read one list, so they can't drift apart; a command already doing the same, like New Page, stands for its action. Move and Section Color open their submenus beside the palette. A replay test checks each menu item has its command. Assisted-by: claude-opus-5.5

6 files changed, 439 insertions(+), 148 deletions(-)

crates/canvas/src/interaction/mod.rs+18-3
......@@ -1618,8 +1618,24 @@ impl PageView {
16181618 if !inside {
16191619 self.editor.select(at)?;
16201620 }
1621 let context = self.caret_context().unwrap_or_default();
1622 Ok(Some((self.changed()?, context)))
1623 }
1624
1625 /// What a context menu at the caret or the selected file acts on; `None` where neither
1626 /// takes one.
1627 pub fn caret_context(&self) -> Option<Context> {
1628 if let Some(ObjectFocus::File(id)) = self.object_focus {
1629 return Some(Context {
1630 attachment: self.editor.attachment(id).cloned(),
1631 ..Context::default()
1632 });
1633 }
1634 if self.object_focus.is_some() || !self.accepts_text() {
1635 return None;
1636 }
16211637 let [anchor, focus] = self.editor.selection().positions;
1622 let context = Context {
1638 Some(Context {
16231639 link: self
16241640 .editor
16251641 .link_at(anchor.min(focus))
......@@ -1634,8 +1650,7 @@ impl PageView {
16341650 .map(|(_, _, node)| node.id),
16351651 attachment: None,
16361652 spelling: self.selected_correction(),
1637 };
1638 Ok(Some((self.changed()?, context)))
1653 })
16391654 }
16401655
16411656 /// The marked word at the caret, or wholly selected, with its corrections.
crates/snowbound/src/link.rs+142-107
......@@ -3,6 +3,7 @@
33//! of the notebook or with the system's handler.
44
55use crate::{Command, State, art, commands, page, platform};
6use canvas::interaction::Context;
67use onestore::ExGuid;
78use onestore::page::link::{LinkTarget, internal_link};
89use std::error::Error;
......@@ -317,137 +318,171 @@ impl State {
317318 None => Vec::new(),
318319 };
319320 let corrections = items.len();
321 let mut rest = self.text_items(context);
322 if let Some((first, _)) = rest.first_mut() {
323 first.separated |= corrections > 0;
324 }
325 items.extend(rest.into_iter().map(|(item, _)| item));
326 let Some(chosen) =
327 ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None)
328 else {
329 return Ok(());
330 };
331 let chosen_text = items[chosen].text.to_owned();
332 let Some((context, _)) = self.text_menu.take() else {
333 return Ok(());
334 };
335 if chosen >= corrections {
336 return self.text_command(context, &chosen_text);
337 }
338 let correction = context.spelling.unwrap();
339 let response = match chosen_text.as_str() {
340 _ if chosen < correction.suggestions.len() => {
341 self.view.correct(&correction, &chosen_text)?
342 }
343 "Delete Repeated Word" => self.view.correct(&correction, "")?,
344 "Spelling" => {
345 self.open_spelling_pane();
346 return Ok(());
347 }
348 text => {
349 if let Some(spelling) = &self.view.spelling {
350 if text == "Ignore" {
351 spelling.ignore(&correction.word);
352 } else {
353 spelling.learn(&correction.word);
354 }
355 }
356 self.window.request_redraw();
357 return Ok(());
358 }
359 };
360 self.respond(response);
361 Ok(())
362 }
363
364 /// The page's context menu items after any corrections, for text and links at `context`
365 /// or the file it selects, each with how the palette's commands name it.
366 pub(crate) fn text_items(&self, context: &Context) -> Vec<(Item<'static>, &'static str)> {
367 let drawn = |text, icon| Item {
368 text,
369 icon: Some(icon),
370 ..Item::default()
371 };
320372 let format = self.format_state();
321373 let disabled = |id| !self.status(&commands::Choice::Command(id), &format).enabled;
322374 // Paste stands for any edit to the text here.
323375 let (fixed, unlinkable) = (disabled(commands::Id::Paste), disabled(commands::Id::Link));
324 let lists = [commands::Id::ToDoList, commands::Id::BulletedList]
325 .into_iter()
326 .filter(|id| {
327 let choice = commands::Choice::Command(*id);
328 self.status(&choice, &format).enabled
329 })
330 .collect::<Vec<_>>();
331 items.extend(if context.attachment.is_some() {
376 if context.attachment.is_some() {
332377 // OneNote 2010's commands for the file itself; its clipboard holds text alone.
333 vec![drawn("Open", art::OPEN), drawn("Save As", art::SAVE)]
334 } else {
335 let mut items = vec![
378 return vec![
379 (drawn("Open", art::OPEN), "Open File"),
380 (drawn("Save As", art::SAVE), "Save File As"),
381 ];
382 }
383 let mut items = vec![
384 Item {
385 disabled: !context.selected || fixed,
386 ..drawn("Cut", art::CUT)
387 },
388 Item {
389 disabled: !context.selected,
390 ..drawn("Copy", art::COPY)
391 },
392 Item {
393 disabled: fixed,
394 ..drawn("Paste", art::PASTE)
395 },
396 ];
397 match &context.link {
398 Some(_) => items.extend([
336399 Item {
337 disabled: !context.selected || fixed,
338 separated: corrections > 0,
339 ..drawn("Cut", art::CUT)
400 separated: true,
401 disabled: unlinkable,
402 ..drawn("Edit Link", art::LINK)
340403 },
404 drawn("Copy Link to Paragraph", art::COPY_LINK),
341405 Item {
342 disabled: !context.selected,
343 ..drawn("Copy", art::COPY)
406 separated: true,
407 ..drawn("Copy Link", art::COPY_LINK)
344408 },
409 drawn("Select Link", art::SELECT),
345410 Item {
346 disabled: fixed,
347 ..drawn("Paste", art::PASTE)
411 disabled: unlinkable,
412 ..drawn("Remove Link", art::REMOVE_LINK)
348413 },
349 ];
350 match &context.link {
351 Some(_) => items.extend([
352 Item {
353 separated: true,
354 disabled: unlinkable,
355 ..drawn("Edit Link", art::LINK)
356 },
357 drawn("Copy Link to Paragraph", art::COPY_LINK),
358 Item {
359 separated: true,
360 ..drawn("Copy Link", art::COPY_LINK)
361 },
362 drawn("Select Link", art::SELECT),
363 Item {
364 disabled: unlinkable,
365 ..drawn("Remove Link", art::REMOVE_LINK)
366 },
367 ]),
368 None => items.extend([
369 Item {
370 separated: true,
371 disabled: unlinkable,
372 ..drawn("Link", art::LINK)
414 ]),
415 None => items.extend([
416 Item {
417 separated: true,
418 disabled: unlinkable,
419 ..drawn("Link", art::LINK)
420 },
421 drawn("Copy Link to Paragraph", art::COPY_LINK),
422 ]),
423 }
424 let lists = [commands::Id::ToDoList, commands::Id::BulletedList];
425 for (at, id) in lists.into_iter().filter(|id| !disabled(*id)).enumerate() {
426 items.push(Item {
427 text: commands::command(id).title,
428 icon: crate::artwork(id),
429 separated: at == 0,
430 ..Item::default()
431 });
432 }
433 // Link names the command editing a link too.
434 let mut items: Vec<_> = (items.into_iter())
435 .map(|item| {
436 (
437 item,
438 if item.text == "Edit Link" {
439 "Link"
440 } else {
441 item.text
373442 },
374 drawn("Copy Link to Paragraph", art::COPY_LINK),
375 ]),
376 }
377 for (at, id) in lists.iter().enumerate() {
378 items.push(Item {
379 separated: at == 0,
380 icon: crate::artwork(*id),
381 ..item(commands::command(*id).title)
382 });
383 }
384 if context.equation {
385 items.extend([
443 )
444 })
445 .collect();
446 if context.equation {
447 items.extend([
448 (
386449 Item {
387450 separated: true,
388451 disabled: fixed,
389452 ..drawn("Professional", art::EQUATION)
390453 },
454 "Professional Equation",
455 ),
456 (
391457 Item {
392458 disabled: fixed,
393459 ..drawn("Linear", art::EQUATION)
394460 },
395 ]);
396 }
397 items
398 });
399 let Some(chosen) =
400 ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None)
401 else {
402 return Ok(());
403 };
404 let chosen_text = items[chosen].text.to_owned();
405 let Some((context, _)) = self.text_menu.take() else {
406 return Ok(());
407 };
408 if chosen < corrections {
409 let correction = context.spelling.unwrap();
410 let response = match chosen_text.as_str() {
411 _ if chosen < correction.suggestions.len() => {
412 self.view.correct(&correction, &chosen_text)?
413 }
414 "Delete Repeated Word" => self.view.correct(&correction, "")?,
415 "Spelling" => {
416 self.open_spelling_pane();
417 return Ok(());
418 }
419 text => {
420 if let Some(spelling) = &self.view.spelling {
421 if text == "Ignore" {
422 spelling.ignore(&correction.word);
423 } else {
424 spelling.learn(&correction.word);
425 }
426 }
427 self.window.request_redraw();
428 return Ok(());
429 }
461 "Linear Equation",
462 ),
463 ]);
464 }
465 items
466 }
467
468 /// Does what the page's context menu item `text` does at `context`.
469 pub(crate) fn text_command(
470 &mut self,
471 context: Context,
472 text: &str,
473 ) -> Result<(), Box<dyn Error>> {
474 if let Some(file) = &context.attachment {
475 return match text {
476 "Open" => self.open_attachment(file),
477 _ => self.save_attachment(file),
430478 };
431 self.respond(response);
432 return Ok(());
433479 }
434 if let Some(id) = lists
435 .into_iter()
436 .find(|id| commands::command(*id).title == chosen_text)
437 {
438 return self.run(commands::Choice::Command(id));
480 // Cut, Copy, Paste, Link and the list conversions are the commands of those names.
481 if let Some(command) = (commands::COMMANDS.iter()).find(|command| command.title == text) {
482 return self.run(commands::Choice::Command(command.id));
439483 }
440 let response = match chosen_text.as_str() {
441 "Open" => return self.open_attachment(&context.attachment.unwrap()),
442 "Save As" => return self.save_attachment(&context.attachment.unwrap()),
443 "Cut" => self.view.copy(true)?,
444 "Copy" => self.view.copy(false)?,
445 "Paste" => {
446 self.commands
447 .push(Command::Page(canvas::interaction::Request::Paste));
448 return Ok(());
449 }
450 "Edit Link" | "Link" => {
484 let response = match text {
485 "Edit Link" => {
451486 self.open_link_dialog();
452487 return Ok(());
453488 }
crates/snowbound/src/main.rs+7
......@@ -701,6 +701,8 @@ enum Command {
701701 },
702702 /// Deletes pages of the open section to the notebook's recycle bin.
703703 DeletePages(Vec<ExGuid>),
704 /// The page's context menu item of this text, at the caret or on the file selected.
705 Text(&'static str),
704706 /// Restores or deletes for good pages of the recycle bin's open section.
705707 Recycle(recycle::Request),
706708 /// Moves or indents pages of the open section.
......@@ -3438,6 +3440,11 @@ impl State {
34383440 Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?,
34393441 Command::Choose(choice) => self.run(choice)?,
34403442 Command::TagPicture => self.pick_tag_picture(),
3443 Command::Text(text) => {
3444 if let Some(context) = self.view.caret_context() {
3445 self.text_command(context, text)?;
3446 }
3447 }
34413448 }
34423449 Ok(())
34433450 }
crates/snowbound/src/menus.rs+117-35
......@@ -76,9 +76,28 @@ pub enum Target {
7676 Server(String),
7777 /// A page to add to the open section, by its title.
7878 NewPage(String),
79 /// The page's context menu at the caret or the file selected, by an item's text.
80 Text(&'static str),
7981 Command(commands::Id),
8082}
8183
84impl Target {
85 /// What it is, as a palette command acting on it names it.
86 fn noun(&self) -> &'static str {
87 match self {
88 Target::Page { .. } => "Page",
89 Target::Section { .. } => "Section",
90 Target::Group { .. } => "Section Group",
91 Target::Notebook(_) => "Notebook",
92 Target::Closed(_)
93 | Target::Server(_)
94 | Target::Command(_)
95 | Target::NewPage(_)
96 | Target::Text(_) => "",
97 }
98 }
99}
100
82101/// What can be done to a target, as its context menu and the palette's actions list it.
83102#[derive(Clone, Debug, PartialEq)]
84103pub enum Action {
......@@ -191,6 +210,56 @@ pub(crate) const SECTION_COLORS: [(u32, &str); 16] = [
191210 (0xaa9595, "Silver"),
192211];
193212
213/// Whether a command does `action` to `target` when it is the one shown, so the palette's
214/// commands need no row of their own for it.
215pub(crate) fn covered(target: &Target, action: &Action) -> bool {
216 use Action::*;
217 match target {
218 Target::Page { .. } => matches!(action, CopyLink | NewPage | NewSubpage | Theme),
219 Target::Section { .. } => matches!(
220 action,
221 SaveAs | NewSection | NewGroup | Password | Theme | EmptyRecycleBin
222 ),
223 Target::Group { .. } => matches!(action, NewSection | NewGroup),
224 Target::Notebook(_) => {
225 #[cfg(feature = "live")]
226 if *action == LiveShare {
227 return true;
228 }
229 matches!(
230 action,
231 SaveAs
232 | Close
233 | NewSection
234 | NewGroup
235 | MarkNotebookRead
236 | Reveal
237 | RecycleBin
238 | Theme
239 )
240 }
241 Target::Closed(_)
242 | Target::Server(_)
243 | Target::Command(_)
244 | Target::NewPage(_)
245 | Target::Text(_) => false,
246 }
247}
248
249/// How the palette's commands name `action` on `target`, shown in its context menu as
250/// `label`: with the target's noun where the label lacks it, as Rename becomes Rename Page.
251pub(crate) fn command_title(target: &Target, action: &Action, label: &str) -> String {
252 let noun = target.noun();
253 if label.to_lowercase().contains(&noun.to_lowercase()) {
254 return label.to_owned();
255 }
256 match label.split_once(' ') {
257 _ if *action == Action::Properties => format!("{noun} {label}"),
258 Some((verb, rest)) => format!("{verb} {noun} {rest}"),
259 None => format!("{label} {noun}"),
260 }
261}
262
194263/// A place Move offers.
195264struct Destination {
196265 name: String,
......@@ -533,7 +602,7 @@ impl State {
533602 Target::Closed(_) | Target::Server(_) => {
534603 vec![item(Action::Delete, "Remove from Recent", false, false)]
535604 }
536 Target::Command(_) | Target::NewPage(_) => Vec::new(),
605 Target::Command(_) | Target::NewPage(_) | Target::Text(_) => Vec::new(),
537606 }
538607 }
539608
......@@ -549,37 +618,53 @@ impl State {
549618 ) -> Option<Action> {
550619 let items: Vec<Item> = actions.iter().map(|(_, item)| *item).collect();
551620 let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter);
552 let places = id.child("move");
553 let copies = id.child("copy");
554 let colors = id.child("colors");
555 ui::popup::submenus(&mut self.ui, id, &items, |index| match actions[index].0 {
556 Action::Move => Some(places),
557 Action::Copy => Some(copies),
558 Action::Colors => Some(colors),
621 let submenu = |action: &Action| match action {
622 Action::Move => Some(id.child("move")),
623 Action::Copy => Some(id.child("copy")),
624 Action::Colors => Some(id.child("colors")),
559625 _ => None,
560 });
561 let mut moved = None;
562 for (submenu, chosen) in [
563 (places, Action::MoveTo as fn(String) -> Action),
564 (copies, Action::CopyTo),
565 ] {
566 if !self.ui.popup_open(submenu) {
567 continue;
626 };
627 ui::popup::submenus(&mut self.ui, id, &items, |index| submenu(&actions[index].0));
628 let mut chosen = chosen.map(|index| actions[index].0.clone());
629 for action in [Action::Move, Action::Copy, Action::Colors] {
630 if let Some(menu) = submenu(&action) {
631 chosen = chosen.or_else(|| self.submenu(menu, &action, target, anchor));
568632 }
569 let destinations = self.destinations(target);
570 let items: Vec<Item> = (destinations.iter())
571 .map(|place| Item {
572 text: &place.name,
573 icon: Some(place.icon),
574 tint: place.tint,
575 ..Item::default()
576 })
577 .collect();
578 moved = moved.or(ui::popup::menu(&mut self.ui, submenu, anchor, &items, None)
579 .map(|index| chosen(destinations[index].path.clone())));
580633 }
581 let colored = match target {
582 Target::Section { library, path } if self.ui.popup_open(colors) => {
634 chosen
635 }
636
637 /// Builds submenu `id` of `action`, Move, Copy or Colors, on `target` while it is open:
638 /// the place or colour chosen, as the action it takes.
639 pub(crate) fn submenu(
640 &mut self,
641 id: Id,
642 action: &Action,
643 target: &Target,
644 anchor: Anchor,
645 ) -> Option<Action> {
646 if !self.ui.popup_open(id) {
647 return None;
648 }
649 match (action, target) {
650 (Action::Move | Action::Copy, _) => {
651 let destinations = self.destinations(target);
652 let items: Vec<Item> = (destinations.iter())
653 .map(|place| Item {
654 text: &place.name,
655 icon: Some(place.icon),
656 tint: place.tint,
657 ..Item::default()
658 })
659 .collect();
660 let index = ui::popup::menu(&mut self.ui, id, anchor, &items, None)?;
661 let path = destinations[index].path.clone();
662 Some(match action {
663 Action::Move => Action::MoveTo(path),
664 _ => Action::CopyTo(path),
665 })
666 }
667 (Action::Colors, Target::Section { library, path }) => {
583668 let current = (library.tabs(&folder(path)).into_iter())
584669 .find(|tab| tab.path == *path)
585670 .and_then(|tab| tab.color);
......@@ -599,15 +684,11 @@ impl State {
599684 separated: true,
600685 ..Item::default()
601686 });
602 ui::popup::menu(&mut self.ui, colors, anchor, &items, None)
687 ui::popup::menu(&mut self.ui, id, anchor, &items, None)
603688 .map(|index| Action::Color(SECTION_COLORS.get(index).map(|(color, _)| *color)))
604689 }
605690 _ => None,
606 };
607 chosen
608 .map(|index| actions[index].0.clone())
609 .or(moved)
610 .or(colored)
691 }
611692 }
612693
613694 /// Where Move takes `target`: the other sections of a page's folder, or the folders a
......@@ -676,6 +757,7 @@ impl State {
676757 None
677758 }
678759 (Target::NewPage(title), _) => Some(Command::NewPage { under: None, title }),
760 (Target::Text(text), _) => Some(Command::Text(text)),
679761 (
680762 Target::Page {
681763 library,
crates/snowbound/src/palette.rs+87-3
......@@ -33,7 +33,11 @@ struct Row {
3333 repeated: bool,
3434 /// Offered only when nothing else matches the query.
3535 fallback: bool,
36 /// Opens a submenu of places or colours for `action`.
37 submenu: bool,
3638 target: Option<Target>,
39 /// What choosing it does to `target`, where not to run or open it.
40 action: Option<Action>,
3741}
3842
3943impl Row {
......@@ -55,6 +59,7 @@ impl Row {
5559 separated: self.target.is_none(),
5660 repeated: self.repeated,
5761 fallback: self.fallback,
62 submenu: self.submenu,
5863 ..Item::default()
5964 }
6065 }
......@@ -92,6 +97,35 @@ impl State {
9297 ..Row::default()
9398 }
9499 }));
100 // What a context menu does to the page shown, its section, group and notebook.
101 for target in self.shown() {
102 for (action, item) in self.actions(&target) {
103 if !menus::covered(&target, &action) {
104 commands.push(Row {
105 text: menus::command_title(&target, &action, item.text),
106 icon: item.icon,
107 disabled: item.disabled,
108 submenu: item.submenu,
109 target: Some(target.clone()),
110 action: Some(action),
111 ..Row::default()
112 });
113 }
114 }
115 }
116 // And what the page's context menu does at the caret, or to the file selected.
117 let text = self.view.caret_context();
118 for (item, title) in text.iter().flat_map(|context| self.text_items(context)) {
119 if !(commands::COMMANDS.iter()).any(|command| command.title == title) {
120 commands.push(Row {
121 text: title.to_owned(),
122 icon: item.icon,
123 disabled: item.disabled,
124 target: Some(Target::Text(item.text)),
125 ..Row::default()
126 });
127 }
128 }
95129 let mut places = self.recent();
96130 places.push(Row::heading("Notebooks"));
97131 places.extend(
......@@ -186,14 +220,29 @@ impl State {
186220 &[(COMMANDS, &items[0]), ("", &items[1])],
187221 "Search pages, sections and notebooks (type > for commands)",
188222 );
223 let submenu = |index: usize| id().child(("submenu", index));
224 ui::popup::submenus(&mut self.ui, id(), &items[0], |index| {
225 commands[index].submenu.then(|| submenu(index))
226 });
227 let anchor = Anchor::Right(self.ui.rect(id()).unwrap_or_default());
228 for (index, row) in commands.iter().enumerate() {
229 if let (Some(target), Some(action)) = (&row.target, &row.action)
230 && let Some(chosen) = self.submenu(submenu(index), action, target, anchor)
231 {
232 self.ui.close_popup(id());
233 self.act_on(target.clone(), chosen);
234 return;
235 }
236 }
189237 let mut rows = [commands, places];
190238 match picked {
191239 Some(Pick::Run(mode, index)) => {
192 if let Some(target) = rows[mode].swap_remove(index).target {
193 let action = match target {
240 let row = rows[mode].swap_remove(index);
241 if let Some(target) = row.target {
242 let action = row.action.unwrap_or(match target {
194243 Target::Command(_) => Action::Run,
195244 _ => Action::Open,
196 };
245 });
197246 self.act_on(target, action);
198247 }
199248 }
......@@ -267,6 +316,41 @@ impl State {
267316 }
268317 }
269318
319 /// The page shown, its section, the group holding that and its notebook, which the
320 /// palette's commands act on.
321 fn shown(&self) -> Vec<Target> {
322 let Some(session) = &self.session else {
323 return self
324 .notebook()
325 .map(Arc::clone)
326 .map(Target::Notebook)
327 .into_iter()
328 .collect();
329 };
330 let library = &session.library;
331 let path = session.tabs[session.tab].path.clone();
332 let group = menus::folder(&path);
333 let mut shown = vec![
334 Target::Page {
335 library: Arc::clone(library),
336 path: path.clone(),
337 space: session.space,
338 },
339 Target::Section {
340 library: Arc::clone(library),
341 path,
342 },
343 ];
344 if !group.is_empty() && !crate::recycle::binned(&group) {
345 shown.push(Target::Group {
346 library: Arc::clone(library),
347 path: group,
348 });
349 }
350 shown.push(Target::Notebook(Arc::clone(library)));
351 shown
352 }
353
270354 /// Under a heading, the pages shown lately but the one shown, their sections but the one
271355 /// open, the notebooks of those since closed, and the servers saved to reconnect to;
272356 /// latest first, and none before any. Pages and sections since gone are forgotten.
crates/snowbound/tests/replay.rs+68
......@@ -226,6 +226,74 @@ fn the_palette_makes_a_page_of_a_query_matching_nothing() {
226226 );
227227}
228228
229/// The options listed in `tree`'s palette, one a line.
230fn options(tree: &str) -> Vec<&str> {
231 tree.lines()
232 .filter(|line| line.contains("ListBoxOption"))
233 .map(|line| line.split('"').nth(1).unwrap_or_default())
234 .collect()
235}
236
237/// Every item of the context menus of the page's text, the page, the section and the notebook
238/// is a command in the palette: one of the same name, or one naming what it acts on as well.
239#[test]
240fn every_context_menu_action_is_a_palette_command() {
241 let scratch = Scratch::new("palette-commands");
242 let notebook =
243 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
244 let menu = |at: &'static str, name: &'static str| {
245 [
246 at,
247 "press right",
248 "release right",
249 "settle",
250 name,
251 "key Escape",
252 ]
253 };
254 let mut steps = Vec::from(menu("move 200 106", "accessibility text"));
255 steps.extend(menu("move 1000 87", "accessibility page"));
256 steps.extend(menu("move 109 50", "accessibility section"));
257 steps.extend(["modifiers command", "key \\", "modifiers", "wait 500"]);
258 steps.extend(menu("move 60 76", "accessibility notebook"));
259 let menus = replay(&scratch, Some(&notebook), &steps);
260 let mut labels = Vec::new();
261 for (noun, tree) in ["", "page", "section", "notebook"].into_iter().zip(&menus) {
262 let items = menu_items(tree);
263 assert!(items.len() > 4, "{tree}");
264 // The sync status heads the notebook's.
265 let skip = usize::from(noun == "notebook");
266 labels.extend(items[skip..].iter().map(|label| (noun, label.to_string())));
267 }
268 let mut steps = Vec::new();
269 for (at, (_, label)) in labels.iter().enumerate() {
270 steps.extend(["modifiers command", "key p", "modifiers", "settle"].map(String::from));
271 steps.extend([format!("type >{label}"), "settle".into()]);
272 steps.extend([format!("accessibility {at}"), "key Escape".into()]);
273 }
274 let steps: Vec<_> = steps.iter().map(String::as_str).collect();
275 let scratch = Scratch::new("palette-commands-found");
276 let found = replay(&scratch, Some(&notebook), &steps);
277 for ((noun, label), tree) in labels.iter().zip(&found) {
278 let words = |text: &str| {
279 text.to_lowercase()
280 .split(' ')
281 .map(String::from)
282 .collect::<Vec<_>>()
283 };
284 let wanted = words(label);
285 assert!(
286 options(tree).iter().any(|option| {
287 let have = words(option);
288 *option == label
289 || wanted.iter().all(|word| have.contains(word))
290 && option.to_lowercase().contains(noun)
291 }),
292 "the {noun}'s {label:?} has no palette command:\n{tree}"
293 );
294 }
295}
296
229297/// The page tabs listed in `tree`, the selected one marked with `*`.
230298fn page_tabs(tree: &str) -> Vec<String> {
231299 tree.lines()