authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 21:43:11-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 01:50:33-07:00
logc09590a9c59a6645ff9cbe96e0037000405a8bf7
tree3b5fdf2780fa9664f2cb9faeea03c7f70eb846ee
parent0fe456a8909d9b9991afbe28c28f915d031ca44f
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: context menus offer only what applies to what was clicked, disabled when it can't

Clover: New Notebook isn't about the notebook right-clicked, so its menu drops it. Sections gain OneNote 2010's Copy Link to Section, as the lab's OneNote copies it. New Section, New Section Group and Customize Font Styles are disabled where the notebook has no catalog or the section no identity, and the page's Cut, Paste, Link, Remove Link and equation forms where the page takes no edits. Assisted-by: claude-opus-5.5

2 files changed, 98 insertions(+), 50 deletions(-)

crates/snowbound/src/link.rs+60-30
...@@ -59,26 +59,9 @@ fn system_url(address: &str) -> String {...@@ -59,26 +59,9 @@ fn system_url(address: &str) -> String {
59 }59 }
60}60}
6161
62/// A page, or a paragraph on it, as Copy Link to Page gives it: the section file's path62/// A section, page or paragraph as Copy Link gives it: the section file's path, then the
63/// before the title, then the identities OneNote finds it by.63/// title and identities OneNote finds it by.
64fn clipboard_link(64fn clipboard_link(path: &str, section: [u8; 16], target: LinkTarget) -> String {
65 path: &str,
66 section: [u8; 16],
67 title: &str,
68 page: [u8; 16],
69 object: Option<ExGuid>,
70) -> String {
71 let target = match object {
72 Some(object) => LinkTarget::Object {
73 identity: page,
74 title,
75 object,
76 },
77 None => LinkTarget::Page {
78 identity: page,
79 title,
80 },
81 };
82 let stored = internal_link(section, "", target);65 let stored = internal_link(section, "", target);
83 let fragment = stored66 let fragment = stored
84 .strip_prefix("onenote:#")67 .strip_prefix("onenote:#")
...@@ -335,6 +318,9 @@ impl State {...@@ -335,6 +318,9 @@ impl State {
335 };318 };
336 let corrections = items.len();319 let corrections = items.len();
337 let format = self.format_state();320 let format = self.format_state();
321 let disabled = |id| !self.status(&commands::Choice::Command(id), &format).enabled;
322 // Paste stands for any edit to the text here.
323 let (fixed, unlinkable) = (disabled(commands::Id::Paste), disabled(commands::Id::Link));
338 let lists = [commands::Id::ToDoList, commands::Id::BulletedList]324 let lists = [commands::Id::ToDoList, commands::Id::BulletedList]
339 .into_iter()325 .into_iter()
340 .filter(|id| {326 .filter(|id| {
...@@ -348,7 +334,7 @@ impl State {...@@ -348,7 +334,7 @@ impl State {
348 } else {334 } else {
349 let mut items = vec![335 let mut items = vec![
350 Item {336 Item {
351 disabled: !context.selected,337 disabled: !context.selected || fixed,
352 separated: corrections > 0,338 separated: corrections > 0,
353 ..drawn("Cut", art::CUT)339 ..drawn("Cut", art::CUT)
354 },340 },
...@@ -356,12 +342,16 @@ impl State {...@@ -356,12 +342,16 @@ impl State {
356 disabled: !context.selected,342 disabled: !context.selected,
357 ..drawn("Copy", art::COPY)343 ..drawn("Copy", art::COPY)
358 },344 },
359 drawn("Paste", art::PASTE),345 Item {
346 disabled: fixed,
347 ..drawn("Paste", art::PASTE)
348 },
360 ];349 ];
361 match &context.link {350 match &context.link {
362 Some(_) => items.extend([351 Some(_) => items.extend([
363 Item {352 Item {
364 separated: true,353 separated: true,
354 disabled: unlinkable,
365 ..drawn("Edit Link", art::LINK)355 ..drawn("Edit Link", art::LINK)
366 },356 },
367 drawn("Copy Link to Paragraph", art::COPY_LINK),357 drawn("Copy Link to Paragraph", art::COPY_LINK),
...@@ -370,11 +360,15 @@ impl State {...@@ -370,11 +360,15 @@ impl State {
370 ..drawn("Copy Link", art::COPY_LINK)360 ..drawn("Copy Link", art::COPY_LINK)
371 },361 },
372 drawn("Select Link", art::SELECT),362 drawn("Select Link", art::SELECT),
373 drawn("Remove Link", art::REMOVE_LINK),363 Item {
364 disabled: unlinkable,
365 ..drawn("Remove Link", art::REMOVE_LINK)
366 },
374 ]),367 ]),
375 None => items.extend([368 None => items.extend([
376 Item {369 Item {
377 separated: true,370 separated: true,
371 disabled: unlinkable,
378 ..drawn("Link", art::LINK)372 ..drawn("Link", art::LINK)
379 },373 },
380 drawn("Copy Link to Paragraph", art::COPY_LINK),374 drawn("Copy Link to Paragraph", art::COPY_LINK),
...@@ -391,9 +385,13 @@ impl State {...@@ -391,9 +385,13 @@ impl State {
391 items.extend([385 items.extend([
392 Item {386 Item {
393 separated: true,387 separated: true,
388 disabled: fixed,
394 ..drawn("Professional", art::EQUATION)389 ..drawn("Professional", art::EQUATION)
395 },390 },
396 drawn("Linear", art::EQUATION),391 Item {
392 disabled: fixed,
393 ..drawn("Linear", art::EQUATION)
394 },
397 ]);395 ]);
398 }396 }
399 items397 items
...@@ -489,12 +487,30 @@ impl State {...@@ -489,12 +487,30 @@ impl State {
489 .iter()487 .iter()
490 .find(|(listed, ..)| *listed == space)488 .find(|(listed, ..)| *listed == space)
491 .map_or(page.title.as_str(), |(_, title, _)| title.as_str());489 .map_or(page.title.as_str(), |(_, title, _)| title.as_str());
490 let target = match object {
491 Some(object) => LinkTarget::Object {
492 identity,
493 title,
494 object,
495 },
496 None => LinkTarget::Page { identity, title },
497 };
492 Ok(clipboard_link(498 Ok(clipboard_link(
493 &session.section.file().to_string_lossy(),499 &session.section.file().to_string_lossy(),
494 session.section.identity()?,500 session.section.identity()?,
495 title,501 target,
502 ))
503 }
504
505 /// Copy Link to Section: the link OneNote 2010 puts on the clipboard for the section at
506 /// catalog `path`; none for one without a file here.
507 pub(crate) fn section_link(&self, library: &crate::Library, path: &str) -> Option<String> {
508 let file = library.folder()?.join(path);
509 let identity = library.section_identity(path)?;
510 Some(clipboard_link(
511 &file.to_string_lossy(),
496 identity,512 identity,
497 object,513 LinkTarget::Section,
498 ))514 ))
499 }515 }
500516
...@@ -541,13 +557,27 @@ mod tests {...@@ -541,13 +557,27 @@ mod tests {
541 }557 }
542558
543 /// OneNote 2010's Copy Link to Page (`corpus/link-edit/native-typed`, README): the file's559 /// OneNote 2010's Copy Link to Page (`corpus/link-edit/native-typed`, README): the file's
544 /// path, the title, and the section and page identities.560 /// path, the title, and the section and page identities; and its Copy Link to Section,
561 /// as the lab's OneNote 2010 copied one, the path's spaces escaped.
545 #[test]562 #[test]
546 fn page_links_copy_as_onenote_copies_them() {563 fn links_copy_as_onenote_copies_them() {
547 let section = *b"\x25\xfc\x1e\x0f\x7c\xdf\xcb\x45\x87\xa4\xbd\xd0\x3f\x42\x05\x7e";564 let section = *b"\x25\xfc\x1e\x0f\x7c\xdf\xcb\x45\x87\xa4\xbd\xd0\x3f\x42\x05\x7e";
548 let page = *b"\x18\xa3\xcc\xa9\x3e\xa4\x12\x49\xa1\xc7\xf1\x9f\x1e\xaf\x30\xca";565 let identity = *b"\x18\xa3\xcc\xa9\x3e\xa4\x12\x49\xa1\xc7\xf1\x9f\x1e\xaf\x30\xca";
566 let page = LinkTarget::Page {
567 identity,
568 title: "Links",
569 };
570 let general = *b"\x1b\x79\xcd\xe4\x17\x94\x4f\x4b\x99\x3a\x34\xfe\xb3\xb2\xa8\x4d";
571 assert_eq!(
572 clipboard_link(
573 "C:\\Users\\clover\\Documents\\OneNote Notebooks\\Personal\\General.one",
574 general,
575 LinkTarget::Section
576 ),
577 "onenote:///C:\\Users\\clover\\Documents\\OneNote%20Notebooks\\Personal\\General.one#section-id={E4CD791B-9417-4B4F-993A-34FEB3B2A84D}&end"
578 );
549 assert_eq!(579 assert_eq!(
550 clipboard_link("C:\\one\\links.one", section, "Links", page, None),580 clipboard_link("C:\\one\\links.one", section, page),
551 "onenote:///C:\\one\\links.one#Links&section-id={0F1EFC25-DF7C-45CB-87A4-BDD03F42057E}&page-id={A9CCA318-A43E-4912-A1C7-F19F1EAF30CA}&end"581 "onenote:///C:\\one\\links.one#Links&section-id={0F1EFC25-DF7C-45CB-87A4-BDD03F42057E}&page-id={A9CCA318-A43E-4912-A1C7-F19F1EAF30CA}&end"
552 );582 );
553 }583 }
crates/snowbound/src/menus.rs+38-20
...@@ -116,7 +116,6 @@ pub enum Action {...@@ -116,7 +116,6 @@ pub enum Action {
116 /// A section's colour, COLORREF; none is OneNote's None.116 /// A section's colour, COLORREF; none is OneNote's None.
117 Color(Option<u32>),117 Color(Option<u32>),
118 Sync,118 Sync,
119 NewNotebook,
120 /// Moves a notebook up the notebook list, or down.119 /// Moves a notebook up the notebook list, or down.
121 Raise(bool),120 Raise(bool),
122 Properties,121 Properties,
...@@ -154,7 +153,6 @@ impl Action {...@@ -154,7 +153,6 @@ impl Action {
154 Action::Theme => art::STYLES,153 Action::Theme => art::STYLES,
155 Action::Colors => art::SECTION_COLOR,154 Action::Colors => art::SECTION_COLOR,
156 Action::Sync => art::SYNC_NOW,155 Action::Sync => art::SYNC_NOW,
157 Action::NewNotebook => art::NEW_NOTEBOOK,
158 Action::Raise(true) => art::MOVE_UP,156 Action::Raise(true) => art::MOVE_UP,
159 Action::Raise(false) => art::MOVE_DOWN,157 Action::Raise(false) => art::MOVE_DOWN,
160 Action::Properties => art::PROPERTIES,158 Action::Properties => art::PROPERTIES,
...@@ -335,9 +333,9 @@ impl State {...@@ -335,9 +333,9 @@ impl State {
335 };333 };
336 let nowhere = || self.destinations(target).is_empty();334 let nowhere = || self.destinations(target).is_empty();
337 // Last on a page, section or notebook, under a rule.335 // Last on a page, section or notebook, under a rule.
338 let styles = || {336 let styles = |library: &Library| {
339 let title = commands::command(commands::Id::Themes).title;337 let title = commands::command(commands::Id::Themes).title;
340 item(Action::Theme, title, false, true)338 item(Action::Theme, title, library.catalog().is_none(), true)
341 };339 };
342 match target {340 match target {
343 // OneNote's Move or Copy takes a page out of the bin; nothing else changes it.341 // OneNote's Move or Copy takes a page out of the bin; nothing else changes it.
...@@ -369,7 +367,7 @@ impl State {...@@ -369,7 +367,7 @@ impl State {
369 // Levels and versions are known once the section is open.367 // Levels and versions are known once the section is open.
370 let Some(session) = self.session.as_ref().filter(|_| self.open(library, path))368 let Some(session) = self.session.as_ref().filter(|_| self.open(library, path))
371 else {369 else {
372 actions.push(styles());370 actions.push(styles(library));
373 return actions;371 return actions;
374 };372 };
375 let pages = &session.pages;373 let pages = &session.pages;
...@@ -398,12 +396,13 @@ impl State {...@@ -398,12 +396,13 @@ impl State {
398 session.page_versions(*space).is_empty(),396 session.page_versions(*space).is_empty(),
399 true,397 true,
400 ),398 ),
401 styles(),399 styles(library),
402 ]);400 ]);
403 actions401 actions
404 }402 }
405 Target::Section { library, path } | Target::Group { library, path } => {403 Target::Section { library, path } | Target::Group { library, path } => {
406 let section = matches!(target, Target::Section { .. });404 let section = matches!(target, Target::Section { .. });
405 let cataloged = library.catalog().is_some();
407 let mut actions = vec![item(Action::Rename, "Rename", false, false)];406 let mut actions = vec![item(Action::Rename, "Rename", false, false)];
408 if section {407 if section {
409 actions.push(item(Action::SaveAs, "Save As", false, false));408 actions.push(item(Action::SaveAs, "Save As", false, false));
...@@ -411,15 +410,21 @@ impl State {...@@ -411,15 +410,21 @@ impl State {
411 actions.extend([410 actions.extend([
412 item(Action::Delete, "Delete", false, false),411 item(Action::Delete, "Delete", false, false),
413 item(Action::Move, "Move", nowhere(), false),412 item(Action::Move, "Move", nowhere(), false),
414 item(Action::NewSection, "New Section", false, true),413 ]);
415 item(Action::NewGroup, "New Section Group", false, false),414 if section {
415 let link = self.section_link(library, path).is_none();
416 actions.push(item(Action::CopyLink, "Copy Link to Section", link, true));
417 }
418 actions.extend([
419 item(Action::NewSection, "New Section", !cataloged, true),
420 item(Action::NewGroup, "New Section Group", !cataloged, false),
416 ]);421 ]);
417 if section {422 if section {
418 actions.extend([423 actions.extend([
419 item(424 item(
420 Action::Password,425 Action::Password,
421 "Password Protect This Section",426 "Password Protect This Section",
422 library.catalog().is_none(),427 !cataloged,
423 true,428 true,
424 ),429 ),
425 item(Action::Colors, "Section Color", false, false),430 item(Action::Colors, "Section Color", false, false),
...@@ -429,7 +434,12 @@ impl State {...@@ -429,7 +434,12 @@ impl State {
429 section_file(library, path).is_none(),434 section_file(library, path).is_none(),
430 false,435 false,
431 ),436 ),
432 styles(),437 item(
438 Action::Theme,
439 commands::command(commands::Id::Themes).title,
440 library.section_identity(path).is_none(),
441 true,
442 ),
433 ]);443 ]);
434 }444 }
435 actions445 actions
...@@ -467,12 +477,16 @@ impl State {...@@ -467,12 +477,16 @@ impl State {
467 library.catalog().is_none() || library.joined.is_some(),477 library.catalog().is_none() || library.joined.is_some(),
468 false,478 false,
469 ),479 ),
470 item(Action::NewSection, "New Section", false, true),
471 item(Action::NewGroup, "New Section Group", false, false),
472 item(480 item(
473 Action::NewNotebook,481 Action::NewSection,
474 commands::command(commands::Id::NewNotebook).title,482 "New Section",
475 false,483 library.catalog().is_none(),
484 true,
485 ),
486 item(
487 Action::NewGroup,
488 "New Section Group",
489 library.catalog().is_none(),
476 false,490 false,
477 ),491 ),
478 item(492 item(
...@@ -511,7 +525,7 @@ impl State {...@@ -511,7 +525,7 @@ impl State {
511 library.catalog().is_none(),525 library.catalog().is_none(),
512 false,526 false,
513 ),527 ),
514 styles(),528 styles(library),
515 ]529 ]
516 }530 }
517 Target::Closed(_) | Target::Server(_) => {531 Target::Closed(_) | Target::Server(_) => {
...@@ -779,6 +793,14 @@ impl State {...@@ -779,6 +793,14 @@ impl State {
779 library,793 library,
780 Structure::Color { path, color },794 Structure::Color { path, color },
781 )),795 )),
796 (Target::Section { library, path }, Action::CopyLink) => {
797 if let Some(link) = self.section_link(&library, &path)
798 && let Err(error) = self.clipboard.set_text(link)
799 {
800 eprintln!("Copying the link failed: {error}");
801 }
802 None
803 }
782 (Target::Section { library, path }, Action::Reveal) => {804 (Target::Section { library, path }, Action::Reveal) => {
783 if let Some(file) = section_file(&library, &path) {805 if let Some(file) = section_file(&library, &path) {
784 platform::show_file(&file);806 platform::show_file(&file);
...@@ -825,10 +847,6 @@ impl State {...@@ -825,10 +847,6 @@ impl State {
825 }847 }
826 None848 None
827 }849 }
828 (Target::Notebook(_), Action::NewNotebook) => {
829 self.choose(Choice::Command(commands::Id::NewNotebook));
830 None
831 }
832 (Target::Notebook(library), Action::Raise(up)) => {850 (Target::Notebook(library), Action::Raise(up)) => {
833 let at = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, &library));851 let at = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, &library));
834 let to = at.and_then(|at| if up { at.checked_sub(1) } else { Some(at + 1) });852 let to = at.and_then(|at| if up { at.checked_sub(1) } else { Some(at + 1) });