From 14ee0f34793591d9120f881a9ea0381b9982ea20 Mon Sep 17 00:00:00 2001 From: Mariusz Woloszyn Date: Sun, 9 Aug 2026 09:36:34 +0000 Subject: [PATCH 1/4] feat: add visited-page navigation history --- README.md | 8 + crates/onenote-viewer/src/app.rs | 212 +++++++++++-- crates/onenote-viewer/src/main.rs | 1 + .../onenote-viewer/src/navigation_history.rs | 291 ++++++++++++++++++ docs/specs/desktop-ui.md | 35 ++- 5 files changed, 524 insertions(+), 23 deletions(-) create mode 100644 crates/onenote-viewer/src/navigation_history.rs diff --git a/README.md b/README.md index a02879d..fc3610a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,14 @@ directory. Additional notebook directories join the same searchable workspace without being moved. Notebook folders copied under the configurable default notebooks location open automatically on the next launch. +## Navigate Viewed Pages + +Use `Alt+Left` and `Alt+Right`, the mouse Back and Forward buttons, or the Back +and Forward commands in the application menu to move through pages viewed in +the current session. History works across all open notebooks, search results, +and internal OneNote page links. Closing a notebook removes its pages from the +history. + ## Screenshots ### Light Theme diff --git a/crates/onenote-viewer/src/app.rs b/crates/onenote-viewer/src/app.rs index 8c59f7f..9735e6c 100644 --- a/crates/onenote-viewer/src/app.rs +++ b/crates/onenote-viewer/src/app.rs @@ -1,4 +1,5 @@ use crate::navigation::{NavigationTarget, NotebookTree}; +use crate::navigation_history::{HistoryDirection, NavigationHistory, PageLocation}; use crate::navigation_state::{location_for_section, preferred_location, SectionLocation}; use crate::settings::{self, AppSettings, ThemePreference}; use crate::worker::{self, Command, Event}; @@ -28,6 +29,8 @@ const PAGE_NAVIGATION_WIDTH: i32 = 280; const COLLAPSED_NAVIGATION_WIDTH: i32 = 42; const NAVIGATION_SEPARATOR_WIDTH: i32 = 1; const SEARCH_RESULTS_WIDTH: i32 = 520; +const MOUSE_BACK_BUTTON: u32 = 8; +const MOUSE_FORWARD_BUTTON: u32 = 9; const APP_ICON_NAME: &str = "io.github.emsi.OneNoteViewer"; const SYMBOLIC_ICON_NAMES: [&str; 19] = [ "onenote-chevron-down-symbolic", @@ -316,6 +319,14 @@ struct State { scene_generation: u64, pending_reveal: Option, restore_target: Option, + history: NavigationHistory, +} + +#[derive(Clone, Copy)] +enum HistoryUpdate { + Record, + Replace, + Preserve, } struct Viewer { @@ -343,6 +354,8 @@ struct Viewer { operation_progress: gtk::ProgressBar, operation_cancel_button: gtk::Button, import_package_action: gio::SimpleAction, + history_back_action: gio::SimpleAction, + history_forward_action: gio::SimpleAction, foreground_operation: RefCell>, next_operation_id: Cell, operation_pulsing: Cell, @@ -396,6 +409,10 @@ impl Viewer { let open_settings = gio::SimpleAction::new("settings", None); let show_about = gio::SimpleAction::new("about", None); let quit = gio::SimpleAction::new("quit", None); + let history_back = gio::SimpleAction::new("history-back", None); + history_back.set_enabled(false); + let history_forward = gio::SimpleAction::new("history-forward", None); + history_forward.set_enabled(false); let close_source = icon_button("onenote-close-symbolic", "Close selected notebook"); let spinner = gtk::Spinner::new(); spinner.set_tooltip_text(Some("Background activity")); @@ -415,6 +432,10 @@ impl Viewer { ); let application_menu = gio::Menu::new(); application_menu.append_section(None, &file_menu); + let navigation_menu = gio::Menu::new(); + navigation_menu.append(Some("Back"), Some("win.history-back")); + navigation_menu.append(Some("Forward"), Some("win.history-forward")); + application_menu.append_section(None, &navigation_menu); let preferences_menu = gio::Menu::new(); preferences_menu.append(Some("Settings"), Some("win.settings")); application_menu.append_section(None, &preferences_menu); @@ -637,6 +658,8 @@ impl Viewer { operation_progress, operation_cancel_button, import_package_action: import_package.clone(), + history_back_action: history_back.clone(), + history_forward_action: history_forward.clone(), foreground_operation: RefCell::default(), next_operation_id: Cell::new(1), operation_pulsing: Cell::new(false), @@ -665,11 +688,15 @@ impl Viewer { viewer.window.add_action(&open_settings); viewer.window.add_action(&show_about); viewer.window.add_action(&quit); + viewer.window.add_action(&history_back); + viewer.window.add_action(&history_forward); application.set_accels_for_action("win.open-file", &["o"]); application.set_accels_for_action("win.open-folder", &["o"]); application.set_accels_for_action("win.import-package", &["i"]); application.set_accels_for_action("win.settings", &["comma"]); application.set_accels_for_action("win.quit", &["q"]); + application.set_accels_for_action("win.history-back", &["Left"]); + application.set_accels_for_action("win.history-forward", &["Right"]); viewer.connect_header( &open_file, &open_folder, @@ -679,6 +706,7 @@ impl Viewer { &close_source, ); viewer.connect_about(&show_about); + viewer.connect_history(); viewer.connect_system_theme(); viewer.connect_operation_activity(); viewer.connect_zoom(&zoom_out, &zoom_in, &zoom_reset); @@ -739,6 +767,43 @@ impl Viewer { }); } + fn connect_history(self: &Rc) { + let weak = Rc::downgrade(self); + self.history_back_action.connect_activate(move |_, _| { + if let Some(viewer) = weak.upgrade() { + viewer.navigate_history(HistoryDirection::Back); + } + }); + let weak = Rc::downgrade(self); + self.history_forward_action.connect_activate(move |_, _| { + if let Some(viewer) = weak.upgrade() { + viewer.navigate_history(HistoryDirection::Forward); + } + }); + + let mouse_back = gtk::GestureClick::new(); + mouse_back.set_button(MOUSE_BACK_BUTTON); + mouse_back.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = Rc::downgrade(self); + mouse_back.connect_released(move |_, _, _, _| { + if let Some(viewer) = weak.upgrade() { + viewer.history_back_action.activate(None); + } + }); + self.window.add_controller(mouse_back); + + let mouse_forward = gtk::GestureClick::new(); + mouse_forward.set_button(MOUSE_FORWARD_BUTTON); + mouse_forward.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = Rc::downgrade(self); + mouse_forward.connect_released(move |_, _, _, _| { + if let Some(viewer) = weak.upgrade() { + viewer.history_forward_action.activate(None); + } + }); + self.window.add_controller(mouse_forward); + } + fn connect_header( self: &Rc, open_file: &gio::SimpleAction, @@ -1106,7 +1171,7 @@ impl Viewer { }; if let Some((source_id, location)) = destination { self.cancel_pending_restore(); - self.activate_location(&source_id, &location, None); + self.activate_location(&source_id, &location, None, HistoryUpdate::Record); } else { self.show_error( "OneNote page is not available", @@ -1517,6 +1582,12 @@ impl Viewer { .map(|source| source.loaded.notebook.clone()) .expect("inserted source"); let active_source = state.active.as_ref().map(|active| active.source.clone()); + { + let State { + sources, history, .. + } = &mut *state; + history.retain(|location| page_location_exists(sources, location)); + } if restoring { state.restore_target = None; } @@ -1528,7 +1599,14 @@ impl Viewer { .as_ref() .is_none_or(|active| active == &source_id) { - self.activate_source(&source_id); + let history_update = if restoring || active_source.is_none() { + HistoryUpdate::Replace + } else { + HistoryUpdate::Record + }; + self.activate_source(&source_id, history_update); + } else { + self.refresh_history_actions(); } self.schedule_workspace_save(); self.status @@ -1578,14 +1656,14 @@ impl Viewer { match self.notebook_tree.selected_target() { Some(NavigationTarget::Notebook { source_id }) => { self.cancel_pending_restore(); - self.activate_source(&source_id); + self.activate_source(&source_id, HistoryUpdate::Record); } Some(NavigationTarget::Section { source_id, section_id, }) => { self.cancel_pending_restore(); - self.activate_section(&source_id, §ion_id); + self.activate_section(&source_id, §ion_id, HistoryUpdate::Record); } Some(NavigationTarget::Group { .. }) | None => {} } @@ -1596,7 +1674,7 @@ impl Viewer { return; } self.cancel_pending_restore(); - self.activate_page(position as usize, None); + self.activate_page(position as usize, None, HistoryUpdate::Record); } fn result_selection_changed(self: &Rc, position: u32) { @@ -1607,7 +1685,48 @@ impl Viewer { self.activate_result(position as usize); } - fn activate_source(self: &Rc, source_id: &SourceId) { + fn navigate_history(self: &Rc, direction: HistoryDirection) { + self.cancel_pending_restore(); + let target = { + let mut state = self.state.borrow_mut(); + let State { + sources, history, .. + } = &mut *state; + history.retain(|location| page_location_exists(sources, location)); + history.step(direction) + }; + self.refresh_history_actions(); + let Some(target) = target else { + return; + }; + + self.synchronize_selections(|| self.result_selection.set_selected(NO_SELECTION)); + let activated = self.activate_location( + &target.source, + &SectionLocation { + section_id: target.section.clone(), + page_id: Some(target.page.clone()), + }, + None, + HistoryUpdate::Preserve, + ); + if !activated { + let mut state = self.state.borrow_mut(); + state.history.retain(|location| location != &target); + drop(state); + self.refresh_history_actions(); + } + } + + fn refresh_history_actions(&self) { + let state = self.state.borrow(); + self.history_back_action + .set_enabled(state.history.can_go_back()); + self.history_forward_action + .set_enabled(state.history.can_go_forward()); + } + + fn activate_source(self: &Rc, source_id: &SourceId, history_update: HistoryUpdate) { let location = { let mut state = self.state.borrow_mut(); let Some(source) = state @@ -1626,7 +1745,7 @@ impl Viewer { location }; if let Some(location) = location { - self.activate_location(source_id, &location, None); + self.activate_location(source_id, &location, None, history_update); } else { self.clear_page_content(); self.synchronize_selections(|| { @@ -1637,7 +1756,12 @@ impl Viewer { } } - fn activate_section(self: &Rc, source_id: &SourceId, section_id: &SectionId) { + fn activate_section( + self: &Rc, + source_id: &SourceId, + section_id: &SectionId, + history_update: HistoryUpdate, + ) { let location = { let state = self.state.borrow(); state @@ -1653,7 +1777,7 @@ impl Viewer { }) }; if let Some(location) = location { - self.activate_location(source_id, &location, None); + self.activate_location(source_id, &location, None, history_update); } } @@ -1662,7 +1786,8 @@ impl Viewer { source_id: &SourceId, requested: &SectionLocation, reveal: Option, - ) { + history_update: HistoryUpdate, + ) -> bool { let (pages, location) = { let state = self.state.borrow(); let Some(source) = state @@ -1670,14 +1795,14 @@ impl Viewer { .iter() .find(|source| source.loaded.notebook.source_id == *source_id) else { - return; + return false; }; let Some(location) = location_for_section( &source.loaded.notebook, Some(requested), &requested.section_id, ) else { - return; + return false; }; let pages = source .loaded @@ -1699,7 +1824,7 @@ impl Viewer { .iter_mut() .find(|source| source.loaded.notebook.source_id == *source_id) else { - return; + return false; }; source.last_location = Some(location.clone()); state.active = Some(ActiveLocation { @@ -1735,14 +1860,20 @@ impl Viewer { }); if let Some(position) = page_position { - self.activate_page(position, reveal); + self.activate_page(position, reveal, history_update) } else { self.clear_rendered_page(); self.schedule_workspace_save(); + false } } - fn activate_page(self: &Rc, position: usize, reveal: Option) { + fn activate_page( + self: &Rc, + position: usize, + reveal: Option, + history_update: HistoryUpdate, + ) -> bool { let value = (|| { let state = self.state.borrow(); let row = state.pages.get(position)?; @@ -1772,11 +1903,11 @@ impl Viewer { let Some((source_id, section_id, page_id, page, loaded, notebook_name, section_path)) = value else { - return; + return false; }; let location = SectionLocation { section_id: section_id.clone(), - page_id: Some(page_id), + page_id: Some(page_id.clone()), }; { let mut state = self.state.borrow_mut(); @@ -1791,6 +1922,16 @@ impl Viewer { source: source_id.clone(), section: Some(location), }); + let history_location = PageLocation { + source: source_id.clone(), + section: section_id.clone(), + page: page_id, + }; + match history_update { + HistoryUpdate::Record => state.history.record(history_location), + HistoryUpdate::Replace => state.history.replace_current(history_location), + HistoryUpdate::Preserve => {} + } } self.synchronize_selections(|| { self.notebook_tree.select_section(&source_id, §ion_id); @@ -1825,6 +1966,8 @@ impl Viewer { *self.scene_cancel.borrow_mut() = Some(cancel.clone()); self.set_busy("Laying out page"); worker::build_scene(generation, page, cancel, self.events.clone()); + self.refresh_history_actions(); + true } fn clear_page_content(&self) { @@ -1926,6 +2069,7 @@ impl Viewer { object_id: hit.object_id, bounds, }), + HistoryUpdate::Record, ); } @@ -1945,7 +2089,15 @@ impl Viewer { state.active = None; state.pages.clear(); state.restore_target = None; - (position, state.sources.remove(position)) + let removed = state.sources.remove(position); + { + let State { + sources, history, .. + } = &mut *state; + history.retain(|location| page_location_exists(sources, location)); + } + let history_target = state.history.current().cloned(); + (position, removed, history_target) }; let removed_source_id = removed.1.loaded.notebook.source_id.clone(); let _ignored = self @@ -1958,6 +2110,20 @@ impl Viewer { clear_model(&self.page_model); }); self.clear_rendered_page(); + self.refresh_history_actions(); + if let Some(target) = removed.2.as_ref() { + if self.activate_location( + &target.source, + &SectionLocation { + section_id: target.section.clone(), + page_id: Some(target.page.clone()), + }, + None, + HistoryUpdate::Preserve, + ) { + return; + } + } let fallback = { let state = self.state.borrow(); let position = removed.0.min(state.sources.len().saturating_sub(1)); @@ -1967,7 +2133,7 @@ impl Viewer { .map(|source| source.loaded.notebook.source_id.clone()) }; if let Some(source_id) = fallback { - self.activate_source(&source_id); + self.activate_source(&source_id, HistoryUpdate::Replace); } else { self.status.set_label("No notebooks open"); self.schedule_workspace_save(); @@ -3207,6 +3373,14 @@ fn same_source_path(left: &std::path::Path, right: &std::path::Path) -> bool { } } +fn page_location_exists(sources: &[Source], location: &PageLocation) -> bool { + sources + .iter() + .find(|source| source.loaded.notebook.source_id == location.source) + .and_then(|source| source.loaded.notebook.section(&location.section)) + .is_some_and(|section| section.pages.iter().any(|page| page.id == location.page)) +} + fn restore_location_for_source( target: Option<&PersistedPageLocation>, source_id: &SourceId, diff --git a/crates/onenote-viewer/src/main.rs b/crates/onenote-viewer/src/main.rs index f144002..7fe27db 100644 --- a/crates/onenote-viewer/src/main.rs +++ b/crates/onenote-viewer/src/main.rs @@ -6,6 +6,7 @@ mod app; mod attachment; mod dialogs; mod navigation; +mod navigation_history; mod navigation_state; mod settings; #[cfg(test)] diff --git a/crates/onenote-viewer/src/navigation_history.rs b/crates/onenote-viewer/src/navigation_history.rs new file mode 100644 index 0000000..2d29b2e --- /dev/null +++ b/crates/onenote-viewer/src/navigation_history.rs @@ -0,0 +1,291 @@ +use onenote_core::{PageId, SectionId, SourceId}; + +const MAX_HISTORY_ENTRIES: usize = 256; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PageLocation { + pub(crate) source: SourceId, + pub(crate) section: SectionId, + pub(crate) page: PageId, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HistoryDirection { + Back, + Forward, +} + +#[derive(Debug)] +pub(crate) struct NavigationHistory { + entries: Vec, + current: Option, + capacity: usize, +} + +impl Default for NavigationHistory { + fn default() -> Self { + Self::with_capacity(MAX_HISTORY_ENTRIES) + } +} + +impl NavigationHistory { + fn with_capacity(capacity: usize) -> Self { + Self { + entries: Vec::new(), + current: None, + capacity: capacity.max(1), + } + } + + pub(crate) fn current(&self) -> Option<&PageLocation> { + self.current.and_then(|position| self.entries.get(position)) + } + + pub(crate) fn can_go_back(&self) -> bool { + self.current.is_some_and(|position| position > 0) + } + + pub(crate) fn can_go_forward(&self) -> bool { + self.current + .is_some_and(|position| position + 1 < self.entries.len()) + } + + pub(crate) fn record(&mut self, location: PageLocation) { + if self.current() == Some(&location) { + return; + } + if let Some(position) = self.current { + self.entries.truncate(position + 1); + } else { + self.entries.clear(); + } + self.entries.push(location); + if self.entries.len() > self.capacity { + let excess = self.entries.len() - self.capacity; + self.entries.drain(..excess); + } + self.current = Some(self.entries.len() - 1); + } + + pub(crate) fn replace_current(&mut self, location: PageLocation) { + let Some(position) = self.current else { + self.record(location); + return; + }; + if self.entries[position] == location { + return; + } + self.entries[position] = location; + self.coalesce_current(); + } + + pub(crate) fn step(&mut self, direction: HistoryDirection) -> Option { + let current = self.current?; + let next = match direction { + HistoryDirection::Back => current.checked_sub(1)?, + HistoryDirection::Forward => { + (current + 1 < self.entries.len()).then_some(current + 1)? + } + }; + self.current = Some(next); + self.entries.get(next).cloned() + } + + pub(crate) fn retain(&mut self, mut keep: impl FnMut(&PageLocation) -> bool) { + let previous_current = self.current; + let mut retained = Vec::with_capacity(self.entries.len()); + let mut retained_at_or_before_current = None; + for (position, entry) in self.entries.drain(..).enumerate() { + if keep(&entry) { + if retained.last() == Some(&entry) { + if previous_current.is_some_and(|current| position <= current) { + retained_at_or_before_current = Some(retained.len() - 1); + } + continue; + } + if previous_current.is_some_and(|current| position <= current) { + retained_at_or_before_current = Some(retained.len()); + } + retained.push(entry); + } + } + self.entries = retained; + self.current = if self.entries.is_empty() { + None + } else { + retained_at_or_before_current.or(Some(0)) + }; + self.coalesce_current(); + } + + fn coalesce_current(&mut self) { + let Some(mut position) = self.current else { + return; + }; + if position > 0 && self.entries[position - 1] == self.entries[position] { + self.entries.remove(position); + position -= 1; + } + if position + 1 < self.entries.len() && self.entries[position + 1] == self.entries[position] + { + self.entries.remove(position + 1); + } + self.current = Some(position); + } +} + +#[cfg(test)] +mod tests { + use super::{HistoryDirection, NavigationHistory, PageLocation}; + use onenote_core::{PageId, SectionId, SourceId}; + + #[test] + fn traverses_back_and_forward_without_creating_entries() { + let mut history = NavigationHistory::default(); + history.record(location("source", "section", "one")); + history.record(location("source", "section", "two")); + history.record(location("source", "section", "three")); + + assert_eq!( + history.step(HistoryDirection::Back), + Some(location("source", "section", "two")) + ); + assert_eq!( + history.step(HistoryDirection::Back), + Some(location("source", "section", "one")) + ); + assert_eq!(history.step(HistoryDirection::Back), None); + assert_eq!( + history.step(HistoryDirection::Forward), + Some(location("source", "section", "two")) + ); + assert!(history.can_go_back()); + assert!(history.can_go_forward()); + } + + #[test] + fn new_navigation_after_back_discards_the_forward_branch() { + let mut history = NavigationHistory::default(); + history.record(location("source", "section", "one")); + history.record(location("source", "section", "two")); + history.record(location("source", "section", "three")); + history.step(HistoryDirection::Back); + + history.record(location("source", "section", "new")); + + assert!(!history.can_go_forward()); + assert_eq!( + history.step(HistoryDirection::Back), + Some(location("source", "section", "two")) + ); + } + + #[test] + fn consecutive_duplicate_visits_are_ignored() { + let mut history = NavigationHistory::default(); + history.record(location("source", "section", "page")); + history.record(location("source", "section", "page")); + + assert!(!history.can_go_back()); + assert!(!history.can_go_forward()); + } + + #[test] + fn capacity_evicts_the_oldest_entries() { + let mut history = NavigationHistory::with_capacity(2); + history.record(location("source", "section", "one")); + history.record(location("source", "section", "two")); + history.record(location("source", "section", "three")); + + assert_eq!( + history.step(HistoryDirection::Back), + Some(location("source", "section", "two")) + ); + assert_eq!(history.step(HistoryDirection::Back), None); + } + + #[test] + fn replacing_a_provisional_page_does_not_add_history() { + let mut history = NavigationHistory::default(); + history.replace_current(location("provisional", "section", "page")); + history.replace_current(location("restored", "section", "page")); + + assert_eq!( + history.current(), + Some(&location("restored", "section", "page")) + ); + assert!(!history.can_go_back()); + } + + #[test] + fn replacing_with_an_adjacent_page_coalesces_duplicates() { + let mut history = NavigationHistory::default(); + history.record(location("source", "section", "one")); + history.record(location("source", "section", "two")); + + history.replace_current(location("source", "section", "one")); + + assert!(!history.can_go_back()); + assert_eq!( + history.current(), + Some(&location("source", "section", "one")) + ); + } + + #[test] + fn removing_a_source_selects_the_nearest_surviving_page() { + let mut history = NavigationHistory::default(); + history.record(location("first", "section", "one")); + history.record(location("removed", "section", "two")); + history.record(location("third", "section", "three")); + history.step(HistoryDirection::Back); + + history.retain(|entry| entry.source != SourceId::new("removed")); + + assert_eq!( + history.current(), + Some(&location("first", "section", "one")) + ); + assert!(history.can_go_forward()); + assert_eq!( + history.step(HistoryDirection::Forward), + Some(location("third", "section", "three")) + ); + } + + #[test] + fn removing_all_entries_resets_the_cursor() { + let mut history = NavigationHistory::default(); + history.record(location("removed", "section", "page")); + + history.retain(|_| false); + + assert_eq!(history.current(), None); + assert!(!history.can_go_back()); + assert!(!history.can_go_forward()); + } + + #[test] + fn pruning_an_entry_does_not_leave_adjacent_duplicates() { + let mut history = NavigationHistory::default(); + history.record(location("source", "section", "same")); + history.record(location("removed", "section", "middle")); + history.record(location("source", "section", "same")); + + history.retain(|entry| entry.source != SourceId::new("removed")); + + assert_eq!( + history.current(), + Some(&location("source", "section", "same")) + ); + assert!(!history.can_go_back()); + } + + fn location(source: &str, section: &str, page: &str) -> PageLocation { + PageLocation { + source: SourceId::new(source), + section: SectionId::new(section), + page: PageId::new(page), + } + } +} diff --git a/docs/specs/desktop-ui.md b/docs/specs/desktop-ui.md index 5ecf680..1a1d522 100644 --- a/docs/specs/desktop-ui.md +++ b/docs/specs/desktop-ui.md @@ -16,8 +16,8 @@ The header contains: bundled application icon rather than a second manually packed image; - the OneNote Viewer identity; - global notebook search; -- one main menu beside search for file, import, settings, and application - commands; and +- one main menu beside search for file, import, page-history navigation, + settings, and application commands; and - native minimize, maximize, and close controls supplied by the desktop. Frequently repeated, context-specific controls may remain beside their @@ -79,8 +79,8 @@ Persisted identifiers are hints and must be validated against the loaded notebook. A missing page falls back to the first page in its section, a missing section falls back to the first available section and page, and an unavailable source leaves the normal deterministic fallback active. Closing a notebook -removes it from both the workspace and restoration state; history alone must -never reopen a source the user closed. +removes it from both the workspace and restoration state; a navigation-history +entry must never reopen a source the user closed. Workspace navigation writes are debounced and atomically published, with a final flush during clean shutdown. The workspace currently persists only the @@ -97,6 +97,33 @@ Standard window-control fallbacks and the application icon are bundled so the native, AppImage, and Flatpak headers do not depend on different host icon inventories. +## Page Navigation History + +The viewer maintains a bounded, session-only history of committed page +selections. Each entry contains stable source, section, and page identifiers; +it contains no title, content, search query, reveal target, scroll position, +zoom, or tree state. Workspace restoration continues to persist only the last +active page and does not serialize the history. + +Deliberate navigation from the notebook tree, page list, search results, or an +internal OneNote page link records the resulting page. Consecutive visits to +the same page are coalesced. Navigating normally after moving backward removes +the former forward branch. Provisional startup selection and later restoration +replace one current entry so asynchronous load order does not appear as user +history. + +`Alt+Left` moves backward and `Alt+Right` moves forward, matching OneNote. +Conventional mouse Back and Forward buttons invoke the same application +actions. The actions are also present in the main menu and are disabled when +there is no valid destination. + +History traversal commits through the ordinary page activation path. It +synchronizes the notebook, section, and page selections, cancels superseded +scene construction, and does not add another history entry. Closing a notebook +removes all of its entries. Reloading a source removes entries for pages or +sections that no longer exist. Traversal skips invalid entries and never loads +or reopens a source merely because it occurs in history. + ## Page Context Header The page title and complete notebook/section-group/section context are From 04d7cb9aaa2cc6d723a6aa7eaa76ede2b37e6089 Mon Sep 17 00:00:00 2001 From: Mariusz Woloszyn Date: Sun, 9 Aug 2026 13:39:26 +0000 Subject: [PATCH 2/4] fix: support semantic mouse navigation buttons --- crates/onenote-viewer/src/app.rs | 39 +++++++++---------- .../onenote-viewer/src/navigation_history.rs | 31 +++++++++++++++ docs/specs/desktop-ui.md | 8 ++-- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/crates/onenote-viewer/src/app.rs b/crates/onenote-viewer/src/app.rs index 9735e6c..6ba2a1b 100644 --- a/crates/onenote-viewer/src/app.rs +++ b/crates/onenote-viewer/src/app.rs @@ -29,8 +29,6 @@ const PAGE_NAVIGATION_WIDTH: i32 = 280; const COLLAPSED_NAVIGATION_WIDTH: i32 = 42; const NAVIGATION_SEPARATOR_WIDTH: i32 = 1; const SEARCH_RESULTS_WIDTH: i32 = 520; -const MOUSE_BACK_BUTTON: u32 = 8; -const MOUSE_FORWARD_BUTTON: u32 = 9; const APP_ICON_NAME: &str = "io.github.emsi.OneNoteViewer"; const SYMBOLIC_ICON_NAMES: [&str; 19] = [ "onenote-chevron-down-symbolic", @@ -695,8 +693,8 @@ impl Viewer { application.set_accels_for_action("win.import-package", &["i"]); application.set_accels_for_action("win.settings", &["comma"]); application.set_accels_for_action("win.quit", &["q"]); - application.set_accels_for_action("win.history-back", &["Left"]); - application.set_accels_for_action("win.history-forward", &["Right"]); + application.set_accels_for_action("win.history-back", &["Left", "Back"]); + application.set_accels_for_action("win.history-forward", &["Right", "Forward"]); viewer.connect_header( &open_file, &open_folder, @@ -781,27 +779,26 @@ impl Viewer { } }); - let mouse_back = gtk::GestureClick::new(); - mouse_back.set_button(MOUSE_BACK_BUTTON); - mouse_back.set_propagation_phase(gtk::PropagationPhase::Capture); + let mouse_history = gtk::GestureClick::new(); + mouse_history.set_button(0); + mouse_history.set_propagation_phase(gtk::PropagationPhase::Capture); let weak = Rc::downgrade(self); - mouse_back.connect_released(move |_, _, _, _| { - if let Some(viewer) = weak.upgrade() { - viewer.history_back_action.activate(None); - } - }); - self.window.add_controller(mouse_back); - - let mouse_forward = gtk::GestureClick::new(); - mouse_forward.set_button(MOUSE_FORWARD_BUTTON); - mouse_forward.set_propagation_phase(gtk::PropagationPhase::Capture); - let weak = Rc::downgrade(self); - mouse_forward.connect_released(move |_, _, _, _| { + mouse_history.connect_pressed(move |gesture, _, _, _| { + let Some(direction) = HistoryDirection::from_mouse_button(gesture.current_button()) + else { + let _ignored = gesture.set_state(gtk::EventSequenceState::Denied); + gesture.reset(); + return; + }; + let _ignored = gesture.set_state(gtk::EventSequenceState::Claimed); if let Some(viewer) = weak.upgrade() { - viewer.history_forward_action.activate(None); + match direction { + HistoryDirection::Back => viewer.history_back_action.activate(None), + HistoryDirection::Forward => viewer.history_forward_action.activate(None), + } } }); - self.window.add_controller(mouse_forward); + self.window.add_controller(mouse_history); } fn connect_header( diff --git a/crates/onenote-viewer/src/navigation_history.rs b/crates/onenote-viewer/src/navigation_history.rs index 2d29b2e..768c5cb 100644 --- a/crates/onenote-viewer/src/navigation_history.rs +++ b/crates/onenote-viewer/src/navigation_history.rs @@ -15,6 +15,18 @@ pub(crate) enum HistoryDirection { Forward, } +impl HistoryDirection { + pub(crate) fn from_mouse_button(button: u32) -> Option { + // GDK exposes both X11-compatible side/extra buttons and Linux's + // semantic forward/back buttons as distinct numeric identifiers. + match button { + 8 | 11 => Some(Self::Back), + 9 | 10 => Some(Self::Forward), + _ => None, + } + } +} + #[derive(Debug)] pub(crate) struct NavigationHistory { entries: Vec, @@ -163,6 +175,25 @@ mod tests { assert!(history.can_go_forward()); } + #[test] + fn classifies_conventional_and_semantic_navigation_buttons() { + for button in [8, 11] { + assert_eq!( + HistoryDirection::from_mouse_button(button), + Some(HistoryDirection::Back) + ); + } + for button in [9, 10] { + assert_eq!( + HistoryDirection::from_mouse_button(button), + Some(HistoryDirection::Forward) + ); + } + for button in [1, 2, 3, 4, 5, 12] { + assert_eq!(HistoryDirection::from_mouse_button(button), None); + } + } + #[test] fn new_navigation_after_back_discards_the_forward_branch() { let mut history = NavigationHistory::default(); diff --git a/docs/specs/desktop-ui.md b/docs/specs/desktop-ui.md index 1a1d522..ac7a2b4 100644 --- a/docs/specs/desktop-ui.md +++ b/docs/specs/desktop-ui.md @@ -113,9 +113,11 @@ replace one current entry so asynchronous load order does not appear as user history. `Alt+Left` moves backward and `Alt+Right` moves forward, matching OneNote. -Conventional mouse Back and Forward buttons invoke the same application -actions. The actions are also present in the main menu and are disabled when -there is no valid destination. +Standard `Back` and `Forward` key events and conventional mouse Back and +Forward buttons invoke the same application actions. Both X11-compatible +side/extra button numbers and Linux semantic back/forward button numbers are +recognized. The actions are also present in the main menu and are disabled +when there is no valid destination. History traversal commits through the ordinary page activation path. It synchronizes the notebook, section, and page selections, cancels superseded From f311ab64b11353803794c777ce6e140702872f74 Mon Sep 17 00:00:00 2001 From: Mariusz Woloszyn Date: Sun, 9 Aug 2026 14:12:54 +0000 Subject: [PATCH 3/4] Revert "fix: support semantic mouse navigation buttons" This reverts commit 04d7cb9aaa2cc6d723a6aa7eaa76ede2b37e6089. --- crates/onenote-viewer/src/app.rs | 39 ++++++++++--------- .../onenote-viewer/src/navigation_history.rs | 31 --------------- docs/specs/desktop-ui.md | 8 ++-- 3 files changed, 24 insertions(+), 54 deletions(-) diff --git a/crates/onenote-viewer/src/app.rs b/crates/onenote-viewer/src/app.rs index 6ba2a1b..9735e6c 100644 --- a/crates/onenote-viewer/src/app.rs +++ b/crates/onenote-viewer/src/app.rs @@ -29,6 +29,8 @@ const PAGE_NAVIGATION_WIDTH: i32 = 280; const COLLAPSED_NAVIGATION_WIDTH: i32 = 42; const NAVIGATION_SEPARATOR_WIDTH: i32 = 1; const SEARCH_RESULTS_WIDTH: i32 = 520; +const MOUSE_BACK_BUTTON: u32 = 8; +const MOUSE_FORWARD_BUTTON: u32 = 9; const APP_ICON_NAME: &str = "io.github.emsi.OneNoteViewer"; const SYMBOLIC_ICON_NAMES: [&str; 19] = [ "onenote-chevron-down-symbolic", @@ -693,8 +695,8 @@ impl Viewer { application.set_accels_for_action("win.import-package", &["i"]); application.set_accels_for_action("win.settings", &["comma"]); application.set_accels_for_action("win.quit", &["q"]); - application.set_accels_for_action("win.history-back", &["Left", "Back"]); - application.set_accels_for_action("win.history-forward", &["Right", "Forward"]); + application.set_accels_for_action("win.history-back", &["Left"]); + application.set_accels_for_action("win.history-forward", &["Right"]); viewer.connect_header( &open_file, &open_folder, @@ -779,26 +781,27 @@ impl Viewer { } }); - let mouse_history = gtk::GestureClick::new(); - mouse_history.set_button(0); - mouse_history.set_propagation_phase(gtk::PropagationPhase::Capture); + let mouse_back = gtk::GestureClick::new(); + mouse_back.set_button(MOUSE_BACK_BUTTON); + mouse_back.set_propagation_phase(gtk::PropagationPhase::Capture); let weak = Rc::downgrade(self); - mouse_history.connect_pressed(move |gesture, _, _, _| { - let Some(direction) = HistoryDirection::from_mouse_button(gesture.current_button()) - else { - let _ignored = gesture.set_state(gtk::EventSequenceState::Denied); - gesture.reset(); - return; - }; - let _ignored = gesture.set_state(gtk::EventSequenceState::Claimed); + mouse_back.connect_released(move |_, _, _, _| { if let Some(viewer) = weak.upgrade() { - match direction { - HistoryDirection::Back => viewer.history_back_action.activate(None), - HistoryDirection::Forward => viewer.history_forward_action.activate(None), - } + viewer.history_back_action.activate(None); + } + }); + self.window.add_controller(mouse_back); + + let mouse_forward = gtk::GestureClick::new(); + mouse_forward.set_button(MOUSE_FORWARD_BUTTON); + mouse_forward.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = Rc::downgrade(self); + mouse_forward.connect_released(move |_, _, _, _| { + if let Some(viewer) = weak.upgrade() { + viewer.history_forward_action.activate(None); } }); - self.window.add_controller(mouse_history); + self.window.add_controller(mouse_forward); } fn connect_header( diff --git a/crates/onenote-viewer/src/navigation_history.rs b/crates/onenote-viewer/src/navigation_history.rs index 768c5cb..2d29b2e 100644 --- a/crates/onenote-viewer/src/navigation_history.rs +++ b/crates/onenote-viewer/src/navigation_history.rs @@ -15,18 +15,6 @@ pub(crate) enum HistoryDirection { Forward, } -impl HistoryDirection { - pub(crate) fn from_mouse_button(button: u32) -> Option { - // GDK exposes both X11-compatible side/extra buttons and Linux's - // semantic forward/back buttons as distinct numeric identifiers. - match button { - 8 | 11 => Some(Self::Back), - 9 | 10 => Some(Self::Forward), - _ => None, - } - } -} - #[derive(Debug)] pub(crate) struct NavigationHistory { entries: Vec, @@ -175,25 +163,6 @@ mod tests { assert!(history.can_go_forward()); } - #[test] - fn classifies_conventional_and_semantic_navigation_buttons() { - for button in [8, 11] { - assert_eq!( - HistoryDirection::from_mouse_button(button), - Some(HistoryDirection::Back) - ); - } - for button in [9, 10] { - assert_eq!( - HistoryDirection::from_mouse_button(button), - Some(HistoryDirection::Forward) - ); - } - for button in [1, 2, 3, 4, 5, 12] { - assert_eq!(HistoryDirection::from_mouse_button(button), None); - } - } - #[test] fn new_navigation_after_back_discards_the_forward_branch() { let mut history = NavigationHistory::default(); diff --git a/docs/specs/desktop-ui.md b/docs/specs/desktop-ui.md index ac7a2b4..1a1d522 100644 --- a/docs/specs/desktop-ui.md +++ b/docs/specs/desktop-ui.md @@ -113,11 +113,9 @@ replace one current entry so asynchronous load order does not appear as user history. `Alt+Left` moves backward and `Alt+Right` moves forward, matching OneNote. -Standard `Back` and `Forward` key events and conventional mouse Back and -Forward buttons invoke the same application actions. Both X11-compatible -side/extra button numbers and Linux semantic back/forward button numbers are -recognized. The actions are also present in the main menu and are disabled -when there is no valid destination. +Conventional mouse Back and Forward buttons invoke the same application +actions. The actions are also present in the main menu and are disabled when +there is no valid destination. History traversal commits through the ordinary page activation path. It synchronizes the notebook, section, and page selections, cancels superseded From 0c2be95faeea578fe13bcb1721c9cc0f8a22b4a1 Mon Sep 17 00:00:00 2001 From: Mariusz Woloszyn Date: Sun, 9 Aug 2026 14:32:17 +0000 Subject: [PATCH 4/4] fix(input): handle history buttons without blocking clicks --- .github/workflows/release.yml | 2 +- crates/onenote-viewer/src/app.rs | 31 ++--- crates/onenote-viewer/src/input.rs | 211 +++++++++++++++++++++++++++++ crates/onenote-viewer/src/main.rs | 1 + docs/specs/desktop-ui.md | 9 +- 5 files changed, 229 insertions(+), 25 deletions(-) create mode 100644 crates/onenote-viewer/src/input.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1399617..7fa185e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Install GTK build dependencies run: | sudo apt-get update - sudo apt-get install --yes libgtk-4-dev xvfb + sudo apt-get install --yes libgtk-4-dev xdotool xvfb - name: Install license audit tool run: cargo +stable install cargo-about --version 0.9.1 --locked --features cli diff --git a/crates/onenote-viewer/src/app.rs b/crates/onenote-viewer/src/app.rs index 9735e6c..4d83e74 100644 --- a/crates/onenote-viewer/src/app.rs +++ b/crates/onenote-viewer/src/app.rs @@ -1,3 +1,4 @@ +use crate::input::{focus_initial_navigation, history_mouse_controller}; use crate::navigation::{NavigationTarget, NotebookTree}; use crate::navigation_history::{HistoryDirection, NavigationHistory, PageLocation}; use crate::navigation_state::{location_for_section, preferred_location, SectionLocation}; @@ -29,8 +30,6 @@ const PAGE_NAVIGATION_WIDTH: i32 = 280; const COLLAPSED_NAVIGATION_WIDTH: i32 = 42; const NAVIGATION_SEPARATOR_WIDTH: i32 = 1; const SEARCH_RESULTS_WIDTH: i32 = 520; -const MOUSE_BACK_BUTTON: u32 = 8; -const MOUSE_FORWARD_BUTTON: u32 = 9; const APP_ICON_NAME: &str = "io.github.emsi.OneNoteViewer"; const SYMBOLIC_ICON_NAMES: [&str; 19] = [ "onenote-chevron-down-symbolic", @@ -106,6 +105,7 @@ pub(crate) fn run(requested_sources: Vec) -> Result<()> { instance.discover(source.clone()); } instance.window.present(); + focus_initial_navigation(instance.window.upcast_ref(), &instance.notebook_tree.view); if let Some(delay) = smoke_quit_delay() { let application = application.clone(); glib::timeout_add_local_once(delay, move || application.quit()); @@ -695,8 +695,8 @@ impl Viewer { application.set_accels_for_action("win.import-package", &["i"]); application.set_accels_for_action("win.settings", &["comma"]); application.set_accels_for_action("win.quit", &["q"]); - application.set_accels_for_action("win.history-back", &["Left"]); - application.set_accels_for_action("win.history-forward", &["Right"]); + application.set_accels_for_action("win.history-back", &["Left", "Back"]); + application.set_accels_for_action("win.history-forward", &["Right", "Forward"]); viewer.connect_header( &open_file, &open_folder, @@ -781,27 +781,16 @@ impl Viewer { } }); - let mouse_back = gtk::GestureClick::new(); - mouse_back.set_button(MOUSE_BACK_BUTTON); - mouse_back.set_propagation_phase(gtk::PropagationPhase::Capture); let weak = Rc::downgrade(self); - mouse_back.connect_released(move |_, _, _, _| { + let mouse_history = history_mouse_controller(move |direction| { if let Some(viewer) = weak.upgrade() { - viewer.history_back_action.activate(None); - } - }); - self.window.add_controller(mouse_back); - - let mouse_forward = gtk::GestureClick::new(); - mouse_forward.set_button(MOUSE_FORWARD_BUTTON); - mouse_forward.set_propagation_phase(gtk::PropagationPhase::Capture); - let weak = Rc::downgrade(self); - mouse_forward.connect_released(move |_, _, _, _| { - if let Some(viewer) = weak.upgrade() { - viewer.history_forward_action.activate(None); + match direction { + HistoryDirection::Back => viewer.history_back_action.activate(None), + HistoryDirection::Forward => viewer.history_forward_action.activate(None), + } } }); - self.window.add_controller(mouse_forward); + self.window.add_controller(mouse_history); } fn connect_header( diff --git a/crates/onenote-viewer/src/input.rs b/crates/onenote-viewer/src/input.rs new file mode 100644 index 0000000..107899c --- /dev/null +++ b/crates/onenote-viewer/src/input.rs @@ -0,0 +1,211 @@ +use crate::navigation_history::HistoryDirection; +use gtk::glib; +use gtk::prelude::*; + +pub(crate) fn history_mouse_controller( + navigate: impl Fn(HistoryDirection) + 'static, +) -> gtk::EventControllerLegacy { + let controller = gtk::EventControllerLegacy::new(); + controller.set_propagation_phase(gtk::PropagationPhase::Capture); + controller.connect_event(move |_, event| { + let Some(button_event) = event.downcast_ref::() else { + return glib::Propagation::Proceed; + }; + let Some(direction) = history_direction_for_mouse_button(button_event.button()) else { + return glib::Propagation::Proceed; + }; + if event.event_type() == gtk::gdk::EventType::ButtonPress { + navigate(direction); + } + // Consume both halves of a recognized history-button click. All + // unrelated events proceed unchanged and never enter gesture state. + glib::Propagation::Stop + }); + controller +} + +pub(crate) fn focus_initial_navigation(window: >k::Window, navigation: >k::ListView) { + gtk::prelude::GtkWindowExt::set_focus(window, Some(navigation)); +} + +fn history_direction_for_mouse_button(button: u32) -> Option { + // Linux exposes both generic side buttons and semantic navigation + // buttons. GDK preserves their conventional 8-11 numbering on X11 + // and Wayland. + match button { + 8 | 11 => Some(HistoryDirection::Back), + 9 | 10 => Some(HistoryDirection::Forward), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::{Cell, RefCell}; + use std::process::Command; + use std::rc::Rc; + + #[test] + fn classifies_conventional_and_semantic_navigation_buttons() { + for button in [8, 11] { + assert_eq!( + history_direction_for_mouse_button(button), + Some(HistoryDirection::Back) + ); + } + for button in [9, 10] { + assert_eq!( + history_direction_for_mouse_button(button), + Some(HistoryDirection::Forward) + ); + } + for button in [1, 2, 3, 4, 5, 6, 7, 12] { + assert_eq!(history_direction_for_mouse_button(button), None); + } + } + + #[test] + fn mouse_history_preserves_primary_clicks_and_handles_navigation_buttons() { + crate::test_support::run_gtk_test(mouse_history_preserves_primary_clicks_gtk); + } + + fn mouse_history_preserves_primary_clicks_gtk() { + if !x11_pointer_injection_available() { + return; + } + + let title = format!("onenote-viewer-mouse-history-{}", std::process::id()); + let primary_clicks = Rc::new(Cell::new(0_u32)); + let target = gtk::Button::with_label("Target"); + let callback_clicks = Rc::clone(&primary_clicks); + target.connect_clicked(move |_| callback_clicks.set(callback_clicks.get() + 1)); + + let navigations = Rc::new(RefCell::new(Vec::new())); + let callback_navigations = Rc::clone(&navigations); + let controller = history_mouse_controller(move |direction| { + callback_navigations.borrow_mut().push(direction); + }); + assert_eq!( + controller.propagation_phase(), + gtk::PropagationPhase::Capture + ); + let window = gtk::Window::builder() + .title(&title) + .default_width(180) + .default_height(100) + .child(&target) + .build(); + window.add_controller(controller); + window.present(); + drain_gtk_events(); + + let window_id = xdotool_window_id(&title); + xdotool_click(&window_id, 1); + drain_gtk_events(); + assert_eq!(primary_clicks.get(), 1); + assert!(navigations.borrow().is_empty()); + + // Xvfb's synthetic core pointer rejects button 11 at the XTEST + // protocol boundary. Its mapping remains covered by the classifier + // test; exercise every extra button Xvfb can inject here. + for button in [8, 9, 10] { + xdotool_click(&window_id, button); + drain_gtk_events(); + } + assert_eq!( + *navigations.borrow(), + [ + HistoryDirection::Back, + HistoryDirection::Forward, + HistoryDirection::Forward, + ] + ); + assert_eq!(primary_clicks.get(), 1); + let status = Command::new("xdotool") + .args(["mousemove", "0", "0"]) + .status() + .expect("move pointer away from test window"); + assert!(status.success(), "xdotool failed to move the pointer"); + drain_gtk_events(); + window.close(); + drain_gtk_events(); + } + + #[test] + fn initial_focus_uses_navigation_instead_of_close_notebook() { + crate::test_support::run_gtk_test(initial_focus_uses_navigation_gtk); + } + + fn initial_focus_uses_navigation_gtk() { + let close_source = gtk::Button::with_label("Close selected notebook"); + let navigation = + gtk::ListView::new(None::, None::); + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + content.append(&close_source); + content.append(&navigation); + let window = gtk::Window::builder().child(&content).build(); + window.present(); + focus_initial_navigation(&window, &navigation); + drain_gtk_events(); + + let expected: gtk::Widget = navigation.clone().upcast(); + assert_eq!( + gtk::prelude::GtkWindowExt::focus(&window).as_ref(), + Some(&expected) + ); + window.close(); + } + + fn x11_pointer_injection_available() -> bool { + let Some(display) = gtk::gdk::Display::default() else { + return false; + }; + if !display.type_().name().contains("X11") { + return false; + } + Command::new("xdotool") + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) + } + + fn xdotool_window_id(title: &str) -> String { + let output = Command::new("xdotool") + .args(["search", "--onlyvisible", "--name", title]) + .output() + .expect("run xdotool window search"); + assert!( + output.status.success(), + "xdotool could not find test window: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("xdotool window id is UTF-8") + .lines() + .next() + .expect("xdotool returned a window id") + .to_owned() + } + + fn xdotool_click(window_id: &str, button: u32) { + let button = button.to_string(); + let status = Command::new("xdotool") + .args([ + "mousemove", + "--window", + window_id, + "60", + "40", + "click", + &button, + ]) + .status() + .expect("run xdotool click"); + assert!(status.success(), "xdotool failed to inject button {button}"); + } + + fn drain_gtk_events() { + while glib::MainContext::default().iteration(false) {} + } +} diff --git a/crates/onenote-viewer/src/main.rs b/crates/onenote-viewer/src/main.rs index 7fe27db..580d247 100644 --- a/crates/onenote-viewer/src/main.rs +++ b/crates/onenote-viewer/src/main.rs @@ -5,6 +5,7 @@ mod app; mod attachment; mod dialogs; +mod input; mod navigation; mod navigation_history; mod navigation_state; diff --git a/docs/specs/desktop-ui.md b/docs/specs/desktop-ui.md index 1a1d522..0eefe15 100644 --- a/docs/specs/desktop-ui.md +++ b/docs/specs/desktop-ui.md @@ -113,9 +113,12 @@ replace one current entry so asynchronous load order does not appear as user history. `Alt+Left` moves backward and `Alt+Right` moves forward, matching OneNote. -Conventional mouse Back and Forward buttons invoke the same application -actions. The actions are also present in the main menu and are disabled when -there is no valid destination. +Standard `Back` and `Forward` key events and conventional mouse Back and +Forward buttons invoke the same application actions. Both generic side/extra +buttons and Linux semantic back/forward buttons are recognized on X11 and +Wayland. Unrelated pointer events must continue to their target widgets without +changing history. The actions are also present in the main menu and are +disabled when there is no valid destination. History traversal commits through the ordinary page activation path. It synchronizes the notebook, section, and page selections, cancels superseded