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 {...@@ -119,6 +119,7 @@ pub enum Action {
119 /// Shows the notebook's recycle bin.119 /// Shows the notebook's recycle bin.
120 RecycleBin,120 RecycleBin,
121 EmptyRecycleBin,121 EmptyRecycleBin,
122 MarkRead,
122 MarkNotebookRead,123 MarkNotebookRead,
123 CopyLink,124 CopyLink,
124 NewPage,125 NewPage,
...@@ -162,6 +163,7 @@ impl Action {...@@ -162,6 +163,7 @@ impl Action {
162 Action::SaveAs => art::SAVE,163 Action::SaveAs => art::SAVE,
163 Action::RecycleBin => art::RECYCLE_BIN,164 Action::RecycleBin => art::RECYCLE_BIN,
164 Action::EmptyRecycleBin => art::EMPTY_RECYCLE_BIN,165 Action::EmptyRecycleBin => art::EMPTY_RECYCLE_BIN,
166 Action::MarkRead => art::MARK_READ,
165 Action::MarkNotebookRead => art::MARK_NOTEBOOK_READ,167 Action::MarkNotebookRead => art::MARK_NOTEBOOK_READ,
166 Action::CopyLink => art::COPY_LINK,168 Action::CopyLink => art::COPY_LINK,
167 Action::NewPage => art::NEW_PAGE,169 Action::NewPage => art::NEW_PAGE,
...@@ -456,11 +458,21 @@ impl State {...@@ -456,11 +458,21 @@ impl State {
456 path,458 path,
457 space,459 space,
458 } => {460 } => {
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));
459 let mut actions = vec![465 let mut actions = vec![
460 item(Action::Rename, "Rename", false, false),466 item(Action::Rename, "Rename", false, false),
461 item(Action::Delete, "Delete", false, false),467 item(Action::Delete, "Delete", false, false),
462 item(Action::Move, "Move to Section", nowhere(), false),468 item(Action::Move, "Move to Section", nowhere(), false),
463 item(Action::CopyLink, "Copy Link to Page", false, true),469 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 ),
464 item(Action::NewPage, "New Page", false, true),476 item(Action::NewPage, "New Page", false, true),
465 // OneNote offers this beside New Page, which is a plain + here.477 // OneNote offers this beside New Page, which is a plain + here.
466 item(Action::NewSubpage, "New Subpage", false, false),478 item(Action::NewSubpage, "New Subpage", false, false),
...@@ -521,6 +533,16 @@ impl State {...@@ -521,6 +533,16 @@ impl State {
521 if section {533 if section {
522 let link = self.section_link(library, path).is_none();534 let link = self.section_link(library, path).is_none();
523 actions.push(item(Action::CopyLink, "Copy Link to Section", link, true));535 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 ));
524 }546 }
525 if section {547 if section {
526 let [mut first, rest] = new;548 let [mut first, rest] = new;
...@@ -807,6 +829,26 @@ impl State {...@@ -807,6 +829,26 @@ impl State {
807 }829 }
808 (Target::NewPage(title), _) => Some(Command::NewPage { under: None, title }),830 (Target::NewPage(title), _) => Some(Command::NewPage { under: None, title }),
809 (Target::Text(text), _) => Some(Command::Text(text)),831 (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 }
810 (852 (
811 Target::Page {853 Target::Page {
812 library,854 library,
crates/snowbound/tests/replay.rs+3
...@@ -261,6 +261,9 @@ fn every_context_menu_action_is_a_palette_command() {...@@ -261,6 +261,9 @@ fn every_context_menu_action_is_a_palette_command() {
261 for (noun, tree) in ["", "page", "section", "notebook"].into_iter().zip(&menus) {261 for (noun, tree) in ["", "page", "section", "notebook"].into_iter().zip(&menus) {
262 let items = menu_items(tree);262 let items = menu_items(tree);
263 assert!(items.len() > 4, "{tree}");263 assert!(items.len() > 4, "{tree}");
264 if matches!(noun, "page" | "section") {
265 assert!(items.contains(&"Mark as Read"), "{tree}");
266 }
264 // The sync status heads the notebook's.267 // The sync status heads the notebook's.
265 let skip = usize::from(noun == "notebook");268 let skip = usize::from(noun == "notebook");
266 labels.extend(items[skip..].iter().map(|label| (noun, label.to_string())));269 labels.extend(items[skip..].iter().map(|label| (noun, label.to_string())));