From 464da86b45523af24588c5558b41fff36ed753b2 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 21:56:01 -0700 Subject: [PATCH] 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 --- crates/canvas/src/interaction/mod.rs | 21 ++- crates/snowbound/src/link.rs | 259 +++++++++++++++------------ crates/snowbound/src/main.rs | 7 + crates/snowbound/src/menus.rs | 152 ++++++++++++---- crates/snowbound/src/palette.rs | 90 +++++++++- crates/snowbound/tests/replay.rs | 68 +++++++ 6 files changed, 444 insertions(+), 153 deletions(-) diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 5afddd36b596314f300da9a485b8565120a49b9a..f522e8be9fca54f3151fdd308f0175b5d4d32f71 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -1618,8 +1618,24 @@ impl PageView { if !inside { self.editor.select(at)?; } + let context = self.caret_context().unwrap_or_default(); + Ok(Some((self.changed()?, context))) + } + + /// What a context menu at the caret or the selected file acts on; `None` where neither + /// takes one. + pub fn caret_context(&self) -> Option { + if let Some(ObjectFocus::File(id)) = self.object_focus { + return Some(Context { + attachment: self.editor.attachment(id).cloned(), + ..Context::default() + }); + } + if self.object_focus.is_some() || !self.accepts_text() { + return None; + } let [anchor, focus] = self.editor.selection().positions; - let context = Context { + Some(Context { link: self .editor .link_at(anchor.min(focus)) @@ -1634,8 +1650,7 @@ impl PageView { .map(|(_, _, node)| node.id), attachment: None, spelling: self.selected_correction(), - }; - Ok(Some((self.changed()?, context))) + }) } /// The marked word at the caret, or wholly selected, with its corrections. diff --git a/crates/snowbound/src/link.rs b/crates/snowbound/src/link.rs index 8dcf2462f800259e6e9d97670f40450b8a89804b..283cf68b3236db587ecb5efb90862cc649f25ef9 100644 --- a/crates/snowbound/src/link.rs +++ b/crates/snowbound/src/link.rs @@ -3,6 +3,7 @@ //! of the notebook or with the system's handler. use crate::{Command, State, art, commands, page, platform}; +use canvas::interaction::Context; use onestore::ExGuid; use onestore::page::link::{LinkTarget, internal_link}; use std::error::Error; @@ -317,137 +318,171 @@ impl State { None => Vec::new(), }; let corrections = items.len(); + let mut rest = self.text_items(context); + if let Some((first, _)) = rest.first_mut() { + first.separated |= corrections > 0; + } + items.extend(rest.into_iter().map(|(item, _)| item)); + let Some(chosen) = + ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None) + else { + return Ok(()); + }; + let chosen_text = items[chosen].text.to_owned(); + let Some((context, _)) = self.text_menu.take() else { + return Ok(()); + }; + if chosen >= corrections { + return self.text_command(context, &chosen_text); + } + let correction = context.spelling.unwrap(); + let response = match chosen_text.as_str() { + _ if chosen < correction.suggestions.len() => { + self.view.correct(&correction, &chosen_text)? + } + "Delete Repeated Word" => self.view.correct(&correction, "")?, + "Spelling" => { + self.open_spelling_pane(); + return Ok(()); + } + text => { + if let Some(spelling) = &self.view.spelling { + if text == "Ignore" { + spelling.ignore(&correction.word); + } else { + spelling.learn(&correction.word); + } + } + self.window.request_redraw(); + return Ok(()); + } + }; + self.respond(response); + Ok(()) + } + + /// The page's context menu items after any corrections, for text and links at `context` + /// or the file it selects, each with how the palette's commands name it. + pub(crate) fn text_items(&self, context: &Context) -> Vec<(Item<'static>, &'static str)> { + let drawn = |text, icon| Item { + text, + icon: Some(icon), + ..Item::default() + }; let format = self.format_state(); let disabled = |id| !self.status(&commands::Choice::Command(id), &format).enabled; // Paste stands for any edit to the text here. let (fixed, unlinkable) = (disabled(commands::Id::Paste), disabled(commands::Id::Link)); - let lists = [commands::Id::ToDoList, commands::Id::BulletedList] - .into_iter() - .filter(|id| { - let choice = commands::Choice::Command(*id); - self.status(&choice, &format).enabled - }) - .collect::>(); - items.extend(if context.attachment.is_some() { + if context.attachment.is_some() { // OneNote 2010's commands for the file itself; its clipboard holds text alone. - vec![drawn("Open", art::OPEN), drawn("Save As", art::SAVE)] - } else { - let mut items = vec![ - Item { - disabled: !context.selected || fixed, - separated: corrections > 0, - ..drawn("Cut", art::CUT) - }, - Item { - disabled: !context.selected, - ..drawn("Copy", art::COPY) - }, - Item { - disabled: fixed, - ..drawn("Paste", art::PASTE) - }, + return vec![ + (drawn("Open", art::OPEN), "Open File"), + (drawn("Save As", art::SAVE), "Save File As"), ]; - match &context.link { - Some(_) => items.extend([ - Item { - separated: true, - disabled: unlinkable, - ..drawn("Edit Link", art::LINK) + } + let mut items = vec![ + Item { + disabled: !context.selected || fixed, + ..drawn("Cut", art::CUT) + }, + Item { + disabled: !context.selected, + ..drawn("Copy", art::COPY) + }, + Item { + disabled: fixed, + ..drawn("Paste", art::PASTE) + }, + ]; + match &context.link { + Some(_) => items.extend([ + Item { + separated: true, + disabled: unlinkable, + ..drawn("Edit Link", art::LINK) + }, + drawn("Copy Link to Paragraph", art::COPY_LINK), + Item { + separated: true, + ..drawn("Copy Link", art::COPY_LINK) + }, + drawn("Select Link", art::SELECT), + Item { + disabled: unlinkable, + ..drawn("Remove Link", art::REMOVE_LINK) + }, + ]), + None => items.extend([ + Item { + separated: true, + disabled: unlinkable, + ..drawn("Link", art::LINK) + }, + drawn("Copy Link to Paragraph", art::COPY_LINK), + ]), + } + let lists = [commands::Id::ToDoList, commands::Id::BulletedList]; + for (at, id) in lists.into_iter().filter(|id| !disabled(*id)).enumerate() { + items.push(Item { + text: commands::command(id).title, + icon: crate::artwork(id), + separated: at == 0, + ..Item::default() + }); + } + // Link names the command editing a link too. + let mut items: Vec<_> = (items.into_iter()) + .map(|item| { + ( + item, + if item.text == "Edit Link" { + "Link" + } else { + item.text }, - drawn("Copy Link to Paragraph", art::COPY_LINK), - Item { - separated: true, - ..drawn("Copy Link", art::COPY_LINK) - }, - drawn("Select Link", art::SELECT), - Item { - disabled: unlinkable, - ..drawn("Remove Link", art::REMOVE_LINK) - }, - ]), - None => items.extend([ - Item { - separated: true, - disabled: unlinkable, - ..drawn("Link", art::LINK) - }, - drawn("Copy Link to Paragraph", art::COPY_LINK), - ]), - } - for (at, id) in lists.iter().enumerate() { - items.push(Item { - separated: at == 0, - icon: crate::artwork(*id), - ..item(commands::command(*id).title) - }); - } - if context.equation { - items.extend([ + ) + }) + .collect(); + if context.equation { + items.extend([ + ( Item { separated: true, disabled: fixed, ..drawn("Professional", art::EQUATION) }, + "Professional Equation", + ), + ( Item { disabled: fixed, ..drawn("Linear", art::EQUATION) }, - ]); - } - items - }); - let Some(chosen) = - ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None) - else { - return Ok(()); - }; - let chosen_text = items[chosen].text.to_owned(); - let Some((context, _)) = self.text_menu.take() else { - return Ok(()); - }; - if chosen < corrections { - let correction = context.spelling.unwrap(); - let response = match chosen_text.as_str() { - _ if chosen < correction.suggestions.len() => { - self.view.correct(&correction, &chosen_text)? - } - "Delete Repeated Word" => self.view.correct(&correction, "")?, - "Spelling" => { - self.open_spelling_pane(); - return Ok(()); - } - text => { - if let Some(spelling) = &self.view.spelling { - if text == "Ignore" { - spelling.ignore(&correction.word); - } else { - spelling.learn(&correction.word); - } - } - self.window.request_redraw(); - return Ok(()); - } + "Linear Equation", + ), + ]); + } + items + } + + /// Does what the page's context menu item `text` does at `context`. + pub(crate) fn text_command( + &mut self, + context: Context, + text: &str, + ) -> Result<(), Box> { + if let Some(file) = &context.attachment { + return match text { + "Open" => self.open_attachment(file), + _ => self.save_attachment(file), }; - self.respond(response); - return Ok(()); } - if let Some(id) = lists - .into_iter() - .find(|id| commands::command(*id).title == chosen_text) - { - return self.run(commands::Choice::Command(id)); + // Cut, Copy, Paste, Link and the list conversions are the commands of those names. + if let Some(command) = (commands::COMMANDS.iter()).find(|command| command.title == text) { + return self.run(commands::Choice::Command(command.id)); } - let response = match chosen_text.as_str() { - "Open" => return self.open_attachment(&context.attachment.unwrap()), - "Save As" => return self.save_attachment(&context.attachment.unwrap()), - "Cut" => self.view.copy(true)?, - "Copy" => self.view.copy(false)?, - "Paste" => { - self.commands - .push(Command::Page(canvas::interaction::Request::Paste)); - return Ok(()); - } - "Edit Link" | "Link" => { + let response = match text { + "Edit Link" => { self.open_link_dialog(); return Ok(()); } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 54771122beb4f0d7f996990bc4842e69f816f318..7d8de1ba75ebea9f3bbdfaf9c56c0ee4716ad693 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -701,6 +701,8 @@ enum Command { }, /// Deletes pages of the open section to the notebook's recycle bin. DeletePages(Vec), + /// The page's context menu item of this text, at the caret or on the file selected. + Text(&'static str), /// Restores or deletes for good pages of the recycle bin's open section. Recycle(recycle::Request), /// Moves or indents pages of the open section. @@ -3438,6 +3440,11 @@ impl State { Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?, Command::Choose(choice) => self.run(choice)?, Command::TagPicture => self.pick_tag_picture(), + Command::Text(text) => { + if let Some(context) = self.view.caret_context() { + self.text_command(context, text)?; + } + } } Ok(()) } diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 2a7be31cec4d65b9ec1d573f5328f02c23068ce7..e432c9cd934ade9c34ac4222dccc2f89b4e228f8 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -76,9 +76,28 @@ pub enum Target { Server(String), /// A page to add to the open section, by its title. NewPage(String), + /// The page's context menu at the caret or the file selected, by an item's text. + Text(&'static str), Command(commands::Id), } +impl Target { + /// What it is, as a palette command acting on it names it. + fn noun(&self) -> &'static str { + match self { + Target::Page { .. } => "Page", + Target::Section { .. } => "Section", + Target::Group { .. } => "Section Group", + Target::Notebook(_) => "Notebook", + Target::Closed(_) + | Target::Server(_) + | Target::Command(_) + | Target::NewPage(_) + | Target::Text(_) => "", + } + } +} + /// What can be done to a target, as its context menu and the palette's actions list it. #[derive(Clone, Debug, PartialEq)] pub enum Action { @@ -191,6 +210,56 @@ pub(crate) const SECTION_COLORS: [(u32, &str); 16] = [ (0xaa9595, "Silver"), ]; +/// Whether a command does `action` to `target` when it is the one shown, so the palette's +/// commands need no row of their own for it. +pub(crate) fn covered(target: &Target, action: &Action) -> bool { + use Action::*; + match target { + Target::Page { .. } => matches!(action, CopyLink | NewPage | NewSubpage | Theme), + Target::Section { .. } => matches!( + action, + SaveAs | NewSection | NewGroup | Password | Theme | EmptyRecycleBin + ), + Target::Group { .. } => matches!(action, NewSection | NewGroup), + Target::Notebook(_) => { + #[cfg(feature = "live")] + if *action == LiveShare { + return true; + } + matches!( + action, + SaveAs + | Close + | NewSection + | NewGroup + | MarkNotebookRead + | Reveal + | RecycleBin + | Theme + ) + } + Target::Closed(_) + | Target::Server(_) + | Target::Command(_) + | Target::NewPage(_) + | Target::Text(_) => false, + } +} + +/// How the palette's commands name `action` on `target`, shown in its context menu as +/// `label`: with the target's noun where the label lacks it, as Rename becomes Rename Page. +pub(crate) fn command_title(target: &Target, action: &Action, label: &str) -> String { + let noun = target.noun(); + if label.to_lowercase().contains(&noun.to_lowercase()) { + return label.to_owned(); + } + match label.split_once(' ') { + _ if *action == Action::Properties => format!("{noun} {label}"), + Some((verb, rest)) => format!("{verb} {noun} {rest}"), + None => format!("{label} {noun}"), + } +} + /// A place Move offers. struct Destination { name: String, @@ -533,7 +602,7 @@ impl State { Target::Closed(_) | Target::Server(_) => { vec![item(Action::Delete, "Remove from Recent", false, false)] } - Target::Command(_) | Target::NewPage(_) => Vec::new(), + Target::Command(_) | Target::NewPage(_) | Target::Text(_) => Vec::new(), } } @@ -549,37 +618,53 @@ impl State { ) -> Option { let items: Vec = actions.iter().map(|(_, item)| *item).collect(); let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter); - let places = id.child("move"); - let copies = id.child("copy"); - let colors = id.child("colors"); - ui::popup::submenus(&mut self.ui, id, &items, |index| match actions[index].0 { - Action::Move => Some(places), - Action::Copy => Some(copies), - Action::Colors => Some(colors), + let submenu = |action: &Action| match action { + Action::Move => Some(id.child("move")), + Action::Copy => Some(id.child("copy")), + Action::Colors => Some(id.child("colors")), _ => None, - }); - let mut moved = None; - for (submenu, chosen) in [ - (places, Action::MoveTo as fn(String) -> Action), - (copies, Action::CopyTo), - ] { - if !self.ui.popup_open(submenu) { - continue; + }; + ui::popup::submenus(&mut self.ui, id, &items, |index| submenu(&actions[index].0)); + let mut chosen = chosen.map(|index| actions[index].0.clone()); + for action in [Action::Move, Action::Copy, Action::Colors] { + if let Some(menu) = submenu(&action) { + chosen = chosen.or_else(|| self.submenu(menu, &action, target, anchor)); } - let destinations = self.destinations(target); - let items: Vec = (destinations.iter()) - .map(|place| Item { - text: &place.name, - icon: Some(place.icon), - tint: place.tint, - ..Item::default() + } + chosen + } + + /// Builds submenu `id` of `action`, Move, Copy or Colors, on `target` while it is open: + /// the place or colour chosen, as the action it takes. + pub(crate) fn submenu( + &mut self, + id: Id, + action: &Action, + target: &Target, + anchor: Anchor, + ) -> Option { + if !self.ui.popup_open(id) { + return None; + } + match (action, target) { + (Action::Move | Action::Copy, _) => { + let destinations = self.destinations(target); + let items: Vec = (destinations.iter()) + .map(|place| Item { + text: &place.name, + icon: Some(place.icon), + tint: place.tint, + ..Item::default() + }) + .collect(); + let index = ui::popup::menu(&mut self.ui, id, anchor, &items, None)?; + let path = destinations[index].path.clone(); + Some(match action { + Action::Move => Action::MoveTo(path), + _ => Action::CopyTo(path), }) - .collect(); - moved = moved.or(ui::popup::menu(&mut self.ui, submenu, anchor, &items, None) - .map(|index| chosen(destinations[index].path.clone()))); - } - let colored = match target { - Target::Section { library, path } if self.ui.popup_open(colors) => { + } + (Action::Colors, Target::Section { library, path }) => { let current = (library.tabs(&folder(path)).into_iter()) .find(|tab| tab.path == *path) .and_then(|tab| tab.color); @@ -599,15 +684,11 @@ impl State { separated: true, ..Item::default() }); - ui::popup::menu(&mut self.ui, colors, anchor, &items, None) + ui::popup::menu(&mut self.ui, id, anchor, &items, None) .map(|index| Action::Color(SECTION_COLORS.get(index).map(|(color, _)| *color))) } _ => None, - }; - chosen - .map(|index| actions[index].0.clone()) - .or(moved) - .or(colored) + } } /// Where Move takes `target`: the other sections of a page's folder, or the folders a @@ -676,6 +757,7 @@ impl State { None } (Target::NewPage(title), _) => Some(Command::NewPage { under: None, title }), + (Target::Text(text), _) => Some(Command::Text(text)), ( Target::Page { library, diff --git a/crates/snowbound/src/palette.rs b/crates/snowbound/src/palette.rs index 7eb3d5e4db1103fce94e1bc7b9e6bcbb0e8657c6..b69cdd93b722114924fd42911763c241c999428b 100644 --- a/crates/snowbound/src/palette.rs +++ b/crates/snowbound/src/palette.rs @@ -33,7 +33,11 @@ struct Row { repeated: bool, /// Offered only when nothing else matches the query. fallback: bool, + /// Opens a submenu of places or colours for `action`. + submenu: bool, target: Option, + /// What choosing it does to `target`, where not to run or open it. + action: Option, } impl Row { @@ -55,6 +59,7 @@ impl Row { separated: self.target.is_none(), repeated: self.repeated, fallback: self.fallback, + submenu: self.submenu, ..Item::default() } } @@ -92,6 +97,35 @@ impl State { ..Row::default() } })); + // What a context menu does to the page shown, its section, group and notebook. + for target in self.shown() { + for (action, item) in self.actions(&target) { + if !menus::covered(&target, &action) { + commands.push(Row { + text: menus::command_title(&target, &action, item.text), + icon: item.icon, + disabled: item.disabled, + submenu: item.submenu, + target: Some(target.clone()), + action: Some(action), + ..Row::default() + }); + } + } + } + // And what the page's context menu does at the caret, or to the file selected. + let text = self.view.caret_context(); + for (item, title) in text.iter().flat_map(|context| self.text_items(context)) { + if !(commands::COMMANDS.iter()).any(|command| command.title == title) { + commands.push(Row { + text: title.to_owned(), + icon: item.icon, + disabled: item.disabled, + target: Some(Target::Text(item.text)), + ..Row::default() + }); + } + } let mut places = self.recent(); places.push(Row::heading("Notebooks")); places.extend( @@ -186,14 +220,29 @@ impl State { &[(COMMANDS, &items[0]), ("", &items[1])], "Search pages, sections and notebooks (type > for commands)", ); + let submenu = |index: usize| id().child(("submenu", index)); + ui::popup::submenus(&mut self.ui, id(), &items[0], |index| { + commands[index].submenu.then(|| submenu(index)) + }); + let anchor = Anchor::Right(self.ui.rect(id()).unwrap_or_default()); + for (index, row) in commands.iter().enumerate() { + if let (Some(target), Some(action)) = (&row.target, &row.action) + && let Some(chosen) = self.submenu(submenu(index), action, target, anchor) + { + self.ui.close_popup(id()); + self.act_on(target.clone(), chosen); + return; + } + } let mut rows = [commands, places]; match picked { Some(Pick::Run(mode, index)) => { - if let Some(target) = rows[mode].swap_remove(index).target { - let action = match target { + let row = rows[mode].swap_remove(index); + if let Some(target) = row.target { + let action = row.action.unwrap_or(match target { Target::Command(_) => Action::Run, _ => Action::Open, - }; + }); self.act_on(target, action); } } @@ -267,6 +316,41 @@ impl State { } } + /// The page shown, its section, the group holding that and its notebook, which the + /// palette's commands act on. + fn shown(&self) -> Vec { + let Some(session) = &self.session else { + return self + .notebook() + .map(Arc::clone) + .map(Target::Notebook) + .into_iter() + .collect(); + }; + let library = &session.library; + let path = session.tabs[session.tab].path.clone(); + let group = menus::folder(&path); + let mut shown = vec![ + Target::Page { + library: Arc::clone(library), + path: path.clone(), + space: session.space, + }, + Target::Section { + library: Arc::clone(library), + path, + }, + ]; + if !group.is_empty() && !crate::recycle::binned(&group) { + shown.push(Target::Group { + library: Arc::clone(library), + path: group, + }); + } + shown.push(Target::Notebook(Arc::clone(library))); + shown + } + /// Under a heading, the pages shown lately but the one shown, their sections but the one /// open, the notebooks of those since closed, and the servers saved to reconnect to; /// latest first, and none before any. Pages and sections since gone are forgotten. diff --git a/crates/snowbound/tests/replay.rs b/crates/snowbound/tests/replay.rs index d132f1ea2c772ea49594b822ed315b4f9a98e534..d8e7c73477d48a44d23318a0a4847e189b58be22 100644 --- a/crates/snowbound/tests/replay.rs +++ b/crates/snowbound/tests/replay.rs @@ -226,6 +226,74 @@ fn the_palette_makes_a_page_of_a_query_matching_nothing() { ); } +/// The options listed in `tree`'s palette, one a line. +fn options(tree: &str) -> Vec<&str> { + tree.lines() + .filter(|line| line.contains("ListBoxOption")) + .map(|line| line.split('"').nth(1).unwrap_or_default()) + .collect() +} + +/// Every item of the context menus of the page's text, the page, the section and the notebook +/// is a command in the palette: one of the same name, or one naming what it acts on as well. +#[test] +fn every_context_menu_action_is_a_palette_command() { + let scratch = Scratch::new("palette-commands"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); + let menu = |at: &'static str, name: &'static str| { + [ + at, + "press right", + "release right", + "settle", + name, + "key Escape", + ] + }; + let mut steps = Vec::from(menu("move 200 106", "accessibility text")); + steps.extend(menu("move 1000 87", "accessibility page")); + steps.extend(menu("move 109 50", "accessibility section")); + steps.extend(["modifiers command", "key \\", "modifiers", "wait 500"]); + steps.extend(menu("move 60 76", "accessibility notebook")); + let menus = replay(&scratch, Some(¬ebook), &steps); + let mut labels = Vec::new(); + for (noun, tree) in ["", "page", "section", "notebook"].into_iter().zip(&menus) { + let items = menu_items(tree); + assert!(items.len() > 4, "{tree}"); + // The sync status heads the notebook's. + let skip = usize::from(noun == "notebook"); + labels.extend(items[skip..].iter().map(|label| (noun, label.to_string()))); + } + let mut steps = Vec::new(); + for (at, (_, label)) in labels.iter().enumerate() { + steps.extend(["modifiers command", "key p", "modifiers", "settle"].map(String::from)); + steps.extend([format!("type >{label}"), "settle".into()]); + steps.extend([format!("accessibility {at}"), "key Escape".into()]); + } + let steps: Vec<_> = steps.iter().map(String::as_str).collect(); + let scratch = Scratch::new("palette-commands-found"); + let found = replay(&scratch, Some(¬ebook), &steps); + for ((noun, label), tree) in labels.iter().zip(&found) { + let words = |text: &str| { + text.to_lowercase() + .split(' ') + .map(String::from) + .collect::>() + }; + let wanted = words(label); + assert!( + options(tree).iter().any(|option| { + let have = words(option); + *option == label + || wanted.iter().all(|word| have.contains(word)) + && option.to_lowercase().contains(noun) + }), + "the {noun}'s {label:?} has no palette command:\n{tree}" + ); + } +} + /// The page tabs listed in `tree`, the selected one marked with `*`. fn page_tabs(tree: &str) -> Vec { tree.lines() -- 2.54.0