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 {
5959 }
6060}
6161
62/// A page, or a paragraph on it, as Copy Link to Page gives it: the section file's path
63/// before the title, then the identities OneNote finds it by.
64fn clipboard_link(
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 };
62/// A section, page or paragraph as Copy Link gives it: the section file's path, then the
63/// title and identities OneNote finds it by.
64fn clipboard_link(path: &str, section: [u8; 16], target: LinkTarget) -> String {
8265 let stored = internal_link(section, "", target);
8366 let fragment = stored
8467 .strip_prefix("onenote:#")
......@@ -335,6 +318,9 @@ impl State {
335318 };
336319 let corrections = items.len();
337320 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));
338324 let lists = [commands::Id::ToDoList, commands::Id::BulletedList]
339325 .into_iter()
340326 .filter(|id| {
......@@ -348,7 +334,7 @@ impl State {
348334 } else {
349335 let mut items = vec![
350336 Item {
351 disabled: !context.selected,
337 disabled: !context.selected || fixed,
352338 separated: corrections > 0,
353339 ..drawn("Cut", art::CUT)
354340 },
......@@ -356,12 +342,16 @@ impl State {
356342 disabled: !context.selected,
357343 ..drawn("Copy", art::COPY)
358344 },
359 drawn("Paste", art::PASTE),
345 Item {
346 disabled: fixed,
347 ..drawn("Paste", art::PASTE)
348 },
360349 ];
361350 match &context.link {
362351 Some(_) => items.extend([
363352 Item {
364353 separated: true,
354 disabled: unlinkable,
365355 ..drawn("Edit Link", art::LINK)
366356 },
367357 drawn("Copy Link to Paragraph", art::COPY_LINK),
......@@ -370,11 +360,15 @@ impl State {
370360 ..drawn("Copy Link", art::COPY_LINK)
371361 },
372362 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 },
374367 ]),
375368 None => items.extend([
376369 Item {
377370 separated: true,
371 disabled: unlinkable,
378372 ..drawn("Link", art::LINK)
379373 },
380374 drawn("Copy Link to Paragraph", art::COPY_LINK),
......@@ -391,9 +385,13 @@ impl State {
391385 items.extend([
392386 Item {
393387 separated: true,
388 disabled: fixed,
394389 ..drawn("Professional", art::EQUATION)
395390 },
396 drawn("Linear", art::EQUATION),
391 Item {
392 disabled: fixed,
393 ..drawn("Linear", art::EQUATION)
394 },
397395 ]);
398396 }
399397 items
......@@ -489,12 +487,30 @@ impl State {
489487 .iter()
490488 .find(|(listed, ..)| *listed == space)
491489 .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 };
492498 Ok(clipboard_link(
493499 &session.section.file().to_string_lossy(),
494500 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(),
496512 identity,
497 object,
513 LinkTarget::Section,
498514 ))
499515 }
500516
......@@ -541,13 +557,27 @@ mod tests {
541557 }
542558
543559 /// 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.
545562 #[test]
546 fn page_links_copy_as_onenote_copies_them() {
563 fn links_copy_as_onenote_copies_them() {
547564 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 );
549579 assert_eq!(
550 clipboard_link("C:\\one\\links.one", section, "Links", page, None),
580 clipboard_link("C:\\one\\links.one", section, page),
551581 "onenote:///C:\\one\\links.one#Links&section-id={0F1EFC25-DF7C-45CB-87A4-BDD03F42057E}&page-id={A9CCA318-A43E-4912-A1C7-F19F1EAF30CA}&end"
552582 );
553583 }
crates/snowbound/src/menus.rs+38-20
......@@ -116,7 +116,6 @@ pub enum Action {
116116 /// A section's colour, COLORREF; none is OneNote's None.
117117 Color(Option<u32>),
118118 Sync,
119 NewNotebook,
120119 /// Moves a notebook up the notebook list, or down.
121120 Raise(bool),
122121 Properties,
......@@ -154,7 +153,6 @@ impl Action {
154153 Action::Theme => art::STYLES,
155154 Action::Colors => art::SECTION_COLOR,
156155 Action::Sync => art::SYNC_NOW,
157 Action::NewNotebook => art::NEW_NOTEBOOK,
158156 Action::Raise(true) => art::MOVE_UP,
159157 Action::Raise(false) => art::MOVE_DOWN,
160158 Action::Properties => art::PROPERTIES,
......@@ -335,9 +333,9 @@ impl State {
335333 };
336334 let nowhere = || self.destinations(target).is_empty();
337335 // Last on a page, section or notebook, under a rule.
338 let styles = || {
336 let styles = |library: &Library| {
339337 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)
341339 };
342340 match target {
343341 // OneNote's Move or Copy takes a page out of the bin; nothing else changes it.
......@@ -369,7 +367,7 @@ impl State {
369367 // Levels and versions are known once the section is open.
370368 let Some(session) = self.session.as_ref().filter(|_| self.open(library, path))
371369 else {
372 actions.push(styles());
370 actions.push(styles(library));
373371 return actions;
374372 };
375373 let pages = &session.pages;
......@@ -398,12 +396,13 @@ impl State {
398396 session.page_versions(*space).is_empty(),
399397 true,
400398 ),
401 styles(),
399 styles(library),
402400 ]);
403401 actions
404402 }
405403 Target::Section { library, path } | Target::Group { library, path } => {
406404 let section = matches!(target, Target::Section { .. });
405 let cataloged = library.catalog().is_some();
407406 let mut actions = vec![item(Action::Rename, "Rename", false, false)];
408407 if section {
409408 actions.push(item(Action::SaveAs, "Save As", false, false));
......@@ -411,15 +410,21 @@ impl State {
411410 actions.extend([
412411 item(Action::Delete, "Delete", false, false),
413412 item(Action::Move, "Move", nowhere(), false),
414 item(Action::NewSection, "New Section", false, true),
415 item(Action::NewGroup, "New Section Group", false, false),
413 ]);
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),
416421 ]);
417422 if section {
418423 actions.extend([
419424 item(
420425 Action::Password,
421426 "Password Protect This Section",
422 library.catalog().is_none(),
427 !cataloged,
423428 true,
424429 ),
425430 item(Action::Colors, "Section Color", false, false),
......@@ -429,7 +434,12 @@ impl State {
429434 section_file(library, path).is_none(),
430435 false,
431436 ),
432 styles(),
437 item(
438 Action::Theme,
439 commands::command(commands::Id::Themes).title,
440 library.section_identity(path).is_none(),
441 true,
442 ),
433443 ]);
434444 }
435445 actions
......@@ -467,12 +477,16 @@ impl State {
467477 library.catalog().is_none() || library.joined.is_some(),
468478 false,
469479 ),
470 item(Action::NewSection, "New Section", false, true),
471 item(Action::NewGroup, "New Section Group", false, false),
472480 item(
473 Action::NewNotebook,
474 commands::command(commands::Id::NewNotebook).title,
475 false,
481 Action::NewSection,
482 "New Section",
483 library.catalog().is_none(),
484 true,
485 ),
486 item(
487 Action::NewGroup,
488 "New Section Group",
489 library.catalog().is_none(),
476490 false,
477491 ),
478492 item(
......@@ -511,7 +525,7 @@ impl State {
511525 library.catalog().is_none(),
512526 false,
513527 ),
514 styles(),
528 styles(library),
515529 ]
516530 }
517531 Target::Closed(_) | Target::Server(_) => {
......@@ -779,6 +793,14 @@ impl State {
779793 library,
780794 Structure::Color { path, color },
781795 )),
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 }
782804 (Target::Section { library, path }, Action::Reveal) => {
783805 if let Some(file) = section_file(&library, &path) {
784806 platform::show_file(&file);
......@@ -825,10 +847,6 @@ impl State {
825847 }
826848 None
827849 }
828 (Target::Notebook(_), Action::NewNotebook) => {
829 self.choose(Choice::Command(commands::Id::NewNotebook));
830 None
831 }
832850 (Target::Notebook(library), Action::Raise(up)) => {
833851 let at = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, &library));
834852 let to = at.and_then(|at| if up { at.checked_sub(1) } else { Some(at + 1) });