| author | |
| committer | |
| log | 464da86b45523af24588c5558b41fff36ed753b2 |
| tree | be9abf5ed79efff54914ced10e7f1af604f44fa2 |
| parent | e173ad0c64864c7d5c3c1cfb7cc5e5c6fa931707 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.56 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: self | 1639 | link: self |
| 1624 | .editor | 1640 | .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 | } |
| 1640 | 1655 | ||
| 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. |
| 4 | 4 | ||
| 5 | use crate::{Command, State, art, commands, page, platform}; | 5 | use crate::{Command, State, art, commands, page, platform}; |
| 6 | use canvas::interaction::Context; | ||
| 6 | use onestore::ExGuid; | 7 | use onestore::ExGuid; |
| 7 | use onestore::page::link::{LinkTarget, internal_link}; | 8 | use onestore::page::link::{LinkTarget, internal_link}; |
| 8 | use std::error::Error; | 9 | use 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 | items | 463 | ]); |
| 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) = lists | 480 | // 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 | } |
| 81 | 83 | ||
| 84 | impl 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)] |
| 84 | pub enum Action { | 103 | pub 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 | ]; |
| 193 | 212 | ||
| 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. | ||
| 215 | pub(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. | ||
| 251 | pub(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. |
| 195 | struct Destination { | 264 | struct 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 | } |
| 539 | 608 | ||
| ... | @@ -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 | } |
| 612 | 693 | ||
| 613 | /// Where Move takes `target`: the other sections of a page's folder, or the folders a | 694 | /// 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 | None | 757 | 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 | } |
| 38 | 42 | ||
| 39 | impl Row { | 43 | impl 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 | } |
| 269 | 318 | ||
| 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 one | 354 | /// 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 | } |
| 228 | 228 | ||
| 229 | /// The options listed in `tree`'s palette, one a line. | ||
| 230 | fn 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] | ||
| 240 | fn 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 `*`. |
| 230 | fn page_tabs(tree: &str) -> Vec<String> { | 298 | fn page_tabs(tree: &str) -> Vec<String> { |
| 231 | tree.lines() | 299 | tree.lines() |