diff --git a/docs/scanner-architecture.md b/docs/scanner-architecture.md new file mode 100644 index 00000000..1b32a899 --- /dev/null +++ b/docs/scanner-architecture.md @@ -0,0 +1,65 @@ +# Scanner architecture + +The scanner has four boundaries: content, traversal, presentation and execution. +All desktop scanning shares the Rust `Session` lifecycle and +`scanning_runtime::Adapter` platform boundary. Local and Remote switches feed +that same session. No item scanner captures keys, runs native input, persists +settings or owns a timer thread. + +## Adding an item scanner + +Use `scan_items::ItemScanner` with a small typed action enum. Supply +`scan_tree::Node::Group` nodes with stable group IDs and `Leaf` action identities. +Group IDs must be unique among siblings; leaf identities must be unique within +their group. They must identify the operation, not its current label or bounds. +The `rows` convenience constructor is for fixed row layouts: its positional row +IDs are not suitable when groups can reorder. Supply explicit groups in that case. +Empty groups are removed. Explicitly identified groups retain their identity even +with one child. Anonymous branches and fixed single-item rows collapse as before. + +Call `handle` with normalized switch actions and `advance` with elapsed time only +when the containing session permits movement. The core owns the interval, +direction, completed passes, suspension and nested Back navigation. Read its +navigator to build a presentation. Do not add a second interval in the provider. +`Policy` preserves the existing menu/keyboard differences during migration: +menus resume their selection; keyboards resume at the root. Keyboard manual +steps reset completed passes; existing menu manual steps do not. + +Use `replace` when action availability or layout structure changes. Identified +groups and leaves retain their selection through reordering; a removed target +returns to the nearest surviving ancestor's first item with a full interval. +Any changed content invalidates pending activation. Identical content preserves +both timing and pending activation. Update labels and bounds separately when the +available actions have not changed. Do not put captured text into action IDs. + +Before asynchronous execution, call `begin_activation` and retain the returned +revision. While pending, the core does not advance or select. Complete with that +same revision; stale or repeated completions return false. Restart and content +replacement cancel pending activation. The runtime's existing input generation +checks remain authoritative across whole-session replacement, disconnect and +capture cancellation; an item revision is local to one scanner instance. + +The tests in `scan_items` provide a platform-free example using editing actions, +including reordered content, cancellation, exactly-once completion and timing. + +## Existing providers + +- `scan_menu` supplies typed menu actions and tile geometry. It delegates traversal + to the item scanner and retains its existing three-column layout and parent stack. +- `scan_keyboard` supplies keys, modifiers, pages, prediction availability and + geometry. It delegates traversal and pending activation to the item scanner. + Prediction tokens still guard insertion, and queued prediction replacement + remains a keyboard policy. Unchanged suggestions never restart the interval. +- `point_scan` retains its continuous movement strategy and uses the shared + navigator for grid scanning. `point_workflow` composes point, menus, countdown, + keyboard and execution-error stages within one session. +- Native hosts render `Frame` without activating Switchify. Domain actions are + executed through the existing typed workflow requests and platform adapters. + +## Following stages + +Issue #801 adds validated shared settings and per-area overrides to this core. +Issue #802 adds a main-window React content adapter and foreground/modal ownership. +Those stages do not require individual screens to implement traversal or timers. +They must preserve the current input generation checks, cleanup, native focus, +point countdown cancellation and Remote behaviour. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 89770a53..02f485ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod protocol; mod remote_scan; mod scan_executor; mod scan_host; +mod scan_items; mod scan_keyboard; mod scan_menu; mod scan_tile; diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 292b10bb..042b5f81 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -188,7 +188,7 @@ impl Workflow { } Item::Setting(setting) => return Some(Request::Setting(setting)), Item::Display(next) => return Some(Request::Display(next)), - Item::Pause => self.menu.suspended = true, + Item::Pause => self.menu.suspend(), Item::Reverse => { self.menu.handle(Action::Reverse); } @@ -260,7 +260,7 @@ impl Technique for Workflow { self.stage = Stage::Menu; self.menu = Menu::new(Kind::Actions, self.point.config.block_interval_ms); self.parent_menu.clear(); - self.menu.suspended = true; + self.menu.suspend(); self.error = Some(message); } fn execution_succeeded(&mut self) { @@ -407,7 +407,7 @@ impl Technique for Workflow { fn phase(&self) -> Phase { match self.stage { Stage::KeyboardOpening => Phase::Workflow(WorkflowPhase::KeyboardOpening), - Stage::Keyboard => Phase::Workflow(if self.keyboard.suspended { + Stage::Keyboard => Phase::Workflow(if self.keyboard.suspended() { WorkflowPhase::KeyboardSuspended } else { WorkflowPhase::Keyboard @@ -417,7 +417,7 @@ impl Technique for Workflow { Stage::Point => Phase::Point(self.point.phase()), Stage::Destination => Phase::Workflow(WorkflowPhase::DragDestination), Stage::Executing => Phase::Workflow(WorkflowPhase::Executing), - Stage::Menu => Phase::Workflow(if self.menu.suspended { + Stage::Menu => Phase::Workflow(if self.menu.suspended() { WorkflowPhase::MenuSuspended } else if self.menu.kind == Kind::ConfirmDrag { WorkflowPhase::DragConfirmation @@ -826,7 +826,7 @@ mod tests { w.advance(5000); assert_eq!(w.frame(), frame); assert!(w.handle(Action::Select).is_none()); - assert!(!w.menu.suspended); + assert!(!w.menu.suspended()); assert_eq!(w.source, (120, 80)); } #[test] diff --git a/src-tauri/src/scan_host.rs b/src-tauri/src/scan_host.rs index b8f6541d..e84e40ed 100644 --- a/src-tauri/src/scan_host.rs +++ b/src-tauri/src/scan_host.rs @@ -799,7 +799,9 @@ mod title_tests { for units in [1.0, 2.0] { let mut menu = Menu::new(Kind::Actions, 500); for paused in [false, true] { - menu.suspended = paused; + if paused { + menu.suspend(); + } let frame = menu.frame((0, 0), screen, units); let label = frame.label.as_ref().unwrap(); let MenuTitle { rect, scale } = diff --git a/src-tauri/src/scan_items.rs b/src-tauri/src/scan_items.rs new file mode 100644 index 00000000..c86b6b14 --- /dev/null +++ b/src-tauri/src/scan_items.rs @@ -0,0 +1,377 @@ +use crate::{ + scan_tree::{Navigator, Node, Selection}, + scanning::{Action, Interval, MAX_SCAN_CYCLES}, +}; + +#[derive(Clone, Copy)] +pub struct Policy { + pub reset_passes_on_step: bool, + pub resume_at_root: bool, +} +impl Policy { + pub const MENU: Self = Self { + reset_passes_on_step: false, + resume_at_root: false, + }; + pub const KEYBOARD: Self = Self { + reset_passes_on_step: true, + resume_at_root: true, + }; +} + +pub struct ItemScanner { + pub nav: Navigator, + pub suspended: bool, + interval: Interval, + cycles: usize, + forward: bool, + policy: Policy, + pending: bool, + revision: u64, +} +impl ItemScanner { + pub fn new(nodes: Vec>, policy: Policy) -> Self { + Self { + nav: Navigator::new(nodes), + suspended: false, + interval: Interval::default(), + cycles: 0, + forward: true, + policy, + pending: false, + revision: 0, + } + } + pub fn nodes(rows: &[Vec]) -> Vec> { + rows.iter() + .enumerate() + .map(|(index, row)| { + if let [only] = row.as_slice() { + Node::Leaf(only.clone()) + } else { + Node::Group { + id: format!("row-{index}"), + children: row.iter().cloned().map(Node::Leaf).collect(), + } + } + }) + .collect() + } + pub fn rows(rows: &[Vec], policy: Policy) -> Self { + Self::new(Self::nodes(rows), policy) + } + pub fn pending(&self) -> bool { + self.pending + } + pub fn begin_activation(&mut self) -> Option { + if self.pending || self.suspended { + return None; + } + self.revision = self.revision.wrapping_add(1); + self.pending = true; + Some(self.revision) + } + pub fn complete_activation(&mut self, revision: u64) -> bool { + if !self.pending || revision != self.revision { + return false; + } + self.pending = false; + true + } + pub fn restart_interval(&mut self) { + self.interval.reset(); + self.cycles = 0; + self.suspended = false; + } + pub fn reset_clock(&mut self) { + self.interval.reset(); + } + pub fn restart(&mut self) { + self.pending = false; + self.nav.reset(); + self.forward = true; + self.restart_interval(); + } + pub fn skip(&mut self) { + self.nav.step(self.forward); + } + pub fn advance(&mut self, ms: u64, period: u64) -> bool { + if self.pending || self.suspended || !self.interval.elapsed(ms, period) { + return false; + } + if self.nav.step(self.forward) { + self.cycles += 1; + self.suspended = self.cycles >= MAX_SCAN_CYCLES; + } + true + } + pub fn handle(&mut self, action: Action) -> Option { + if self.pending { + return None; + } + if self.suspended { + if action == Action::Select { + if self.policy.resume_at_root { + self.restart(); + } else { + self.restart_interval(); + } + } + return None; + } + match action { + Action::Select => { + self.restart_interval(); + match self.nav.select() { + Selection::Leaf(value) => return Some(value), + Selection::Entered | Selection::Escaped => self.forward = true, + Selection::None => {} + } + } + Action::Next | Action::Back => { + self.reset_clock(); + if self.policy.reset_passes_on_step { + self.cycles = 0; + } + self.forward = action == Action::Next; + self.skip(); + } + Action::Reverse => { + self.forward = !self.forward; + self.restart_interval(); + } + _ => {} + } + None + } +} + +impl ItemScanner { + pub fn replace(&mut self, nodes: Vec>) -> bool { + let Some(preserved) = self.nav.replace(nodes) else { + return false; + }; + self.revision = self.revision.wrapping_add(1); + self.pending = false; + if !preserved { + self.interval.reset(); + self.cycles = 0; + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scanner(policy: Policy) -> ItemScanner<&'static str> { + ItemScanner::rows(&[vec!["copy", "paste"], vec!["close"]], policy) + } + + #[test] + fn nested_back_restores_parent_and_gives_it_a_full_interval() { + let mut scan = scanner(Policy::MENU); + assert_eq!(scan.handle(Action::Select), None); + scan.advance(499, 500); + scan.handle(Action::Back); + assert!(scan.nav.escaping()); + assert_eq!(scan.handle(Action::Select), None); + assert!(scan.nav.path().is_empty()); + assert_eq!(scan.nav.index(), 0); + assert!(!scan.advance(499, 500)); + assert!(scan.advance(1, 500)); + assert_eq!(scan.handle(Action::Select), Some("close")); + } + + #[test] + fn suspension_consumes_resume_without_selecting() { + for policy in [Policy::MENU, Policy::KEYBOARD] { + let mut scan = scanner(policy); + for _ in 0..6 { + scan.advance(500, 500); + } + assert!(scan.suspended); + assert_eq!(scan.handle(Action::Next), None); + assert!(scan.suspended); + assert_eq!(scan.handle(Action::Select), None); + assert!(!scan.suspended); + assert!(!scan.advance(499, 500)); + } + } + + #[test] + fn reverse_and_manual_steps_restart_the_clock() { + let mut scan = scanner(Policy::MENU); + scan.advance(499, 500); + scan.handle(Action::Reverse); + assert!(!scan.advance(499, 500)); + assert!(scan.advance(1, 500)); + assert_eq!(scan.handle(Action::Select), Some("close")); + scan.handle(Action::Next); + assert_eq!(scan.nav.index(), 0); + assert!(!scan.advance(499, 500)); + } + + #[test] + fn adapters_preserve_their_existing_resume_policy() { + for policy in [Policy::MENU, Policy::KEYBOARD] { + let mut scan = scanner(policy); + scan.handle(Action::Select); + scan.handle(Action::Next); + scan.suspended = true; + scan.handle(Action::Select); + assert_eq!(scan.nav.path().is_empty(), policy.resume_at_root); + assert_eq!(scan.nav.index(), usize::from(!policy.resume_at_root)); + } + } + fn group(id: &str, keys: &[&'static str]) -> Node<&'static str> { + Node::Group { + id: id.into(), + children: keys.iter().copied().map(Node::Leaf).collect(), + } + } + + #[test] + fn reordered_groups_and_keys_keep_identity_and_elapsed_time() { + let mut scan = ItemScanner::new( + vec![ + group("edit", &["copy", "paste"]), + group("window", &["close", "minimise"]), + ], + Policy::MENU, + ); + scan.handle(Action::Select); + scan.handle(Action::Next); + scan.advance(400, 500); + assert!(scan.replace(vec![ + group("window", &["close", "minimise"]), + group("edit", &["paste", "copy", "undo"]) + ])); + assert_eq!(scan.nav.path(), &[1]); + assert_eq!(scan.nav.index(), 0); + assert!(!scan.advance(99, 500)); + assert!(scan.advance(1, 500)); + assert_eq!(scan.handle(Action::Select), Some("copy")); + } + + #[test] + fn missing_item_returns_to_safe_parent_with_a_fresh_interval() { + let mut scan = ItemScanner::new( + vec![group("edit", &["copy", "paste", "undo"])], + Policy::MENU, + ); + scan.handle(Action::Select); + scan.handle(Action::Next); + scan.advance(499, 500); + scan.replace(vec![group("edit", &["copy", "undo"])]); + assert!(!scan.advance(499, 500)); + assert_eq!(scan.handle(Action::Select), Some("copy")); + scan.replace(vec![]); + assert_eq!(scan.handle(Action::Select), None); + } + + #[test] + fn unchanged_content_does_not_cancel_activation_or_reset_interval() { + let nodes = vec![group("edit", &["copy", "paste"])]; + let mut scan = ItemScanner::new(nodes.clone(), Policy::MENU); + scan.handle(Action::Select); + scan.advance(499, 500); + assert!(!scan.replace(nodes.clone())); + assert!(scan.advance(1, 500)); + let token = scan.begin_activation().unwrap(); + assert!(!scan.replace(nodes)); + assert!(scan.complete_activation(token)); + assert!(!scan.complete_activation(token)); + } + + #[test] + fn pending_activation_blocks_steps_and_selection_and_stale_completion() { + let mut scan = scanner(Policy::KEYBOARD); + let first = scan.begin_activation().unwrap(); + assert_eq!(scan.begin_activation(), None); + assert_eq!(scan.handle(Action::Select), None); + assert_eq!(scan.handle(Action::Next), None); + assert!(!scan.advance(500, 500)); + scan.restart(); + assert!(!scan.complete_activation(first)); + let second = scan.begin_activation().unwrap(); + assert_ne!(first, second); + assert!(!scan.complete_activation(first)); + scan.replace(vec![Node::Leaf("save")]); + assert!(!scan.complete_activation(second)); + assert_eq!(scan.handle(Action::Select), Some("save")); + } + #[test] + fn back_remains_back_when_the_hidden_edge_item_disappears() { + for forward in [false, true] { + let mut scan = ItemScanner::new( + vec![group("edit", &["copy", "paste", "undo"])], + Policy::MENU, + ); + scan.handle(Action::Select); + if forward { + scan.handle(Action::Next); + scan.handle(Action::Next); + } + scan.handle(if forward { Action::Next } else { Action::Back }); + assert!(scan.nav.escaping()); + scan.advance(400, 500); + scan.replace(vec![ + group("other", &["save", "close"]), + group("edit", &["paste", "cut"]), + ]); + assert!(scan.nav.escaping()); + assert_eq!(scan.nav.path(), &[1]); + assert!(!scan.advance(99, 500)); + assert_eq!(scan.handle(Action::Select), None); + assert!(scan.nav.path().is_empty()); + assert_eq!(scan.nav.index(), 1); + } + } + #[test] + fn changing_content_never_resumes_a_suspended_scanner() { + let mut scan = scanner(Policy::MENU); + scan.handle(Action::Next); + scan.suspended = true; + scan.replace(vec![Node::Leaf("save")]); + assert!(scan.suspended); + assert!(!scan.advance(500, 500)); + assert_eq!(scan.handle(Action::Select), None); + assert!(!scan.suspended); + assert_eq!(scan.handle(Action::Select), Some("save")); + } + #[test] + fn identified_group_survives_shrinking_to_one_item_while_back_is_selected() { + for forward in [false, true] { + let mut scan = ItemScanner::new(vec![group("edit", &["copy", "paste"])], Policy::MENU); + scan.handle(Action::Select); + if forward { + scan.handle(Action::Next); + } + scan.handle(if forward { Action::Next } else { Action::Back }); + scan.replace(vec![group("edit", &["paste"])]); + assert!(scan.nav.escaping()); + assert_eq!(scan.handle(Action::Select), None); + assert!(scan.nav.path().is_empty()); + assert_eq!(scan.handle(Action::Select), None); + assert_eq!(scan.handle(Action::Select), Some("paste")); + } + } + + #[test] + fn selected_leaf_survives_its_group_shrinking_to_one_child() { + let mut scan = ItemScanner::new(vec![group("edit", &["copy", "paste"])], Policy::MENU); + scan.handle(Action::Select); + scan.handle(Action::Next); + scan.replace(vec![group("edit", &["paste"])]); + assert_eq!(scan.nav.path(), &[0]); + assert_eq!(scan.handle(Action::Select), Some("paste")); + } + + #[test] + fn fixed_single_item_rows_keep_direct_activation() { + let mut scan = ItemScanner::rows(&[vec!["close"]], Policy::MENU); + assert_eq!(scan.handle(Action::Select), Some("close")); + } +} diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 4d87558f..3dda257e 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -1,11 +1,8 @@ //! Switch-owned keyboard state and geometry. No native input or captured text. use crate::{ + scan_items::{ItemScanner, Policy}, scan_menu::Item, - scan_tree::{Navigator, Node, Selection}, - scanning::{ - Action, Frame, FrameTile, Interval, KeyboardRole, KeyboardTileStyle, Rect, ScannerColor, - MAX_SCAN_CYCLES, - }, + scanning::{Action, Frame, FrameTile, KeyboardRole, KeyboardTileStyle, Rect, ScannerColor}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -177,15 +174,11 @@ pub struct Keyboard { pub modifiers: [Modifier; 4], pub caps: bool, pub top: bool, - pub suspended: bool, pub error: bool, mac: bool, rows: Vec>, - nav: Navigator, - interval: Interval, - cycles: usize, - forward: bool, - pending: bool, + scan: ItemScanner, + activation: Option, prediction_enabled: bool, prediction_failed: bool, predictions: Option, @@ -195,21 +188,17 @@ pub struct Keyboard { impl Keyboard { pub fn new(mac: bool) -> Self { let rows = rows(Page::Letters, mac); - let nav = Self::navigator(&rows); + let scan = ItemScanner::rows(&rows, Policy::KEYBOARD); Self { page: Page::Letters, modifiers: [Modifier::Off; 4], caps: false, top: false, - suspended: false, error: false, mac, rows, - nav, - interval: Interval::default(), - cycles: 0, - forward: true, - pending: false, + scan, + activation: None, prediction_enabled: false, prediction_failed: false, predictions: None, @@ -229,13 +218,22 @@ impl Keyboard { if self.prediction_enabled && self.page == Page::Letters { self.rows.insert(0, (0..5).map(Key::Prediction).collect()); } - self.nav = Self::navigator(&self.rows); + self.scan.replace(ItemScanner::nodes(&self.rows)); + self.scan.restart(); + self.activation = None; self.skip_disabled(); } fn prediction_row_active(&self) -> bool { self.prediction_enabled && self.page == Page::Letters - && self.nav.path().first().copied().unwrap_or(self.nav.index()) == 0 + && self + .scan + .nav + .path() + .first() + .copied() + .unwrap_or(self.scan.nav.index()) + == 0 } pub fn predictions(&mut self, batch: Option, failed: bool) { self.prediction_failed = failed; @@ -259,21 +257,21 @@ impl Keyboard { .as_ref() .is_some_and(|b| !b.words.is_empty()) { - self.nav.reset(); - self.interval.reset(); + self.scan.nav.reset(); + self.scan.reset_clock(); self.prefer_predictions = false; } } } fn disabled(&self) -> bool { - if !self.prediction_row_active() || self.nav.escaping() { + if !self.prediction_row_active() || self.scan.nav.escaping() { return false; } let count = self.predictions.as_ref().map_or(0, |b| b.words.len()); - if self.nav.path().is_empty() { + if self.scan.nav.path().is_empty() { count == 0 } else { - self.nav.index() >= count + self.scan.nav.index() >= count } } fn skip_disabled(&mut self) { @@ -281,7 +279,7 @@ impl Keyboard { if !self.disabled() { break; } - self.nav.step(self.forward); + self.scan.skip(); } if !self.prediction_row_active() { if let Some(batch) = self.queued_predictions.take() { @@ -289,37 +287,25 @@ impl Keyboard { } } } - fn navigator(rows: &[Vec]) -> Navigator { - Navigator::new( - rows.iter() - .map(|row| Node::Branch(row.iter().copied().map(Node::Leaf).collect())) - .collect(), - ) + pub fn suspended(&self) -> bool { + self.scan.suspended } fn restart(&mut self) { self.prefer_predictions = true; - self.nav.reset(); - self.interval.reset(); - self.cycles = 0; - self.forward = true; - self.suspended = false; + self.scan.restart(); self.skip_disabled(); } pub fn advance(&mut self, ms: u64, period: u64) { - if !self.pending && !self.suspended && self.interval.elapsed(ms, period) { + if self.scan.advance(ms, period) { self.prefer_predictions = false; - if self.nav.step(self.forward) { - self.cycles += 1; - self.suspended = self.cycles >= MAX_SCAN_CYCLES; - } } self.skip_disabled(); } pub fn handle(&mut self, action: Action) -> Option { - if self.pending { + if self.scan.pending() { return None; } - if self.suspended { + if self.scan.suspended { if action == Action::Select { self.error = false; self.restart(); @@ -327,28 +313,8 @@ impl Keyboard { return None; } self.prefer_predictions = false; - match action { - Action::Select => { - self.interval.reset(); - self.cycles = 0; - match self.nav.select() { - Selection::Leaf(key) => return self.choose(key), - Selection::Entered | Selection::Escaped => self.forward = true, - Selection::None => {} - } - } - Action::Next | Action::Back => { - self.interval.reset(); - self.cycles = 0; - self.forward = action == Action::Next; - self.nav.step(self.forward); - } - Action::Reverse => { - self.forward = !self.forward; - self.interval.reset(); - self.cycles = 0; - } - _ => {} + if let Some(key) = self.scan.handle(action) { + return self.choose(key); } self.skip_disabled(); None @@ -360,7 +326,8 @@ impl Keyboard { if index >= batch.words.len() { return None; } - self.pending = true; + self.activation = self.scan.begin_activation(); + self.activation?; return Some(Output::Prediction { token: batch.token, index, @@ -377,7 +344,8 @@ impl Keyboard { Key::Character(..) | Key::Named(_) => { self.predictions = None; self.queued_predictions = None; - self.pending = true; + self.activation = self.scan.begin_activation(); + self.activation?; return Some(Output::Stroke(Stroke { key, modifiers: self.modifiers.map(|m| m != Modifier::Off), @@ -389,23 +357,27 @@ impl Keyboard { None } pub fn succeeded(&mut self) { - if self.pending { + if self + .activation + .take() + .is_some_and(|revision| self.scan.complete_activation(revision)) + { for modifier in &mut self.modifiers { if *modifier == Modifier::Once { *modifier = Modifier::Off; } } - self.pending = false; + self.activation = None; self.restart(); } } pub fn failed(&mut self) { self.modifiers = [Modifier::Off; 4]; self.caps = false; - self.pending = false; + self.activation = None; self.restart(); self.error = true; - self.suspended = true; + self.scan.suspended = true; } fn label(&self, key: Key) -> String { match key { @@ -540,8 +512,14 @@ impl Keyboard { let x = x + padding; let y = y + padding; let width = content_width; - let row_scan = self.nav.path().is_empty(); - let active_row = self.nav.path().first().copied().unwrap_or(self.nav.index()); + let row_scan = self.scan.nav.path().is_empty(); + let active_row = self + .scan + .nav + .path() + .first() + .copied() + .unwrap_or(self.scan.nav.index()); for (r, row) in self.rows.iter().enumerate() { let total_weight: f64 = row.iter().copied().map(Self::weight).sum(); let cell_unit = (width - gap * (row.len() - 1) as f64).max(1.0) / total_weight; @@ -560,10 +538,10 @@ impl Keyboard { height: (row_height - gap).max(1.0), }, scale, - selected: !self.suspended - && !self.nav.escaping() + selected: !self.scan.suspended + && !self.scan.nav.escaping() && r == active_row - && (row_scan || c == self.nav.index()), + && (row_scan || c == self.scan.nav.index()), }); left += key_width + gap; } @@ -577,9 +555,9 @@ impl Keyboard { "Predictions unavailable · Keyboard ready".to_owned() } else if self.error { "Input failed · Select to try again".to_owned() - } else if self.suspended { + } else if self.scan.suspended { "Keyboard paused · Select to resume".to_owned() - } else if self.nav.escaping() { + } else if self.scan.nav.escaping() { "Back to rows · Select to return".to_owned() } else if row_scan { format!("{} · Select a row", page) @@ -587,7 +565,7 @@ impl Keyboard { format!( "{} · Select {}", page, - self.label(self.rows[active_row][self.nav.index()]) + self.label(self.rows[active_row][self.scan.nav.index()]) .replace('\n', " ") ) }; @@ -607,7 +585,7 @@ impl Keyboard { height: (header_height - gap).max(1.0), }, scale, - selected: !self.suspended && self.nav.escaping(), + selected: !self.scan.suspended && self.scan.nav.escaping(), }); frame } @@ -616,32 +594,33 @@ impl Keyboard { #[cfg(test)] mod tests { use super::*; + use crate::scanning::MAX_SCAN_CYCLES; #[test] fn arriving_predictions_receive_a_full_scan_interval() { let mut keyboard = Keyboard::new(false); keyboard.enable_predictions(true); keyboard.advance(490, 500); - assert_eq!(keyboard.nav.index(), 1); + assert_eq!(keyboard.scan.nav.index(), 1); let batch = crate::prediction::worker::Batch { token: 1, words: vec!["hello".into()], }; keyboard.predictions(Some(batch.clone()), false); - assert_eq!(keyboard.nav.index(), 0); + assert_eq!(keyboard.scan.nav.index(), 0); keyboard.advance(10, 500); - assert_eq!(keyboard.nav.index(), 0); + assert_eq!(keyboard.scan.nav.index(), 0); keyboard.predictions(Some(batch), false); keyboard.advance(489, 500); - assert_eq!(keyboard.nav.index(), 0); + assert_eq!(keyboard.scan.nav.index(), 0); keyboard.advance(1, 500); - assert_eq!(keyboard.nav.index(), 1); + assert_eq!(keyboard.scan.nav.index(), 1); } #[test] fn predictions_skip_empty_slots_and_defer_acceptance() { let mut k = Keyboard::new(false); k.enable_predictions(true); - assert_eq!(k.nav.index(), 1); + assert_eq!(k.scan.nav.index(), 1); k.predictions( Some(crate::prediction::worker::Batch { token: 7, @@ -650,7 +629,7 @@ mod tests { false, ); k.restart(); - assert_eq!(k.nav.index(), 0); + assert_eq!(k.scan.nav.index(), 0); assert_eq!(k.handle(Action::Select), None); assert_eq!( k.handle(Action::Select), @@ -658,11 +637,11 @@ mod tests { ); assert!(k.handle(Action::Select).is_none()); k.succeeded(); - assert!(k.nav.path().is_empty()); + assert!(k.scan.nav.path().is_empty()); k.handle(Action::Select); k.handle(Action::Next); k.handle(Action::Next); - assert!(k.nav.escaping()); + assert!(k.scan.nav.escaping()); k.predictions(None, false); assert!(k.choose(Key::Prediction(0)).is_none()); k.choose(Key::Page(Page::Numbers)); @@ -756,7 +735,7 @@ mod tests { assert_eq!(selected.len(), 1); assert!(selected[0].text.starts_with("Back to rows")); k.handle(Action::Select); - assert!(k.nav.path().is_empty()); + assert!(k.scan.nav.path().is_empty()); } #[test] fn uk_layout_and_platform_keys() { @@ -811,9 +790,9 @@ mod tests { k.choose(Key::Modifier(2)); k.failed(); assert_eq!(k.modifiers, [Modifier::Off; 4]); - assert!(k.suspended); + assert!(k.suspended()); k.handle(Action::Select); - assert!(!k.suspended); + assert!(!k.suspended()); } #[test] fn caps_xor_shift_only_affects_letters() { @@ -832,22 +811,22 @@ mod tests { fn scan_returns_to_top_and_has_row_escape_and_suspension() { let mut k = Keyboard::new(false); k.handle(Action::Select); - assert!(!k.nav.path().is_empty()); + assert!(!k.scan.nav.path().is_empty()); k.handle(Action::Back); - assert!(k.nav.escaping()); + assert!(k.scan.nav.escaping()); k.handle(Action::Select); - assert!(k.nav.path().is_empty()); + assert!(k.scan.nav.path().is_empty()); k.handle(Action::Select); assert!(matches!(k.handle(Action::Select), Some(Output::Stroke(_)))); k.succeeded(); - assert!(k.nav.path().is_empty()); - assert_eq!(k.nav.index(), 0); + assert!(k.scan.nav.path().is_empty()); + assert_eq!(k.scan.nav.index(), 0); for _ in 0..k.rows.len() * MAX_SCAN_CYCLES { k.advance(100, 100); } - assert!(k.suspended); + assert!(k.suspended()); assert!(k.handle(Action::Select).is_none()); - assert!(!k.suspended); + assert!(!k.suspended()); } #[test] fn geometry_fits_work_area_at_both_docks_and_scales() { diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index 1c7f1507..4e26f2f0 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -1,7 +1,7 @@ //! Menu rows and navigation are independent of native windows and input. use crate::{ - scan_tree::{Navigator, Node, Selection}, - scanning::{Action, Frame, FrameLabel, FrameTile, Interval, Rect, MAX_SCAN_CYCLES}, + scan_items::{ItemScanner, Policy}, + scanning::{Action, Frame, FrameLabel, FrameTile, Rect}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Item { @@ -98,77 +98,40 @@ pub(crate) const ALL_MENU_KINDS: [Kind; 13] = [ pub struct Menu { pub kind: Kind, rows: Vec>, - nav: Navigator, - interval: Interval, + scan: ItemScanner, period: u64, - cycles: usize, - forward: bool, - pub suspended: bool, } impl Menu { pub fn new(kind: Kind, period: u64) -> Self { let rows = kind.rows(); - let nav = Navigator::new( - rows.iter() - .map(|row| Node::Branch(row.iter().copied().map(Node::Leaf).collect())) - .collect(), - ); + let scan = ItemScanner::rows(&rows, Policy::MENU); Self { kind, rows, - nav, - interval: Interval::default(), + scan, period, - cycles: 0, - forward: true, - suspended: false, } } + pub fn suspended(&self) -> bool { + self.scan.suspended + } + pub fn suspend(&mut self) { + self.scan.suspended = true; + } pub fn set_period(&mut self, period: u64) { self.period = period; self.restart_interval(); } pub fn restart_interval(&mut self) { - self.interval.reset(); - self.cycles = 0; - self.suspended = false; + self.scan.restart_interval(); } pub fn advance(&mut self, ms: u64) { - if !self.suspended && self.interval.elapsed(ms, self.period) && self.nav.step(self.forward) - { - self.cycles += 1; - self.suspended = self.cycles >= MAX_SCAN_CYCLES; - } + self.scan.advance(ms, self.period); } pub fn handle(&mut self, action: Action) -> Option { - if self.suspended { - if action == Action::Select { - self.restart_interval(); - } - return None; - } - match action { - Action::Select => { - self.restart_interval(); - match self.nav.select() { - Selection::Leaf(item) => return Some(item), - Selection::Entered | Selection::Escaped => self.forward = true, - Selection::None => {} - } - } - Action::Next | Action::Back => { - self.interval.reset(); - self.forward = action == Action::Next; - self.nav.step(self.forward); - } - Action::Reverse => { - self.forward = !self.forward; - self.restart_interval(); - } - _ => {} - } - None + self.scan.handle(action) } + pub fn frame(&self, point: (i32, i32), screen: Rect, units: f64) -> Frame { let columns = self.rows.iter().map(Vec::len).max().unwrap_or(1) as f64; let logical_width = 16.0 + columns * 180.0; @@ -180,7 +143,13 @@ impl Menu { let height = logical_height * scale; let panel = place(point, screen, width, height, 20.0 * scale); let mut frame = Frame::default(); - let active_row = self.nav.path().first().copied().unwrap_or(self.nav.index()); + let active_row = self + .scan + .nav + .path() + .first() + .copied() + .unwrap_or(self.scan.nav.index()); for (r, row) in self.rows.iter().enumerate() { let tile_width = 180.0 * scale; for (c, item) in row.iter().enumerate() { @@ -191,7 +160,9 @@ impl Menu { height: 168.0 * scale, }; let selected = r == active_row - && (self.nav.path().is_empty() || self.nav.escaping() || c == self.nav.index()); + && (self.scan.nav.path().is_empty() + || self.scan.nav.escaping() + || c == self.scan.nav.index()); frame.tiles.push(FrameTile { keyboard: None, color: Default::default(), @@ -203,9 +174,9 @@ impl Menu { }); } } - let text = if self.suspended { + let text = if self.suspended() { "Select to resume" - } else if self.nav.escaping() { + } else if self.scan.nav.escaping() { "Back to rows" } else { match self.kind { @@ -741,12 +712,12 @@ mod tests { m.advance(249); m.handle(Action::Back); m.advance(1); - assert!(m.nav.escaping()); + assert!(m.scan.nav.escaping()); assert_eq!(m.handle(Action::Select), None); - assert!(m.nav.path().is_empty()); - assert_eq!(m.nav.index(), 0); + assert!(m.scan.nav.path().is_empty()); + assert_eq!(m.scan.nav.index(), 0); m.handle(Action::Reverse); m.advance(250); - assert_eq!(m.nav.index(), 3); + assert_eq!(m.scan.nav.index(), 3); } } diff --git a/src-tauri/src/scan_tree.rs b/src-tauri/src/scan_tree.rs index bb0692bd..6c12adaf 100644 --- a/src-tauri/src/scan_tree.rs +++ b/src-tauri/src/scan_tree.rs @@ -2,6 +2,7 @@ #[derive(Debug, Clone, PartialEq)] pub enum Node { Branch(Vec>), + Group { id: String, children: Vec> }, Leaf(T), } @@ -9,6 +10,13 @@ impl Node { fn normalized(self) -> Option { match self { Self::Leaf(_) => Some(self), + Self::Group { id, children } => { + let children: Vec<_> = children.into_iter().filter_map(Self::normalized).collect(); + match children.len() { + 0 => None, + _ => Some(Self::Group { id, children }), + } + } Self::Branch(children) => { let mut children: Vec<_> = children.into_iter().filter_map(Self::normalized).collect(); @@ -49,7 +57,7 @@ impl Navigator { fn siblings(&self) -> &[Node] { let mut nodes = self.roots.as_slice(); for index in &self.path { - let Node::Branch(children) = &nodes[*index] else { + let (Node::Branch(children) | Node::Group { children, .. }) = &nodes[*index] else { unreachable!() }; nodes = children; @@ -107,7 +115,7 @@ impl Navigator { } match self.siblings().get(self.index) { Some(Node::Leaf(value)) => Selection::Leaf(value.clone()), - Some(Node::Branch(_)) => { + Some(Node::Branch(_) | Node::Group { .. }) => { self.path.push(self.index); self.index = 0; Selection::Entered @@ -117,6 +125,62 @@ impl Navigator { } } +impl Navigator { + pub fn replace(&mut self, roots: Vec>) -> Option { + let roots: Vec<_> = roots.into_iter().filter_map(Node::normalized).collect(); + if roots == self.roots { + return None; + } + let mut old = self.roots.as_slice(); + let mut new = roots.as_slice(); + let mut path = Vec::new(); + let mut preserved = true; + for &index in &self.path { + let Some(next) = new.iter().position(|node| same_identity(&old[index], node)) else { + preserved = false; + break; + }; + let (Node::Branch(old_children) + | Node::Group { + children: old_children, + .. + }) = &old[index] + else { + unreachable!() + }; + let (Node::Branch(new_children) + | Node::Group { + children: new_children, + .. + }) = &new[next] + else { + unreachable!() + }; + path.push(next); + old = old_children; + new = new_children; + } + let index = if preserved { + old.get(self.index) + .and_then(|selected| new.iter().position(|node| same_identity(selected, node))) + } else { + None + }; + preserved &= index.is_some() || (self.escaping && !path.is_empty()); + self.index = index.unwrap_or(0); + self.escaping &= preserved && !path.is_empty(); + self.path = path; + self.roots = roots; + Some(preserved) + } +} +fn same_identity(a: &Node, b: &Node) -> bool { + match (a, b) { + (Node::Group { id: a, .. }, Node::Group { id: b, .. }) => a == b, + _ => a == b, + } +} + #[cfg(test)] mod tests { use super::*;