authorclover caruso <git@paperclover.net> 2026-10-10 19:19:21-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:05-07:00
log549549b097928ce56e2cc8b8e07ba3530d8e418b
treefec8b2094d8899bc0e81c05cd6d34c140a35596a
parent773ac767abacd8ff5c3f32991045fca1cd000278
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix: offer targeted mark-as-read actions in context menus

Expose the existing Mark as Read title and artwork on page and section context menus and the target command palette. Mark only the clicked target, without opening another page, and keep the action idempotent. Assert both menus expose the action in the accessibility replay. Fixes #106 Assisted-by: gpt-6.1-sol

2 files changed, 45 insertions(+), 0 deletions(-)

crates/snowbound/src/menus.rs+42
......@@ -119,6 +119,7 @@ pub enum Action {
119119 /// Shows the notebook's recycle bin.
120120 RecycleBin,
121121 EmptyRecycleBin,
122 MarkRead,
122123 MarkNotebookRead,
123124 CopyLink,
124125 NewPage,
......@@ -162,6 +163,7 @@ impl Action {
162163 Action::SaveAs => art::SAVE,
163164 Action::RecycleBin => art::RECYCLE_BIN,
164165 Action::EmptyRecycleBin => art::EMPTY_RECYCLE_BIN,
166 Action::MarkRead => art::MARK_READ,
165167 Action::MarkNotebookRead => art::MARK_NOTEBOOK_READ,
166168 Action::CopyLink => art::COPY_LINK,
167169 Action::NewPage => art::NEW_PAGE,
......@@ -456,11 +458,21 @@ impl State {
456458 path,
457459 space,
458460 } => {
461 let unread = library
462 .section_identity(path)
463 .and_then(|section| self.reads.pages(&library.location, section))
464 .is_some_and(|pages| pages.contains(space));
459465 let mut actions = vec![
460466 item(Action::Rename, "Rename", false, false),
461467 item(Action::Delete, "Delete", false, false),
462468 item(Action::Move, "Move to Section", nowhere(), false),
463469 item(Action::CopyLink, "Copy Link to Page", false, true),
470 item(
471 Action::MarkRead,
472 commands::command(commands::Id::MarkRead).title,
473 !unread,
474 true,
475 ),
464476 item(Action::NewPage, "New Page", false, true),
465477 // OneNote offers this beside New Page, which is a plain + here.
466478 item(Action::NewSubpage, "New Subpage", false, false),
......@@ -521,6 +533,16 @@ impl State {
521533 if section {
522534 let link = self.section_link(library, path).is_none();
523535 actions.push(item(Action::CopyLink, "Copy Link to Section", link, true));
536 let unread = library
537 .section_identity(path)
538 .and_then(|section| self.reads.pages(&library.location, section))
539 .is_some_and(|pages| !pages.is_empty());
540 actions.push(item(
541 Action::MarkRead,
542 commands::command(commands::Id::MarkRead).title,
543 !unread,
544 true,
545 ));
524546 }
525547 if section {
526548 let [mut first, rest] = new;
......@@ -807,6 +829,26 @@ impl State {
807829 }
808830 (Target::NewPage(title), _) => Some(Command::NewPage { under: None, title }),
809831 (Target::Text(text), _) => Some(Command::Text(text)),
832 (
833 Target::Page {
834 library,
835 path,
836 space,
837 },
838 Action::MarkRead,
839 ) => {
840 if let Some(section) = library.section_identity(&path) {
841 self.reads
842 .mark_read(&library.location, section, Some(space));
843 }
844 None
845 }
846 (Target::Section { library, path }, Action::MarkRead) => {
847 if let Some(section) = library.section_identity(&path) {
848 self.reads.mark_read(&library.location, section, None);
849 }
850 None
851 }
810852 (
811853 Target::Page {
812854 library,
crates/snowbound/tests/replay.rs+3
......@@ -261,6 +261,9 @@ fn every_context_menu_action_is_a_palette_command() {
261261 for (noun, tree) in ["", "page", "section", "notebook"].into_iter().zip(&menus) {
262262 let items = menu_items(tree);
263263 assert!(items.len() > 4, "{tree}");
264 if matches!(noun, "page" | "section") {
265 assert!(items.contains(&"Mark as Read"), "{tree}");
266 }
264267 // The sync status heads the notebook's.
265268 let skip = usize::from(noun == "notebook");
266269 labels.extend(items[skip..].iter().map(|label| (noun, label.to_string())));