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