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 {...@@ -1618,8 +1618,24 @@ impl PageView {
1618 if !inside {1618 if !inside {
1619 self.editor.select(at)?;1619 self.editor.select(at)?;
1620 }1620 }
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 }
1621 let [anchor, focus] = self.editor.selection().positions;1637 let [anchor, focus] = self.editor.selection().positions;
1622 let context = Context {1638 Some(Context {
1623 link: self1639 link: self
1624 .editor1640 .editor
1625 .link_at(anchor.min(focus))1641 .link_at(anchor.min(focus))
...@@ -1634,8 +1650,7 @@ impl PageView {...@@ -1634,8 +1650,7 @@ impl PageView {
1634 .map(|(_, _, node)| node.id),1650 .map(|(_, _, node)| node.id),
1635 attachment: None,1651 attachment: None,
1636 spelling: self.selected_correction(),1652 spelling: self.selected_correction(),
1637 };1653 })
1638 Ok(Some((self.changed()?, context)))
1639 }1654 }
16401655
1641 /// The marked word at the caret, or wholly selected, with its corrections.1656 /// The marked word at the caret, or wholly selected, with its corrections.
crates/snowbound/src/link.rs+142-107
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3//! of the notebook or with the system's handler.3//! of the notebook or with the system's handler.
44
5use crate::{Command, State, art, commands, page, platform};5use crate::{Command, State, art, commands, page, platform};
6use canvas::interaction::Context;
6use onestore::ExGuid;7use onestore::ExGuid;
7use onestore::page::link::{LinkTarget, internal_link};8use onestore::page::link::{LinkTarget, internal_link};
8use std::error::Error;9use std::error::Error;
...@@ -317,137 +318,171 @@ impl State {...@@ -317,137 +318,171 @@ impl State {
317 None => Vec::new(),318 None => Vec::new(),
318 };319 };
319 let corrections = items.len();320 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 };
320 let format = self.format_state();372 let format = self.format_state();
321 let disabled = |id| !self.status(&commands::Choice::Command(id), &format).enabled;373 let disabled = |id| !self.status(&commands::Choice::Command(id), &format).enabled;
322 // Paste stands for any edit to the text here.374 // Paste stands for any edit to the text here.
323 let (fixed, unlinkable) = (disabled(commands::Id::Paste), disabled(commands::Id::Link));375 let (fixed, unlinkable) = (disabled(commands::Id::Paste), disabled(commands::Id::Link));
324 let lists = [commands::Id::ToDoList, commands::Id::BulletedList]376 if context.attachment.is_some() {
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() {
332 // OneNote 2010's commands for the file itself; its clipboard holds text alone.377 // OneNote 2010's commands for the file itself; its clipboard holds text alone.
333 vec![drawn("Open", art::OPEN), drawn("Save As", art::SAVE)]378 return vec![
334 } else {379 (drawn("Open", art::OPEN), "Open File"),
335 let mut items = vec![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([
336 Item {399 Item {
337 disabled: !context.selected || fixed,400 separated: true,
338 separated: corrections > 0,401 disabled: unlinkable,
339 ..drawn("Cut", art::CUT)402 ..drawn("Edit Link", art::LINK)
340 },403 },
404 drawn("Copy Link to Paragraph", art::COPY_LINK),
341 Item {405 Item {
342 disabled: !context.selected,406 separated: true,
343 ..drawn("Copy", art::COPY)407 ..drawn("Copy Link", art::COPY_LINK)
344 },408 },
409 drawn("Select Link", art::SELECT),
345 Item {410 Item {
346 disabled: fixed,411 disabled: unlinkable,
347 ..drawn("Paste", art::PASTE)412 ..drawn("Remove Link", art::REMOVE_LINK)
348 },413 },
349 ];414 ]),
350 match &context.link {415 None => items.extend([
351 Some(_) => items.extend([416 Item {
352 Item {417 separated: true,
353 separated: true,418 disabled: unlinkable,
354 disabled: unlinkable,419 ..drawn("Link", art::LINK)
355 ..drawn("Edit Link", art::LINK)420 },
356 },421 drawn("Copy Link to Paragraph", art::COPY_LINK),
357 drawn("Copy Link to Paragraph", art::COPY_LINK),422 ]),
358 Item {423 }
359 separated: true,424 let lists = [commands::Id::ToDoList, commands::Id::BulletedList];
360 ..drawn("Copy Link", art::COPY_LINK)425 for (at, id) in lists.into_iter().filter(|id| !disabled(*id)).enumerate() {
361 },426 items.push(Item {
362 drawn("Select Link", art::SELECT),427 text: commands::command(id).title,
363 Item {428 icon: crate::artwork(id),
364 disabled: unlinkable,429 separated: at == 0,
365 ..drawn("Remove Link", art::REMOVE_LINK)430 ..Item::default()
366 },431 });
367 ]),432 }
368 None => items.extend([433 // Link names the command editing a link too.
369 Item {434 let mut items: Vec<_> = (items.into_iter())
370 separated: true,435 .map(|item| {
371 disabled: unlinkable,436 (
372 ..drawn("Link", art::LINK)437 item,
438 if item.text == "Edit Link" {
439 "Link"
440 } else {
441 item.text
373 },442 },
374 drawn("Copy Link to Paragraph", art::COPY_LINK),443 )
375 ]),444 })
376 }445 .collect();
377 for (at, id) in lists.iter().enumerate() {446 if context.equation {
378 items.push(Item {447 items.extend([
379 separated: at == 0,448 (
380 icon: crate::artwork(*id),
381 ..item(commands::command(*id).title)
382 });
383 }
384 if context.equation {
385 items.extend([
386 Item {449 Item {
387 separated: true,450 separated: true,
388 disabled: fixed,451 disabled: fixed,
389 ..drawn("Professional", art::EQUATION)452 ..drawn("Professional", art::EQUATION)
390 },453 },
454 "Professional Equation",
455 ),
456 (
391 Item {457 Item {
392 disabled: fixed,458 disabled: fixed,
393 ..drawn("Linear", art::EQUATION)459 ..drawn("Linear", art::EQUATION)
394 },460 },
395 ]);461 "Linear Equation",
396 }462 ),
397 items463 ]);
398 });464 }
399 let Some(chosen) =465 items
400 ui::popup::menu(&mut self.ui, menu(), Anchor::Point(*point), &items, None)466 }
401 else {467
402 return Ok(());468 /// Does what the page's context menu item `text` does at `context`.
403 };469 pub(crate) fn text_command(
404 let chosen_text = items[chosen].text.to_owned();470 &mut self,
405 let Some((context, _)) = self.text_menu.take() else {471 context: Context,
406 return Ok(());472 text: &str,
407 };473 ) -> Result<(), Box<dyn Error>> {
408 if chosen < corrections {474 if let Some(file) = &context.attachment {
409 let correction = context.spelling.unwrap();475 return match text {
410 let response = match chosen_text.as_str() {476 "Open" => self.open_attachment(file),
411 _ if chosen < correction.suggestions.len() => {477 _ => self.save_attachment(file),
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 }
430 };478 };
431 self.respond(response);
432 return Ok(());
433 }479 }
434 if let Some(id) = lists480 // Cut, Copy, Paste, Link and the list conversions are the commands of those names.
435 .into_iter()481 if let Some(command) = (commands::COMMANDS.iter()).find(|command| command.title == text) {
436 .find(|id| commands::command(*id).title == chosen_text)482 return self.run(commands::Choice::Command(command.id));
437 {
438 return self.run(commands::Choice::Command(id));
439 }483 }
440 let response = match chosen_text.as_str() {484 let response = match text {
441 "Open" => return self.open_attachment(&context.attachment.unwrap()),485 "Edit Link" => {
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" => {
451 self.open_link_dialog();486 self.open_link_dialog();
452 return Ok(());487 return Ok(());
453 }488 }
crates/snowbound/src/main.rs+7
...@@ -701,6 +701,8 @@ enum Command {...@@ -701,6 +701,8 @@ enum Command {
701 },701 },
702 /// Deletes pages of the open section to the notebook's recycle bin.702 /// Deletes pages of the open section to the notebook's recycle bin.
703 DeletePages(Vec<ExGuid>),703 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),
704 /// Restores or deletes for good pages of the recycle bin's open section.706 /// Restores or deletes for good pages of the recycle bin's open section.
705 Recycle(recycle::Request),707 Recycle(recycle::Request),
706 /// Moves or indents pages of the open section.708 /// Moves or indents pages of the open section.
...@@ -3438,6 +3440,11 @@ impl State {...@@ -3438,6 +3440,11 @@ impl State {
3438 Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?,3440 Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?,
3439 Command::Choose(choice) => self.run(choice)?,3441 Command::Choose(choice) => self.run(choice)?,
3440 Command::TagPicture => self.pick_tag_picture(),3442 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 }
3441 }3448 }
3442 Ok(())3449 Ok(())
3443 }3450 }
crates/snowbound/src/menus.rs+117-35
...@@ -76,9 +76,28 @@ pub enum Target {...@@ -76,9 +76,28 @@ pub enum Target {
76 Server(String),76 Server(String),
77 /// A page to add to the open section, by its title.77 /// A page to add to the open section, by its title.
78 NewPage(String),78 NewPage(String),
79 /// The page's context menu at the caret or the file selected, by an item's text.
80 Text(&'static str),
79 Command(commands::Id),81 Command(commands::Id),
80}82}
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
82/// What can be done to a target, as its context menu and the palette's actions list it.101/// What can be done to a target, as its context menu and the palette's actions list it.
83#[derive(Clone, Debug, PartialEq)]102#[derive(Clone, Debug, PartialEq)]
84pub enum Action {103pub enum Action {
...@@ -191,6 +210,56 @@ pub(crate) const SECTION_COLORS: [(u32, &str); 16] = [...@@ -191,6 +210,56 @@ pub(crate) const SECTION_COLORS: [(u32, &str); 16] = [
191 (0xaa9595, "Silver"),210 (0xaa9595, "Silver"),
192];211];
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
194/// A place Move offers.263/// A place Move offers.
195struct Destination {264struct Destination {
196 name: String,265 name: String,
...@@ -533,7 +602,7 @@ impl State {...@@ -533,7 +602,7 @@ impl State {
533 Target::Closed(_) | Target::Server(_) => {602 Target::Closed(_) | Target::Server(_) => {
534 vec![item(Action::Delete, "Remove from Recent", false, false)]603 vec![item(Action::Delete, "Remove from Recent", false, false)]
535 }604 }
536 Target::Command(_) | Target::NewPage(_) => Vec::new(),605 Target::Command(_) | Target::NewPage(_) | Target::Text(_) => Vec::new(),
537 }606 }
538 }607 }
539608
...@@ -549,37 +618,53 @@ impl State {...@@ -549,37 +618,53 @@ impl State {
549 ) -> Option<Action> {618 ) -> Option<Action> {
550 let items: Vec<Item> = actions.iter().map(|(_, item)| *item).collect();619 let items: Vec<Item> = actions.iter().map(|(_, item)| *item).collect();
551 let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter);620 let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter);
552 let places = id.child("move");621 let submenu = |action: &Action| match action {
553 let copies = id.child("copy");622 Action::Move => Some(id.child("move")),
554 let colors = id.child("colors");623 Action::Copy => Some(id.child("copy")),
555 ui::popup::submenus(&mut self.ui, id, &items, |index| match actions[index].0 {624 Action::Colors => Some(id.child("colors")),
556 Action::Move => Some(places),
557 Action::Copy => Some(copies),
558 Action::Colors => Some(colors),
559 _ => None,625 _ => None,
560 });626 };
561 let mut moved = None;627 ui::popup::submenus(&mut self.ui, id, &items, |index| submenu(&actions[index].0));
562 for (submenu, chosen) in [628 let mut chosen = chosen.map(|index| actions[index].0.clone());
563 (places, Action::MoveTo as fn(String) -> Action),629 for action in [Action::Move, Action::Copy, Action::Colors] {
564 (copies, Action::CopyTo),630 if let Some(menu) = submenu(&action) {
565 ] {631 chosen = chosen.or_else(|| self.submenu(menu, &action, target, anchor));
566 if !self.ui.popup_open(submenu) {
567 continue;
568 }632 }
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())));
580 }633 }
581 let colored = match target {634 chosen
582 Target::Section { library, path } if self.ui.popup_open(colors) => {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 }) => {
583 let current = (library.tabs(&folder(path)).into_iter())668 let current = (library.tabs(&folder(path)).into_iter())
584 .find(|tab| tab.path == *path)669 .find(|tab| tab.path == *path)
585 .and_then(|tab| tab.color);670 .and_then(|tab| tab.color);
...@@ -599,15 +684,11 @@ impl State {...@@ -599,15 +684,11 @@ impl State {
599 separated: true,684 separated: true,
600 ..Item::default()685 ..Item::default()
601 });686 });
602 ui::popup::menu(&mut self.ui, colors, anchor, &items, None)687 ui::popup::menu(&mut self.ui, id, anchor, &items, None)
603 .map(|index| Action::Color(SECTION_COLORS.get(index).map(|(color, _)| *color)))688 .map(|index| Action::Color(SECTION_COLORS.get(index).map(|(color, _)| *color)))
604 }689 }
605 _ => None,690 _ => None,
606 };691 }
607 chosen
608 .map(|index| actions[index].0.clone())
609 .or(moved)
610 .or(colored)
611 }692 }
612693
613 /// Where Move takes `target`: the other sections of a page's folder, or the folders a694 /// Where Move takes `target`: the other sections of a page's folder, or the folders a
...@@ -676,6 +757,7 @@ impl State {...@@ -676,6 +757,7 @@ impl State {
676 None757 None
677 }758 }
678 (Target::NewPage(title), _) => Some(Command::NewPage { under: None, title }),759 (Target::NewPage(title), _) => Some(Command::NewPage { under: None, title }),
760 (Target::Text(text), _) => Some(Command::Text(text)),
679 (761 (
680 Target::Page {762 Target::Page {
681 library,763 library,
crates/snowbound/src/palette.rs+87-3
...@@ -33,7 +33,11 @@ struct Row {...@@ -33,7 +33,11 @@ struct Row {
33 repeated: bool,33 repeated: bool,
34 /// Offered only when nothing else matches the query.34 /// Offered only when nothing else matches the query.
35 fallback: bool,35 fallback: bool,
36 /// Opens a submenu of places or colours for `action`.
37 submenu: bool,
36 target: Option<Target>,38 target: Option<Target>,
39 /// What choosing it does to `target`, where not to run or open it.
40 action: Option<Action>,
37}41}
3842
39impl Row {43impl Row {
...@@ -55,6 +59,7 @@ impl Row {...@@ -55,6 +59,7 @@ impl Row {
55 separated: self.target.is_none(),59 separated: self.target.is_none(),
56 repeated: self.repeated,60 repeated: self.repeated,
57 fallback: self.fallback,61 fallback: self.fallback,
62 submenu: self.submenu,
58 ..Item::default()63 ..Item::default()
59 }64 }
60 }65 }
...@@ -92,6 +97,35 @@ impl State {...@@ -92,6 +97,35 @@ impl State {
92 ..Row::default()97 ..Row::default()
93 }98 }
94 }));99 }));
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 }
95 let mut places = self.recent();129 let mut places = self.recent();
96 places.push(Row::heading("Notebooks"));130 places.push(Row::heading("Notebooks"));
97 places.extend(131 places.extend(
...@@ -186,14 +220,29 @@ impl State {...@@ -186,14 +220,29 @@ impl State {
186 &[(COMMANDS, &items[0]), ("", &items[1])],220 &[(COMMANDS, &items[0]), ("", &items[1])],
187 "Search pages, sections and notebooks (type > for commands)",221 "Search pages, sections and notebooks (type > for commands)",
188 );222 );
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 }
189 let mut rows = [commands, places];237 let mut rows = [commands, places];
190 match picked {238 match picked {
191 Some(Pick::Run(mode, index)) => {239 Some(Pick::Run(mode, index)) => {
192 if let Some(target) = rows[mode].swap_remove(index).target {240 let row = rows[mode].swap_remove(index);
193 let action = match target {241 if let Some(target) = row.target {
242 let action = row.action.unwrap_or(match target {
194 Target::Command(_) => Action::Run,243 Target::Command(_) => Action::Run,
195 _ => Action::Open,244 _ => Action::Open,
196 };245 });
197 self.act_on(target, action);246 self.act_on(target, action);
198 }247 }
199 }248 }
...@@ -267,6 +316,41 @@ impl State {...@@ -267,6 +316,41 @@ impl State {
267 }316 }
268 }317 }
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
270 /// Under a heading, the pages shown lately but the one shown, their sections but the one354 /// Under a heading, the pages shown lately but the one shown, their sections but the one
271 /// open, the notebooks of those since closed, and the servers saved to reconnect to;355 /// open, the notebooks of those since closed, and the servers saved to reconnect to;
272 /// latest first, and none before any. Pages and sections since gone are forgotten.356 /// 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() {...@@ -226,6 +226,74 @@ fn the_palette_makes_a_page_of_a_query_matching_nothing() {
226 );226 );
227}227}
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
229/// The page tabs listed in `tree`, the selected one marked with `*`.297/// The page tabs listed in `tree`, the selected one marked with `*`.
230fn page_tabs(tree: &str) -> Vec<String> {298fn page_tabs(tree: &str) -> Vec<String> {
231 tree.lines()299 tree.lines()