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()