From 4f47d65cf495b96075a876e864aa7bc5bc6b00c7 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 23 Sep 2026 16:26:42 +0100 Subject: [PATCH 1/2] Share one scan panel layout between keyboard and mouse Extract the keyboard's panel geometry into scan_panel so panels only describe their rows and status. The keyboard renders through it with unchanged output; the Mouse panel gains the keyboard's size, padding, gaps, status tile and paused/back-to-rows/select prompts. Closes #860. Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/lib.rs | 1 + src-tauri/src/point_workflow.rs | 24 ++-- src-tauri/src/scan_keyboard.rs | 119 +++++------------- src-tauri/src/scan_mouse.rs | 174 ++++++++++++++------------ src-tauri/src/scan_panel.rs | 211 ++++++++++++++++++++++++++++++++ 5 files changed, 348 insertions(+), 181 deletions(-) create mode 100644 src-tauri/src/scan_panel.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8f357745..b5f14384 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,6 +29,7 @@ mod scan_items; mod scan_keyboard; mod scan_menu; mod scan_mouse; +mod scan_panel; mod scan_preferences; mod scan_tile; mod scan_tree; diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 876c5c08..fc7e4fe3 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -735,22 +735,12 @@ impl Technique for Workflow { self.point.units_per_logical_pixel, self.point.config.scanner_color, ), - Stage::Mouse | Stage::MouseMoving => { - let mut frame = self.mouse.frame( - self.mouse_area, - self.point.units_per_logical_pixel, - self.point.config.mouse_scan.color, - ); - if self.stage == Stage::MouseMoving { - if let Some(label) = frame.label.as_mut() { - label.text = "Moving pointer · Press any switch to stop".into(); - } - for tile in &mut frame.tiles { - tile.selected = false; - } - } - frame - } + Stage::Mouse | Stage::MouseMoving => self.mouse.frame( + self.mouse_area, + self.point.units_per_logical_pixel, + self.point.config.mouse_scan.color, + self.stage == Stage::MouseMoving, + ), Stage::Countdown => { let scale = self.point.units_per_logical_pixel; let screen = self.point.screen; @@ -928,7 +918,7 @@ mod tests { assert!(session .technique .mouse - .frame(screen, 1.0, Default::default()) + .frame(screen, 1.0, Default::default(), false) .tiles .iter() .skip(1) diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index af434285..5efbca00 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -1,8 +1,7 @@ //! Switch-owned keyboard state and geometry. No native input or captured text. use crate::{ scan_items::{ItemScanner, Policy}, - scan_menu::Item, - scanning::{Action, Frame, FrameTile, Rect, ScannerColor, TileRole, TileStyle}, + scanning::{Action, Frame, Rect, ScannerColor, TileRole, TileStyle}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -504,113 +503,59 @@ impl Keyboard { } } pub fn frame(&self, screen: Rect, units: f64, color: ScannerColor) -> Frame { - let width = (1180.0 * units).min((screen.width - 24.0 * units).max(1.0)); - let height = (460.0 * units).min(screen.height * 0.55).max(1.0); - let x = screen.x + (screen.width - width) / 2.0; - let y = if self.top { - screen.y - } else { - screen.y + screen.height - height - }; - let outer_scale = units.min(height / 460.0).min(width / 1180.0); - let padding = 16.0 * outer_scale; - let content_width = (width - padding * 2.0).max(1.0); - let content_height = (height - padding * 2.0).max(1.0); - let row_height = content_height / (self.rows.len() as f64 + 0.9); - let scale = units.min(row_height / 60.0).min(content_width / 1040.0); - let gap = (6.0 * scale).min(row_height / 8.0); - let header_height = row_height * 0.9; - let mut frame = Frame::default(); - frame.tiles.push(FrameTile::panel_background( - Rect { - x, - y, - width, - height, - }, - outer_scale, - color, - )); - let x = x + padding; - let y = y + padding; - let width = content_width; let row_scan = self.scan.row_scan(); let (active_row, active_column) = self.scan.position(&self.rows); - 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; - let mut left = x; - for (c, key) in row.iter().enumerate() { - let key_width = cell_unit * Self::weight(*key); - frame.tiles.push(FrameTile { - thickness: Default::default(), - color, - text: self.label(*key), - icon: Item::KeyboardKey, - style: Some(self.style(*key, row_scan)), - rect: Rect { - x: left, - y: y + header_height + r as f64 * row_height, - width: key_width, - height: (row_height - gap).max(1.0), - }, - scale, - selected: !self.scan.suspended - && !self.disabled() - && !self.scan.nav.escaping() - && r == active_row - && active_column.is_none_or(|column| c == column), - }); - left += key_width + gap; - } - } let page = match self.page { Page::Letters => "Letters", Page::Functions => "Navigation", Page::Numbers => "Numbers", }; - let text = if self.error { + let status = if self.error { "Input failed · Select to try again".to_owned() } else if self.waiting_after_typing { "Press Select to continue typing.".to_owned() } else if self.scan.suspended { "Keyboard paused · Select to resume".to_owned() } else if self.scan.nav.escaping() { - "Back to rows · Select to return".to_owned() + crate::scan_panel::BACK_TO_ROWS.to_owned() } else if self.disabled() { "Suggestions updating · Select to continue".to_owned() } else if self.prediction_failed { "Predictions unavailable · Keyboard ready".to_owned() - } else if row_scan { - format!("{} · Select a row", page) } else { - format!( - "{} · Select {}", + crate::scan_panel::scanning_status( page, - self.label(self.rows[active_row][active_column.unwrap_or(0)]) - .replace('\n', " ") + row_scan, + &self.label(self.rows[active_row][active_column.unwrap_or(0)]), ) }; - frame.tiles.push(FrameTile { + crate::scan_panel::Panel { + rows: self + .rows + .iter() + .map(|row| { + row.iter() + .map(|&key| { + let style = self.style(key, row_scan); + crate::scan_panel::PanelKey { + text: self.label(key), + weight: Self::weight(key), + role: style.role, + active: style.active, + } + }) + .collect() + }) + .collect(), + status, + top: self.top, + selected: (!self.scan.suspended && !self.disabled() && !self.scan.nav.escaping()) + .then_some((active_row, active_column)), + status_selected: !self.scan.suspended && self.scan.nav.escaping(), + row_scan, thickness: Default::default(), - color, - text, - icon: Item::KeyboardKey, - style: Some(TileStyle { - role: TileRole::Status, - active: false, - row_scan: false, - }), - rect: Rect { - x, - y, - width, - height: (header_height - gap).max(1.0), - }, - scale, - selected: !self.scan.suspended && self.scan.nav.escaping(), - }); - frame + } + .frame(screen, units, color) } } diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index 5c2cd962..ffd53f7d 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -1,9 +1,9 @@ //! Switch-owned mouse panel. Native input stays in the scanning adapter. use crate::{ scan_items::{ItemScanner, Policy}, - scan_menu::Item, + scan_panel::{Panel, PanelKey}, scan_preferences::Resolved, - scanning::{Action, Frame, FrameLabel, FrameTile, Rect, ScannerColor, TileRole, TileStyle}, + scanning::{Action, Frame, Rect, ScannerColor, TileRole}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -156,83 +156,61 @@ impl MousePanel { Close => "Switch to Point".into(), } } - pub fn frame(&self, screen: Rect, units: f64, color: ScannerColor) -> Frame { - let columns = self.rows.iter().map(Vec::len).max().unwrap_or(1) as f64; - let width = (columns * 160.0 + 32.0) - .min(screen.width / units.max(0.1) - 8.0) - .max(1.0) - * units; - let height = (self.rows.len() as f64 * 98.0 + 52.0) - .min(screen.height / units.max(0.1) * 0.65) - .max(1.0) - * units; - let x = screen.x + (screen.width - width) / 2.0; - let y = if self.top { - screen.y + fn role(key: Key) -> TileRole { + match key { + Key::Move(..) => TileRole::Character, + Key::More | Key::Movement | Key::Keyboard | Key::Dock | Key::Close => TileRole::Toolbar, + _ => TileRole::Utility, + } + } + /// `moving` shows the repeat-movement prompt and highlights nothing. + pub fn frame(&self, screen: Rect, units: f64, color: ScannerColor, moving: bool) -> Frame { + let row_scan = self.scan.row_scan(); + let (active_row, active_column) = self.scan.position(&self.rows); + let escaping = self.scan.nav.escaping(); + let status = if moving { + "Moving pointer · Press any switch to stop".to_owned() + } else if self.error { + "Mouse action failed · Select to resume".to_owned() + } else if self.scan.suspended { + "Mouse paused · Select to resume".to_owned() + } else if escaping { + crate::scan_panel::BACK_TO_ROWS.to_owned() } else { - screen.y + screen.height - height + crate::scan_panel::scanning_status( + if self.more { + "More controls" + } else { + "Movement" + }, + row_scan, + &self.label(self.rows[active_row][active_column.unwrap_or(0)]), + ) }; - let scale = units - .min(width / (columns * 160.0 + 32.0)) - .min(height / (self.rows.len() as f64 * 98.0 + 52.0)); - let mut frame = Frame::default(); - frame.tiles.push(FrameTile::panel_background( - Rect { - x, - y, - width, - height, - }, - scale, - color, - )); - let (active_row, active_column) = self.scan.position(&self.rows); - let row_height = (height - 52.0 * scale) / self.rows.len() as f64; - for (r, row) in self.rows.iter().enumerate() { - let cell_width = (width - 24.0 * scale) / row.len() as f64; - for (c, key) in row.iter().enumerate() { - frame.tiles.push(FrameTile { - thickness: self.scan.options.thickness, - color, - text: self.label(*key), - rect: Rect { - x: x + 12.0 * scale + c as f64 * cell_width, - y: y + 44.0 * scale + r as f64 * row_height, - width: (cell_width - 6.0 * scale).max(1.0), - height: (row_height - 6.0 * scale).max(1.0), - }, - scale, - icon: Item::KeyboardKey, - style: Some(TileStyle { - role: TileRole::Utility, - active: *key == Key::Drag && self.dragging, - row_scan: self.scan.row_scan(), - }), - selected: !self.scan.suspended - && !self.scan.nav.escaping() - && active_row == r - && active_column.is_none_or(|column| column == c), - }); - } + let scanning = !moving && !self.scan.suspended; + Panel { + rows: self + .rows + .iter() + .map(|row| { + row.iter() + .map(|&key| PanelKey { + text: self.label(key), + weight: 1.0, + role: Self::role(key), + active: key == Key::Drag && self.dragging, + }) + .collect() + }) + .collect(), + status, + top: self.top, + selected: (scanning && !escaping).then_some((active_row, active_column)), + status_selected: scanning && escaping, + row_scan, + thickness: self.scan.options.thickness, } - frame.label = Some(FrameLabel { - text: if self.error { - "Mouse action failed · Select to resume".into() - } else if self.more { - "Mouse · More controls".into() - } else { - "Mouse · Movement".into() - }, - rect: Rect { - x: x + 12.0 * scale, - y: y + 4.0 * scale, - width: width - 24.0 * scale, - height: 38.0 * scale, - }, - scale: scale * 0.8, - hud: None, - }); - frame + .frame(screen, units, color) } } @@ -266,11 +244,53 @@ mod tests { width: 1280.0, height: 720.0, }; - let frame = panel.frame(screen, 1.0, ScannerColor::default()); + let frame = panel.frame(screen, 1.0, ScannerColor::default(), false); assert!(frame.tiles.iter().any(|tile| tile.selected)); assert!(frame .tiles .iter() .all(|tile| tile.rect.x >= screen.x && tile.rect.y >= screen.y)); } + + #[test] + fn panel_matches_keyboard_geometry_and_reports_its_state() { + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + let color = ScannerColor::default(); + let mut panel = MousePanel::new(Resolved::default(), 1, 100); + let frame = panel.frame(screen, 1.0, color, false); + let keyboard = crate::scan_keyboard::Keyboard::new(false).frame(screen, 1.0, color); + assert_eq!(frame.tiles[0].rect, keyboard.tiles[0].rect); + assert!(frame.label.is_none()); + let status = frame.tiles.last().unwrap(); + assert_eq!(status.style.unwrap().role, TileRole::Status); + assert_eq!(status.text, "Movement · Select a row"); + assert_eq!(status.rect.x, keyboard.tiles.last().unwrap().rect.x); + let close = &frame.tiles[frame.tiles.len() - 2]; + assert_eq!(close.text, "Switch to Point"); + assert_eq!(close.style.unwrap().role, TileRole::Toolbar); + + panel.handle(Action::Select); + let status = panel.frame(screen, 1.0, color, false).tiles.pop().unwrap(); + assert_eq!(status.text, "Movement · Select ↖"); + + let moving = panel.frame(screen, 1.0, color, true); + assert!(moving.tiles.iter().all(|tile| !tile.selected)); + assert_eq!( + moving.tiles.last().unwrap().text, + "Moving pointer · Press any switch to stop" + ); + + panel.failed(); + let failed = panel.frame(screen, 1.0, color, false); + assert!(failed.tiles.iter().all(|tile| !tile.selected)); + assert_eq!( + failed.tiles.last().unwrap().text, + "Mouse action failed · Select to resume" + ); + } } diff --git a/src-tauri/src/scan_panel.rs b/src-tauri/src/scan_panel.rs new file mode 100644 index 00000000..cb30dd8e --- /dev/null +++ b/src-tauri/src/scan_panel.rs @@ -0,0 +1,211 @@ +//! Shared layout for switch-scanned panels: a docked background, a status tile +//! and weighted rows of keys. Panels supply content; geometry lives here. +use crate::{ + scan_menu::Item, + scan_preferences::Thickness, + scanning::{Frame, FrameTile, Rect, ScannerColor, TileRole, TileStyle}, +}; + +pub struct PanelKey { + pub text: String, + pub weight: f64, + pub role: TileRole, + pub active: bool, +} + +pub struct Panel { + pub rows: Vec>, + pub status: String, + pub top: bool, + /// Scanner position to highlight, or `None` when no key may be chosen. + pub selected: Option<(usize, Option)>, + /// Highlights the status tile, used while the scan offers to go back to rows. + pub status_selected: bool, + pub row_scan: bool, + pub thickness: Thickness, +} + +impl Panel { + pub fn frame(&self, screen: Rect, units: f64, color: ScannerColor) -> Frame { + let width = (1180.0 * units).min((screen.width - 24.0 * units).max(1.0)); + let height = (460.0 * units).min(screen.height * 0.55).max(1.0); + let x = screen.x + (screen.width - width) / 2.0; + let y = if self.top { + screen.y + } else { + screen.y + screen.height - height + }; + let outer_scale = units.min(height / 460.0).min(width / 1180.0); + let padding = 16.0 * outer_scale; + let content_width = (width - padding * 2.0).max(1.0); + let content_height = (height - padding * 2.0).max(1.0); + let row_height = content_height / (self.rows.len() as f64 + 0.9); + let scale = units.min(row_height / 60.0).min(content_width / 1040.0); + let gap = (6.0 * scale).min(row_height / 8.0); + let header_height = row_height * 0.9; + let mut frame = Frame::default(); + frame.tiles.push(FrameTile::panel_background( + Rect { + x, + y, + width, + height, + }, + outer_scale, + color, + )); + let x = x + padding; + let y = y + padding; + let width = content_width; + for (r, row) in self.rows.iter().enumerate() { + let total_weight: f64 = row.iter().map(|key| key.weight).sum(); + let cell_unit = (width - gap * (row.len() - 1) as f64).max(1.0) / total_weight; + let mut left = x; + for (c, key) in row.iter().enumerate() { + let key_width = cell_unit * key.weight; + frame.tiles.push(FrameTile { + thickness: self.thickness, + color, + text: key.text.clone(), + icon: Item::KeyboardKey, + style: Some(TileStyle { + role: key.role, + active: key.active, + row_scan: self.row_scan, + }), + rect: Rect { + x: left, + y: y + header_height + r as f64 * row_height, + width: key_width, + height: (row_height - gap).max(1.0), + }, + scale, + selected: self.selected.is_some_and(|(active_row, active_column)| { + r == active_row && active_column.is_none_or(|column| c == column) + }), + }); + left += key_width + gap; + } + } + frame.tiles.push(FrameTile { + thickness: self.thickness, + color, + text: self.status.clone(), + icon: Item::KeyboardKey, + style: Some(TileStyle { + role: TileRole::Status, + active: false, + row_scan: false, + }), + rect: Rect { + x, + y, + width, + height: (header_height - gap).max(1.0), + }, + scale, + selected: self.status_selected, + }); + frame + } +} + +/// Status wording shared by panels once error and pause states are handled. +pub fn scanning_status(page: &str, row_scan: bool, key: &str) -> String { + if row_scan { + format!("{page} · Select a row") + } else { + format!("{page} · Select {}", key.replace('\n', " ")) + } +} +pub const BACK_TO_ROWS: &str = "Back to rows · Select to return"; + +#[cfg(test)] +mod tests { + use super::*; + + fn key(text: &str, weight: f64) -> PanelKey { + PanelKey { + text: text.into(), + weight, + role: TileRole::Utility, + active: false, + } + } + + #[test] + fn rows_fill_the_padded_width_with_even_gaps_below_the_status_tile() { + let panel = Panel { + rows: vec![vec![key("a", 1.0), key("b", 2.0)], vec![key("c", 1.0)]], + status: "Page · Select a row".into(), + top: false, + selected: Some((1, None)), + status_selected: false, + row_scan: true, + thickness: Thickness::Thick, + }; + let screen = Rect { + x: -1920.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + let frame = panel.frame(screen, 1.0, ScannerColor::default()); + let [background, a, b, c, status] = frame.tiles.as_slice() else { + panic!("expected background, three keys and a status tile"); + }; + assert!(background.is_panel_background()); + assert_eq!(background.rect.width, 1180.0); + assert_eq!(background.rect.y + background.rect.height, 1080.0); + let (left, right) = (background.rect.x + 16.0, background.rect.x + 1180.0 - 16.0); + assert_eq!(a.rect.x, left); + assert!((b.rect.x + b.rect.width - right).abs() < 1e-9); + assert!((c.rect.x + c.rect.width - right).abs() < 1e-9); + assert!((b.rect.width - 2.0 * a.rect.width).abs() < 1e-9); + assert!((b.rect.x - (a.rect.x + a.rect.width) - 6.0).abs() < 1e-9); + assert_eq!(status.style.unwrap().role, TileRole::Status); + assert_eq!( + (status.rect.x, status.rect.y), + (left, background.rect.y + 16.0) + ); + assert!(status.rect.y + status.rect.height < a.rect.y); + assert_eq!( + frame.tiles.iter().filter(|tile| tile.selected).count(), + 1, + "row scan highlights only row 1" + ); + assert!(c.selected && c.thickness == Thickness::Thick); + } + + #[test] + fn docking_and_status_selection() { + let panel = Panel { + rows: vec![vec![key("a", 1.0)]], + status: BACK_TO_ROWS.into(), + top: true, + selected: None, + status_selected: true, + row_scan: false, + thickness: Thickness::default(), + }; + let screen = Rect { + x: 0.0, + y: 50.0, + width: 800.0, + height: 400.0, + }; + let frame = panel.frame(screen, 1.0, ScannerColor::default()); + assert_eq!(frame.tiles[0].rect.y, 50.0); + assert!(frame.tiles[0].rect.x >= 0.0 && frame.tiles[0].rect.width <= 800.0); + assert!(!frame.tiles[1].selected); + assert!(frame.tiles[2].selected); + assert_eq!( + scanning_status("Movement", false, "Caps lock\nOn"), + "Movement · Select Caps lock On" + ); + assert_eq!( + scanning_status("Movement", true, "x"), + "Movement · Select a row" + ); + } +} From fe46f8a955c0fc1417ff7015e223ae7f744c2382 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 23 Sep 2026 16:46:43 +0100 Subject: [PATCH 2/2] Tidy mouse speed labels and cover the moving prompt Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/point_workflow.rs | 6 ++++++ src-tauri/src/scan_mouse.rs | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index fc7e4fe3..40aa0a0d 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -909,6 +909,12 @@ mod tests { } } assert!(moved); + let frame = session.technique.frame(); + assert!(frame.tiles.iter().all(|tile| !tile.selected)); + assert_eq!( + frame.tiles.last().unwrap().text, + "Moving pointer · Press any switch to stop" + ); assert!(session.technique.switch_pressed()); assert!(!session.technique.switch_pressed()); assert_eq!( diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index ffd53f7d..339e4084 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -142,8 +142,8 @@ impl MousePanel { .into(), Scroll(1) => "Scroll up".into(), Scroll(_) => "Scroll down".into(), - Speed(-1) => format!("Slower · {}%", self.speed_percent), - Speed(_) => format!("Faster · {}%", self.speed_percent), + Speed(-1) => format!("Slower\n{}%", self.speed_percent), + Speed(_) => format!("Faster\n{}%", self.speed_percent), Monitor(-1, 0) => "Monitor left".into(), Monitor(0, -1) => "Monitor up".into(), Monitor(0, 1) => "Monitor down".into(), @@ -278,6 +278,12 @@ mod tests { let status = panel.frame(screen, 1.0, color, false).tiles.pop().unwrap(); assert_eq!(status.text, "Movement · Select ↖"); + panel.choose(Key::More); + panel.handle(Action::Next); + panel.handle(Action::Select); + let status = panel.frame(screen, 1.0, color, false).tiles.pop().unwrap(); + assert_eq!(status.text, "More controls · Select Slower 100%"); + let moving = panel.frame(screen, 1.0, color, true); assert!(moving.tiles.iter().all(|tile| !tile.selected)); assert_eq!(