From cb2edd7edc50c125fe4f3ab48dcc58439bfcf798 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 5 Oct 2026 08:59:47 +0100 Subject: [PATCH 1/2] Size scanning panels from content with shared presets --- docs/panel-sizing.md | 20 ++++ src-tauri/src/modifier_overlay_windows.rs | 100 +++++++++++++++++++ src-tauri/src/point_workflow.rs | 85 +++++++++++++--- src-tauri/src/scan_keyboard.rs | 45 +++++++++ src-tauri/src/scan_menu.rs | 53 +++++++++- src-tauri/src/scan_mouse.rs | 71 ++++++++++++- src-tauri/src/scan_panel.rs | 115 +++++++++++++++------- src-tauri/src/scan_preferences.rs | 54 ++++++++++ src/PointScan.test.tsx | 17 ++++ src/scanning/preferences.ts | 5 +- src/settings/ScannerPreferences.tsx | 8 +- 11 files changed, 515 insertions(+), 58 deletions(-) create mode 100644 docs/panel-sizing.md diff --git a/docs/panel-sizing.md b/docs/panel-sizing.md new file mode 100644 index 00000000..0e7a8b00 --- /dev/null +++ b/docs/panel-sizing.md @@ -0,0 +1,20 @@ +# Scanning panel sizing + +One shared Panel size preference in scanning Appearance controls mouse, keyboard, Home and action menus. Small, Medium and Large use 0.8, 1.0 and 1.2. Missing or unknown saved values use Medium; per-area overrides are not supported. Point lines, pointer rings and application settings windows are unaffected. + +Mouse keys use 128 logical pixels per weight unit; keyboard keys use 68. Rows use 56-pixel controls, 8-pixel gaps, 16-pixel outer padding and a 48-pixel status area. Width comes from the widest weighted row, including reserved blanks. Medium mouse Movement is 568 by 392; keyboard dimensions depend on layout and whether its six prediction slots are enabled. Menus keep their existing 180-pixel grid pitch at Medium. + +The preset and display scale are applied once to all geometry and text. A uniform fit factor keeps the complete panel within its display bounds. Status text, highlights and prediction contents cannot resize a panel. Page/layout changes may resize it. Docking, relocation and pointer avoidance use measured bounds. + +## Validation + +Geometry tests cover presets, fractional DPI, small and negative-coordinate displays, nine docks, directional alignment, all menus and stable keyboard prediction bounds. Existing fake-input scanning and prediction acceptance tests remain in the suite. UI and serialization tests cover old settings and the shared control. + +On Windows, export synthetic images with the actual GDI text renderer, without displaying a window or injecting input: + +```powershell +$env:SWITCHIFY_PANEL_FIXTURES = Join-Path $PWD '.cache/panel-fixtures' +cargo test --manifest-path src-tauri/Cargo.toml export_panel_size_fixtures -- --ignored --nocapture +``` + +Windows Small and Medium mouse and Medium keyboard fixtures were visually checked for labels, spacing, direction alignment and reserved prediction slots. macOS visual inspection is not available on this Windows machine; cross-platform compilation and tests are covered by CI. This change does not modify platform focus or click-through behavior. diff --git a/src-tauri/src/modifier_overlay_windows.rs b/src-tauri/src/modifier_overlay_windows.rs index 53b0c208..2c3df50d 100644 --- a/src-tauri/src/modifier_overlay_windows.rs +++ b/src-tauri/src/modifier_overlay_windows.rs @@ -461,6 +461,12 @@ fn rounded_rect(rect: Rect, radius: f32) -> Result { .ok_or_else(|| "the modifier overlay chip path is invalid".into()) } +// Captures only synthetic fixtures on the exporting test's thread, never desktop pixels. +#[cfg(test)] +thread_local! { + static PANEL_FIXTURE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + fn present_pixmap_with_text( window: HWND, labels: &[String], @@ -566,6 +572,34 @@ fn present_pixmap_with_text( ); } + #[cfg(test)] + if PANEL_FIXTURE.with(|fixture| fixture.borrow().is_some()) { + let mut pixels = vec![0; output.len()]; + copy_rgba_to_bgra(output, &mut pixels)?; + // GDI clears alpha on text pixels; the panel underneath is opaque. + for pixel in pixels.chunks_exact_mut(4) { + if pixel[0] != 0 || pixel[1] != 0 || pixel[2] != 0 { + pixel[3] = 255; + } + } + let tile = Pixmap::from_vec( + pixels, + tiny_skia::IntSize::from_wh(layout.width as u32, layout.height as u32).unwrap(), + ) + .unwrap(); + PANEL_FIXTURE.with(|fixture| { + fixture.borrow_mut().as_mut().unwrap().draw_pixmap( + layout.x, + layout.y, + tile.as_ref(), + &Default::default(), + Transform::identity(), + None, + ) + }); + return Ok(()); + } + let destination = POINT { x: layout.x, y: layout.y, @@ -873,6 +907,72 @@ mod tests { thread.join().unwrap(); } + #[test] + #[ignore = "Exports synthetic panels through the native Windows text renderer; no input or desktop capture"] + fn export_panel_size_fixtures() { + use crate::{ + scan_panel::Dock, + scan_preferences::{PanelSize, Resolved}, + scanning::{Rect, ScannerColor}, + }; + let directory = + std::env::var("SWITCHIFY_PANEL_FIXTURES").expect("fixture output directory"); + std::fs::create_dir_all(&directory).unwrap(); + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + for size in [PanelSize::Small, PanelSize::Medium, PanelSize::Large] { + let mut mouse = crate::scan_mouse::MousePanel::new( + Resolved { + panel_size: size, + ..Default::default() + }, + 2, + 100, + ); + mouse.dock = Dock { row: 0, column: 0 }; + let mut keyboard = crate::scan_keyboard::Keyboard::new(false); + keyboard.dock = mouse.dock; + keyboard.set_panel_size(size); + keyboard.enable_predictions(true); + for (name, frame) in [ + ( + "mouse", + mouse.frame(screen, 1.0, ScannerColor::default(), None), + ), + ( + "keyboard", + keyboard.frame(screen, 1.0, ScannerColor::default()), + ), + ] { + let bounds = frame.tiles[0].rect; + super::PANEL_FIXTURE.with(|fixture| { + *fixture.borrow_mut() = tiny_skia::Pixmap::new( + bounds.width.ceil() as u32, + bounds.height.ceil() as u32, + ) + }); + for tile in &frame.tiles { + super::present_scan_tile(windows::Win32::Foundation::HWND::default(), tile) + .unwrap(); + } + super::PANEL_FIXTURE.with(|fixture| { + fixture + .borrow_mut() + .take() + .unwrap() + .save_png( + std::path::Path::new(&directory).join(format!("{name}-{size:?}.png")), + ) + .unwrap() + }); + } + } + } + #[test] fn native_window_render_smoke_becomes_visible_and_hides() { let mut host = NativeHost::new().unwrap(); diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 3f3ce58d..b8c45d30 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -239,6 +239,16 @@ impl Workflow { self.open(Kind::Actions); } self.point.config = config; + self.keyboard + .set_panel_size(self.point.config.keyboard_scan.panel_size); + self.mouse + .set_panel_size(self.point.config.mouse_scan.panel_size); + self.menu + .set_panel_size(self.point.config.menu_scan.panel_size); + for menu in &mut self.parent_menu { + menu.set_panel_size(self.point.config.menu_scan.panel_size); + } + self.keyboard.set_layout(self.point.config.keyboard_layout); self.menu .set_period(self.point.config.menu_scan.interval_ms); @@ -405,10 +415,29 @@ impl Workflow { if !self.point.config.panel_avoids_pointer { return None; } + // Use the same measured frame as rendering, never a keyboard-sized proxy. + let frame = match self.stage { + Stage::Keyboard => self.keyboard.frame( + area, + self.point.units_per_logical_pixel, + self.point.config.scanner_color, + ), + _ => self.mouse.frame( + area, + self.point.units_per_logical_pixel, + self.point.config.mouse_scan.color, + None, + ), + }; + let bounds = frame + .tiles + .iter() + .find(|tile| tile.is_panel_background())? + .rect; self.dock.avoiding( self.pointer?, area, - self.point.units_per_logical_pixel, + (bounds.width, bounds.height), self.moved, ) } @@ -1110,13 +1139,7 @@ impl Technique for Workflow { _ => Frame::default(), }; if let Some(moved) = self.avoiding() { - crate::scan_panel::move_frame( - &mut frame, - self.dock, - moved, - panel_area, - self.point.units_per_logical_pixel, - ); + crate::scan_panel::move_frame(&mut frame, moved, panel_area); } if let Some(error) = &self.error { frame.tiles.clear(); @@ -2100,6 +2123,42 @@ mod tests { assert_eq!((workflow.keyboard.dock, workflow.mouse.dock), (dock, dock)); } + #[test] + fn size_changes_update_open_panels_without_resetting_the_scan() { + use crate::scan_preferences::PanelSize; + let screen = Rect { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + let mut config = last_mode(); + let mut workflow = Workflow::new(config.point(), screen, 1.0).unwrap(); + workflow.open_mouse(); + workflow.handle(Action::Next); + let selected = workflow + .frame() + .tiles + .iter() + .filter(|tile| tile.selected) + .map(|tile| tile.text.clone()) + .collect::>(); + config.scan_preferences.panel_size = PanelSize::Large; + workflow.apply_config(config.point(), false); + let frame = workflow.frame(); + assert!((frame.tiles[0].rect.width - 568.0 * 1.2).abs() < 1e-8); + assert_eq!( + selected, + frame + .tiles + .iter() + .filter(|tile| tile.selected) + .map(|tile| tile.text.clone()) + .collect::>() + ); + assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); + } + #[test] fn open_panels_move_out_of_the_pointers_way_only_when_enabled() { use crate::scan_panel::Dock; @@ -2110,8 +2169,8 @@ mod tests { height: 720.0, }; let background = |workflow: &Workflow| workflow.frame().tiles[0].rect; - let bottom = Dock::default().rect(screen, 1.0); - let top = Dock { column: 1, row: 0 }.rect(screen, 1.0); + let bottom = Dock::default().rect(screen, (568.0, 392.0)); + let top = Dock { column: 1, row: 0 }.rect(screen, (568.0, 392.0)); let over_bottom = Some((640.0, 700.0)); let mut workflow = Workflow::new(last_mode().point(), screen, 1.0).unwrap(); workflow.open_mouse(); @@ -2146,6 +2205,10 @@ mod tests { workflow.mouse_key(crate::scan_mouse::Key::Keyboard); workflow.execution_succeeded(); assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Keyboard)); + let keyboard_bounds = background(&workflow); + let size = (keyboard_bounds.width, keyboard_bounds.height); + let top = Dock { column: 1, row: 0 }.rect(screen, size); + let bottom = Dock::default().rect(screen, size); workflow.set_pointer(over_bottom); assert_eq!(background(&workflow), top, "the keyboard also moves"); workflow.set_pointer(None); @@ -2154,7 +2217,7 @@ mod tests { // A closed panel forgets where it moved, so a middle dock chooses afresh when reopened. let middle = Dock { column: 1, row: 1 }; workflow.set_dock(middle); - let area = middle.rect(screen, 1.0); + let area = middle.rect(screen, size); workflow.set_pointer(Some((640.0, area.y + 10.0))); assert_eq!(workflow.moved, Some(Dock::default())); workflow.keyboard_closed(); diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index c3ce64e2..3f37fc3f 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -315,6 +315,10 @@ pub struct Keyboard { pending_typed: Option, } impl Keyboard { + pub fn set_panel_size(&mut self, size: crate::scan_preferences::PanelSize) { + self.scan.options.panel_size = size; + } + #[cfg(test)] pub fn new(mac: bool) -> Self { Self::configured(mac, crate::scan_preferences::Resolved::default()) @@ -1004,6 +1008,8 @@ impl Keyboard { ) }; crate::scan_panel::Panel { + key_width: 68.0, + size: self.scan.options.panel_size, rows: self .rows .iter() @@ -2271,6 +2277,45 @@ mod tests { assert!(k.handle(Action::Select).is_none()); assert!(!k.suspended()); } + #[test] + fn layouts_and_predictions_keep_bounds_independent_of_text() { + use crate::{point_scan::KeyboardLayout, scan_preferences::PanelSize}; + let screen = Rect { + x: -1920.0, + y: -100.0, + width: 1920.0, + height: 1080.0, + }; + for layout in [ + KeyboardLayout::Qwerty, + KeyboardLayout::SimpleQwerty, + KeyboardLayout::CommonLetters, + ] { + for size in [PanelSize::Small, PanelSize::Medium, PanelSize::Large] { + let mut keyboard = Keyboard::new(false); + keyboard.set_layout(layout); + keyboard.set_panel_size(size); + keyboard.enable_predictions(true); + let before = keyboard.frame(screen, 1.0, ScannerColor::default()); + keyboard.predictions( + Some(crate::prediction::worker::Batch { + token: 42, + refined: true, + words: std::array::from_fn(|_| Some("extraordinary".into())), + }), + false, + ); + let after = keyboard.frame(screen, 1.0, ScannerColor::default()); + assert_eq!(before.tiles[0].rect, after.tiles[0].rect); + assert_eq!( + before.tiles.iter().map(|t| t.rect).collect::>(), + after.tiles.iter().map(|t| t.rect).collect::>() + ); + assert_eq!(keyboard.rows[0].len(), 6); + } + } + } + #[test] fn geometry_fits_work_area_at_every_dock_and_scale() { let docks = crate::scan_panel::position_rows(|dock| dock, Default::default()); diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index b57c6095..403c7982 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -114,6 +114,10 @@ pub struct Menu { period: u64, } impl Menu { + pub fn set_panel_size(&mut self, size: crate::scan_preferences::PanelSize) { + self.scan.options.panel_size = size; + } + #[cfg(test)] pub fn new(kind: Kind, period: u64) -> Self { Self::configured( @@ -182,9 +186,13 @@ impl Menu { let columns = self.rows.iter().map(Vec::len).max().unwrap_or(1) as f64; let logical_width = 16.0 + columns * 180.0; let logical_height = 56.0 + 180.0 * self.rows.len() as f64; - let scale = units - .min(screen.width / logical_width) - .min(screen.height / logical_height); + let scale = crate::scan_panel::fit_scale( + screen, + units, + self.scan.options.panel_size, + logical_width, + logical_height, + ); let width = logical_width * scale; let height = logical_height * scale; let panel = placed(width, height, 20.0 * scale); @@ -282,8 +290,8 @@ fn place(point: (i32, i32), screen: Rect, width: f64, height: f64, gap: f64) -> }) .unwrap_or(candidates[0]); Rect { - x: x.clamp(screen.x, screen.x + screen.width - width), - y: y.clamp(screen.y, screen.y + screen.height - height), + x: x.clamp(screen.x, (screen.x + screen.width - width).max(screen.x)), + y: y.clamp(screen.y, (screen.y + screen.height - height).max(screen.y)), width, height, } @@ -618,6 +626,41 @@ impl Kind { mod tests { use super::*; + #[test] + fn every_menu_uses_the_shared_preset_and_fits_the_screen() { + use crate::scan_preferences::PanelSize; + for kind in ALL_MENU_KINDS { + for size in [PanelSize::Small, PanelSize::Medium, PanelSize::Large] { + for units in [1.0, 1.5, 2.0] { + let screen = Rect { + x: -800.0, + y: -200.0, + width: 800.0, + height: 600.0, + }; + let mut menu = Menu::new(kind, 250); + menu.set_panel_size(size); + for frame in [ + menu.frame((-799, -199), screen, units), + menu.centered_frame(screen, units), + ] { + for tile in frame.tiles { + assert!( + tile.rect.x >= screen.x - 0.01 && tile.rect.y >= screen.y - 0.01 + ); + assert!( + tile.rect.x + tile.rect.width <= screen.x + screen.width + 0.01 + ); + assert!( + tile.rect.y + tile.rect.height <= screen.y + screen.height + 0.01 + ); + } + } + } + } + } + } + #[test] fn mouse_actions_expose_app_commands_without_point_only_controls() { let root = Kind::MouseActions diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index ebc2bd74..3d61926a 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -47,6 +47,10 @@ pub struct MousePanel { } impl MousePanel { + pub fn set_panel_size(&mut self, size: crate::scan_preferences::PanelSize) { + self.scan.options.panel_size = size; + } + pub fn new(options: Resolved, displays: usize, speed_percent: u16) -> Self { let rows = Self::rows(false, false, displays); let scan = ItemScanner::configured_rows(&rows, Policy::KEYBOARD, options); @@ -258,6 +262,8 @@ impl MousePanel { }; let scanning = repeating.is_none() && !self.scan.suspended; Panel { + key_width: 128.0, + size: self.scan.options.panel_size, rows: self .rows .iter() @@ -297,6 +303,64 @@ impl MousePanel { mod tests { use super::*; + #[test] + fn compact_geometry_scales_uniformly_and_keeps_direction_columns() { + use crate::scan_preferences::PanelSize; + for size in [PanelSize::Small, PanelSize::Medium, PanelSize::Large] { + for units in [1.0, 1.5, 2.0] { + for (width, height) in [(1920.0, 1080.0), (320.0, 240.0)] { + let screen = Rect { + x: -1920.0, + y: -100.0, + width, + height, + }; + let mut mouse = MousePanel::new( + Resolved { + panel_size: size, + ..Default::default() + }, + 1, + 100, + ); + for row in 0..3 { + for column in 0..3 { + mouse.dock = Dock { row, column }; + let frame = mouse.frame(screen, units, ScannerColor::default(), None); + let bounds = frame.tiles[0].rect; + let scale = (units * size.scale()) + .min(width / 568.0) + .min(height / 392.0); + assert!((bounds.width - 568.0 * scale).abs() < 1e-8); + assert!((bounds.height - 392.0 * scale).abs() < 1e-8); + for tile in &frame.tiles { + assert!( + tile.rect.x >= screen.x - 1e-8 + && tile.rect.y >= screen.y - 1e-8 + ); + assert!(tile.rect.x + tile.rect.width <= screen.x + width + 1e-8); + assert!(tile.rect.y + tile.rect.height <= screen.y + height + 1e-8); + } + // Background, four actions, four upper directions, then left/right. + assert_eq!(frame.tiles[5].rect.x, frame.tiles[9].rect.x); + assert_eq!(frame.tiles[7].rect.x, frame.tiles[10].rect.x); + assert!((frame.tiles[1].scale - scale).abs() < 1e-8); + mouse.error = true; + assert_eq!( + mouse + .frame(screen, units, ScannerColor::default(), None) + .tiles[0] + .rect, + bounds + ); + mouse.error = false; + } + } + } + } + } + } + #[test] fn pages_and_monitor_controls_follow_available_displays() { let mut panel = MousePanel::new(Resolved::default(), 1, 100); @@ -530,7 +594,7 @@ mod tests { } #[test] - fn panel_matches_keyboard_geometry_and_reports_its_state() { + fn panel_is_narrower_than_keyboard_and_reports_its_state() { let screen = Rect { x: 0.0, y: 0.0, @@ -541,12 +605,13 @@ mod tests { let mut panel = MousePanel::new(Resolved::default(), 1, 100); let frame = panel.frame(screen, 1.0, color, None); 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.tiles[0].rect.width < keyboard.tiles[0].rect.width); + assert_eq!(frame.tiles[0].rect.width, 568.0); 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); + assert_eq!(status.rect.x, frame.tiles[0].rect.x + 16.0); let close = &frame.tiles[frame.tiles.len() - 2]; assert_eq!(close.text, "Switch to Point"); assert_eq!(close.style.unwrap().role, TileRole::Toolbar); diff --git a/src-tauri/src/scan_panel.rs b/src-tauri/src/scan_panel.rs index 84d16404..b24e209f 100644 --- a/src-tauri/src/scan_panel.rs +++ b/src-tauri/src/scan_panel.rs @@ -2,10 +2,17 @@ //! and weighted rows of keys. Panels supply content; geometry lives here. use crate::{ scan_menu::Item, - scan_preferences::Thickness, + scan_preferences::{PanelSize, Thickness}, scanning::{Frame, FrameTile, Rect, ScannerColor, TileRole, TileStyle}, }; +/// One scale for the complete panel, including text and artwork. +pub fn fit_scale(screen: Rect, units: f64, size: PanelSize, width: f64, height: f64) -> f64 { + (units * size.scale()) + .min(screen.width.max(1.0) / width) + .min(screen.height.max(1.0) / height) +} + pub struct PanelKey { pub text: String, pub weight: f64, @@ -54,9 +61,7 @@ impl Dock { start + (length - size) * f64::from(slot.min(2)) / 2.0 } /// Outer rectangle of a panel docked here. - pub fn rect(self, screen: Rect, units: f64) -> Rect { - 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); + pub fn rect(self, screen: Rect, (width, height): (f64, f64)) -> Rect { Rect { x: Self::place(self.column, screen.x, screen.width, width), y: Self::place(self.row, screen.y, screen.height, height), @@ -72,18 +77,18 @@ impl Dock { self, pointer: (f64, f64), screen: Rect, - units: f64, + size: (f64, f64), moved: Option, ) -> Option { let covers = |dock: Dock| { - let rect = dock.rect(screen, units); + let rect = dock.rect(screen, size); let (x, y) = pointer; x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height }; if !covers(self) { return None; } - let rect = self.rect(screen, units); + let rect = self.rect(screen, size); let away = if pointer.1 < rect.y + rect.height / 2.0 { 2 } else { @@ -104,8 +109,12 @@ impl Dock { } /// Shifts a panel frame drawn at `from` so it is drawn at `to` instead. -pub fn move_frame(frame: &mut Frame, from: Dock, to: Dock, screen: Rect, units: f64) { - let (from, to) = (from.rect(screen, units), to.rect(screen, units)); +pub fn move_frame(frame: &mut Frame, to: Dock, screen: Rect) { + let Some(background) = frame.tiles.iter().find(|tile| tile.is_panel_background()) else { + return; + }; + let from = background.rect; + let to = to.rect(screen, (from.width, from.height)); for tile in &mut frame.tiles { tile.rect.x += to.x - from.x; tile.rect.y += to.y - from.y; @@ -134,6 +143,8 @@ pub fn position_label(dock: Dock, current: Dock) -> String { } pub struct Panel { + pub key_width: f64, + pub size: PanelSize, pub rows: Vec>, pub status: String, /// Passive feedback beside the scanning prompt; never a scan target. @@ -147,20 +158,33 @@ pub struct Panel { impl Panel { pub fn frame(&self, screen: Rect, units: f64, color: ScannerColor) -> Frame { + let preferred_width = self + .rows + .iter() + .map(|row| { + row.iter().map(|key| key.weight).sum::() * self.key_width + + row.len().saturating_sub(1) as f64 * 8.0 + }) + .fold(0.0, f64::max) + + 32.0; + let preferred_height = 32.0 + + 48.0 + + self.rows.len() as f64 * 56.0 + + self.rows.len().saturating_sub(1) as f64 * 8.0; + let scale = fit_scale(screen, units, self.size, preferred_width, preferred_height); let Rect { x, y, width, height, - } = self.dock.rect(screen, units); - 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; + } = self + .dock + .rect(screen, (preferred_width * scale, preferred_height * scale)); + let padding = 16.0 * scale; + let content_width = width - padding * 2.0; + let gap = 8.0 * scale; + let row_height = 64.0 * scale; + let header_height = 48.0 * scale; let mut frame = Frame::default(); frame.tiles.push(FrameTile::panel_background( Rect { @@ -169,7 +193,7 @@ impl Panel { width, height, }, - outer_scale, + scale, color, )); let x = x + padding; @@ -177,7 +201,8 @@ impl Panel { 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 cell_unit = + (width - gap * row.len().saturating_sub(1) as f64).max(1.0) / total_weight; let mut left = x; let mut c = 0; for key in row { @@ -283,6 +308,8 @@ mod tests { #[test] fn rows_fill_the_padded_width_with_even_gaps_below_the_status_tile() { let panel = Panel { + key_width: 380.0, + size: PanelSize::Medium, rows: vec![vec![key("a", 1.0), key("b", 2.0)], vec![key("c", 1.0)]], status: "Page · Select a row".into(), note: None, @@ -309,7 +336,7 @@ mod tests { 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!((b.rect.x - (a.rect.x + a.rect.width) - 8.0).abs() < 1e-9); assert_eq!(status.style.unwrap().role, TileRole::Status); assert_eq!( (status.rect.x, status.rect.y), @@ -327,6 +354,8 @@ mod tests { #[test] fn docking_and_row_escape_selection() { let panel = Panel { + key_width: 380.0, + size: PanelSize::Medium, rows: vec![vec![key("a", 1.0)]], status: BACK_TO_ROWS.into(), note: None, @@ -366,39 +395,42 @@ mod tests { }; let dock = |column, row| Dock { column, row }; for column in 0..3 { - let bottom = dock(column, 2).rect(screen, 1.0); + let bottom = dock(column, 2).rect(screen, (1180.0, 460.0)); let inside = (bottom.x + 1.0, bottom.y + bottom.height - 1.0); let outside = (bottom.x + 1.0, bottom.y - 1.0); assert_eq!( - dock(column, 2).avoiding(inside, screen, 1.0, None), + dock(column, 2).avoiding(inside, screen, (1180.0, 460.0), None), Some(dock(column, 0)) ); - assert_eq!(dock(column, 2).avoiding(outside, screen, 1.0, None), None); - let top = dock(column, 0).rect(screen, 1.0); assert_eq!( - dock(column, 0).avoiding((top.x, top.y), screen, 1.0, None), + dock(column, 2).avoiding(outside, screen, (1180.0, 460.0), None), + None + ); + let top = dock(column, 0).rect(screen, (1180.0, 460.0)); + assert_eq!( + dock(column, 0).avoiding((top.x, top.y), screen, (1180.0, 460.0), None), Some(dock(column, 2)) ); } - let middle = dock(1, 1).rect(screen, 1.0); + let middle = dock(1, 1).rect(screen, (1180.0, 460.0)); let upper = (middle.x + 10.0, middle.y + 10.0); let lower = (middle.x + 10.0, middle.y + middle.height - 10.0); assert_eq!( - dock(1, 1).avoiding(upper, screen, 1.0, None), + dock(1, 1).avoiding(upper, screen, (1180.0, 460.0), None), Some(dock(1, 2)) ); assert_eq!( - dock(1, 1).avoiding(lower, screen, 1.0, None), + dock(1, 1).avoiding(lower, screen, (1180.0, 460.0), None), Some(dock(1, 0)) ); let centre = (middle.x + 10.0, middle.y + middle.height / 2.0 + 1.0); assert_eq!( - dock(1, 1).avoiding(centre, screen, 1.0, Some(dock(1, 2))), + dock(1, 1).avoiding(centre, screen, (1180.0, 460.0), Some(dock(1, 2))), Some(dock(1, 2)), "a moved middle panel stays put while its end is clear of the pointer" ); assert_eq!( - dock(1, 1).avoiding(lower, screen, 1.0, Some(dock(1, 2))), + dock(1, 1).avoiding(lower, screen, (1180.0, 460.0), Some(dock(1, 2))), Some(dock(1, 0)), "a moved middle panel leaves an end the pointer reaches" ); @@ -411,14 +443,22 @@ mod tests { height: 700.0, }; let overlap = (640.0, 350.0); - assert_eq!(dock(1, 0).avoiding(overlap, short, 1.0, None), None); - assert_eq!(dock(1, 2).avoiding(overlap, short, 1.0, None), None); assert_eq!( - dock(1, 2).avoiding((640.0, 690.0), short, 1.0, None), + dock(1, 0).avoiding(overlap, short, (1180.0, 460.0), None), + None + ); + assert_eq!( + dock(1, 2).avoiding(overlap, short, (1180.0, 460.0), None), + None + ); + assert_eq!( + dock(1, 2).avoiding((640.0, 690.0), short, (1180.0, 460.0), None), Some(dock(1, 0)) ); let mut frame = Panel { + key_width: 380.0, + size: PanelSize::Medium, rows: vec![vec![key("a", 1.0)]], status: String::new(), note: None, @@ -429,8 +469,9 @@ mod tests { } .frame(screen, 1.0, ScannerColor::default()); let key_offset = frame.tiles[1].rect.y - frame.tiles[0].rect.y; - move_frame(&mut frame, Dock::default(), dock(1, 0), screen, 1.0); - assert_eq!(frame.tiles[0].rect, dock(1, 0).rect(screen, 1.0)); + let size = (frame.tiles[0].rect.width, frame.tiles[0].rect.height); + move_frame(&mut frame, dock(1, 0), screen); + assert_eq!(frame.tiles[0].rect, dock(1, 0).rect(screen, size)); assert_eq!(frame.tiles[1].rect.y - frame.tiles[0].rect.y, key_offset); } @@ -448,6 +489,8 @@ mod tests { for dock in rows[..3].iter().flatten().copied() { assert!(labels.insert(dock.label())); let panel = Panel { + key_width: 380.0, + size: PanelSize::Medium, rows: vec![vec![key("a", 1.0)]], status: String::new(), note: None, diff --git a/src-tauri/src/scan_preferences.rs b/src-tauri/src/scan_preferences.rs index c37895a9..0fd08aa8 100644 --- a/src-tauri/src/scan_preferences.rs +++ b/src-tauri/src/scan_preferences.rs @@ -32,6 +32,24 @@ impl Thickness { } } } +/// Shared visual size for panels; never changes point scanning or pointer markers. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PanelSize { + Small, + #[default] + Medium, + Large, +} +impl PanelSize { + pub fn scale(self) -> f64 { + match self { + Self::Small => 0.8, + Self::Medium => 1.0, + Self::Large => 1.2, + } + } +} /// Whether scanning moves on by itself after a selection that performed /// an action. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -76,6 +94,7 @@ pub struct Overrides { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Preferences { + pub panel_size: PanelSize, pub direction: Direction, pub pass_limit: usize, pub pattern: Pattern, @@ -90,6 +109,7 @@ pub struct Preferences { impl Default for Preferences { fn default() -> Self { Self { + panel_size: PanelSize::Medium, direction: Direction::Forward, pass_limit: 3, pattern: Pattern::Grouped, @@ -105,6 +125,7 @@ impl Default for Preferences { } #[derive(Debug, Clone, Copy, PartialEq)] pub struct Resolved { + pub panel_size: PanelSize, pub automatic: bool, pub interval_ms: u64, pub direction: Direction, @@ -143,6 +164,7 @@ impl Preferences { Area::Mouse => self.mouse, }; Resolved { + panel_size: self.panel_size, automatic: local.automatic.unwrap_or(automatic), interval_ms: local .interval_ms @@ -200,6 +222,7 @@ fn forget_unknown_choices(value: &mut serde_json::Value, optional: bool) { return; }; fields.retain(|name, choice| match name.as_str() { + "panelSize" => !optional && PanelSize::deserialize(&*choice).is_ok(), "nextScan" => (optional && choice.is_null()) || NextScan::deserialize(&*choice).is_ok(), "startFrom" => (optional && choice.is_null()) || StartFrom::deserialize(&*choice).is_ok(), _ => true, @@ -212,6 +235,37 @@ mod tests { fn read(value: serde_json::Value) -> Preferences { deserialize_preferences(value).unwrap() } + #[test] + fn shared_panel_size_defaults_round_trips_and_ignores_local_overrides() { + for value in [ + serde_json::json!({}), + serde_json::json!({"panelSize":"future", "direction":"reverse"}), + ] { + let preferences = read(value.clone()); + assert_eq!(preferences.panel_size, PanelSize::Medium); + if value.get("direction").is_some() { + assert_eq!(preferences.direction, Direction::Reverse); + } + } + for size in [PanelSize::Small, PanelSize::Medium, PanelSize::Large] { + let preferences = Preferences { + panel_size: size, + ..Default::default() + }; + let mut value = serde_json::to_value(&preferences).unwrap(); + value["mouse"] = serde_json::json!({"panelSize":"small"}); + let restored = read(value); + for area in [Area::Menu, Area::Mouse, Area::Keyboard] { + assert_eq!( + restored + .resolve(area, false, 1000, ScannerColor::Blue) + .panel_size, + size + ); + } + } + } + #[test] fn after_selection_choices_default_to_standard_and_round_trip() { let saved = read(serde_json::json!({"direction":"reverse"})); diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 7fd422ee..a5b28f95 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -619,3 +619,20 @@ it.each(["press", "release"] as const)("reports the saved %s stop instruction du const instruction = mouseRepeatStopEdge === "press" ? "Press a switch to stop" : "Press and release a switch to stop"; await screen.findByText(`Scrolling. ${instruction}.`); }); + +it("defaults old settings to Medium and saves one shared panel size", async () => { + const oldPreferences = { ...defaultScanPreferences, panelSize: undefined }; + mocks.invoke.mockImplementation(async (command, args) => command === "get_point_scan" + ? { ...initial, config: { ...initial.config, scanPreferences: oldPreferences } } + : { ...initial, ...args }); + render(); + await screen.findByText(initial.message); + more(); + expect(screen.getByRole("button", { name: "Medium" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "Small" })); + await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: expect.objectContaining({ scanPreferences: expect.objectContaining({ panelSize: "small" }) }) + })); + fireEvent.click(screen.getByRole("button", { name: "Customise mouse scanning" })); + expect(screen.queryByText("Panel size")).not.toBeInTheDocument(); +}); diff --git a/src/scanning/preferences.ts b/src/scanning/preferences.ts index c1bcd958..87b8d07b 100644 --- a/src/scanning/preferences.ts +++ b/src/scanning/preferences.ts @@ -1,4 +1,5 @@ import type { PointScanConfig, ScannerColor } from './useScanning'; +export type PanelSize = 'small' | 'medium' | 'large'; export type ScanArea = 'point' | 'menu' | 'keyboard' | 'mouse'; export type ScanOptions = { automatic: boolean; @@ -13,9 +14,9 @@ export type ScanOptions = { startFrom: 'standard' | 'beginning' | 'selection'; }; export type ScanPreferences = Pick - & Partial> & Record>; + & { panelSize?: PanelSize } & Partial> & Record>; export const defaultScanPreferences: ScanPreferences = { - direction: 'forward', passLimit: 3, pattern: 'grouped', thickness: 'standard', + panelSize: 'medium', direction: 'forward', passLimit: 3, pattern: 'grouped', thickness: 'standard', point: {}, menu: {}, keyboard: {}, mouse: {}, }; /** Wait after typing, saved before the choice existed for every scanner, is still in effect. */ diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index f5a51dae..c27a9a8b 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -2,7 +2,7 @@ import { Button, Input, Select, MoreOptions } from "../ui/controls"; import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { ChevronRight } from 'lucide-react'; import { lastModeName, mouseRepeatStopInstruction, selectOpensHome, type ScanningController, type ScannerColor, type PointScanConfig } from '../scanning/useScanning'; -import { areaOptions, sharedOptions, defaultScanPreferences, usualAfterSelection, savedKeyboardWait, type ScanArea, type ScanOptions } from '../scanning/preferences'; +import { areaOptions, sharedOptions, defaultScanPreferences, usualAfterSelection, savedKeyboardWait, type PanelSize, type ScanArea, type ScanOptions } from '../scanning/preferences'; import { SettingGroup, Toggle, OptionGroup } from './controls'; export function ScannerPreferences({ controller }: { controller: ScanningController }) { @@ -210,6 +210,12 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl note={{ summary: 'Grouped scanning chooses a row or group before an item. Linear scanning visits each item directly. Point scanning uses its own line and grid modes.' }} />)} + {area === 'shared' && + legend="Panel size" disabled={disabled} value={settings.panelSize ?? 'medium'} + options={[{ value: 'small', label: 'Small' }, { value: 'medium', label: 'Medium' }, { value: 'large', label: 'Large' }]} + onChange={panelSize => update('scanPreferences', { ...settings, panelSize })} + note={{ summary: 'Sizes the mouse panel, keyboard, Home and action menus. Panels fit their controls and shrink to fit the screen.' }} + />} {field('color', 'Scanner colour', locked =>
Scanner colour
{(['red', 'green', 'blue', 'yellow', 'white'] as const).map(colour => )}
)} From c5fc08fc8101f85b837c60da2d738d6f2882952b Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 5 Oct 2026 09:03:30 +0100 Subject: [PATCH 2/2] Keep position labels in wider utility cells --- docs/panel-sizing.md | 2 +- src-tauri/src/modifier_overlay_windows.rs | 5 ++++- src-tauri/src/scan_keyboard.rs | 11 ++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/panel-sizing.md b/docs/panel-sizing.md index 0e7a8b00..9fd11459 100644 --- a/docs/panel-sizing.md +++ b/docs/panel-sizing.md @@ -2,7 +2,7 @@ One shared Panel size preference in scanning Appearance controls mouse, keyboard, Home and action menus. Small, Medium and Large use 0.8, 1.0 and 1.2. Missing or unknown saved values use Medium; per-area overrides are not supported. Point lines, pointer rings and application settings windows are unaffected. -Mouse keys use 128 logical pixels per weight unit; keyboard keys use 68. Rows use 56-pixel controls, 8-pixel gaps, 16-pixel outer padding and a 48-pixel status area. Width comes from the widest weighted row, including reserved blanks. Medium mouse Movement is 568 by 392; keyboard dimensions depend on layout and whether its six prediction slots are enabled. Menus keep their existing 180-pixel grid pitch at Medium. +Mouse keys use 128 logical pixels per weight unit; keyboard character pages use 68. The keyboard Position page uses 128 for its text labels. Rows use 56-pixel controls, 8-pixel gaps, 16-pixel outer padding and a 48-pixel status area. Width comes from the widest weighted row, including reserved blanks. Medium mouse Movement is 568 by 392; keyboard dimensions depend on layout and whether its six prediction slots are enabled. Menus keep their existing 180-pixel grid pitch at Medium. The preset and display scale are applied once to all geometry and text. A uniform fit factor keeps the complete panel within its display bounds. Status text, highlights and prediction contents cannot resize a panel. Page/layout changes may resize it. Docking, relocation and pointer avoidance use measured bounds. diff --git a/src-tauri/src/modifier_overlay_windows.rs b/src-tauri/src/modifier_overlay_windows.rs index 2c3df50d..a70fa688 100644 --- a/src-tauri/src/modifier_overlay_windows.rs +++ b/src-tauri/src/modifier_overlay_windows.rs @@ -938,13 +938,16 @@ mod tests { keyboard.dock = mouse.dock; keyboard.set_panel_size(size); keyboard.enable_predictions(true); + let keyboard_frame = keyboard.frame(screen, 1.0, ScannerColor::default()); + keyboard.fixture_page(crate::scan_keyboard::Key::Dock); for (name, frame) in [ ( "mouse", mouse.frame(screen, 1.0, ScannerColor::default(), None), ), + ("keyboard", keyboard_frame), ( - "keyboard", + "keyboard-position", keyboard.frame(screen, 1.0, ScannerColor::default()), ), ] { diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 3f37fc3f..388aaff7 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -315,6 +315,11 @@ pub struct Keyboard { pending_typed: Option, } impl Keyboard { + #[cfg(all(test, target_os = "windows"))] + pub(crate) fn fixture_page(&mut self, key: Key) { + self.choose(key); + } + pub fn set_panel_size(&mut self, size: crate::scan_preferences::PanelSize) { self.scan.options.panel_size = size; } @@ -1008,7 +1013,7 @@ impl Keyboard { ) }; crate::scan_panel::Panel { - key_width: 68.0, + key_width: if self.positioning { 128.0 } else { 68.0 }, size: self.scan.options.panel_size, rows: self .rows @@ -2359,6 +2364,10 @@ mod tests { }; let frame = k.frame(screen, 1.0, ScannerColor::default()); assert_eq!(frame.tiles.last().unwrap().text, "Position · Select a row"); + assert_eq!( + frame.tiles[1].rect.width, 128.0, + "position labels use utility cells rather than character cells" + ); assert!(frame.tiles.iter().any(|tile| tile.text == "Bottom •")); k.choose(Key::Back);