From 43c6461db6ef4356b835f75cfa4e991f48324a88 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 30 Sep 2026 09:48:45 +0100 Subject: [PATCH] Add Simple QWERTY scanning keyboard layout --- src-tauri/src/point_scan.rs | 7 ++ src-tauri/src/point_workflow.rs | 58 +++++----- src-tauri/src/scan_keyboard.rs | 169 +++++++++++++++++++++++++++- src/PointScan.test.tsx | 7 +- src/scanning/useScanning.ts | 2 +- src/settings/ScannerPreferences.tsx | 11 +- src/settings/ScanningSection.tsx | 2 +- 7 files changed, 217 insertions(+), 39 deletions(-) diff --git a/src-tauri/src/point_scan.rs b/src-tauri/src/point_scan.rs index 38c0e8f4..f9a5404d 100644 --- a/src-tauri/src/point_scan.rs +++ b/src-tauri/src/point_scan.rs @@ -23,6 +23,7 @@ pub enum ControlMode { pub enum KeyboardLayout { #[default] Qwerty, + SimpleQwerty, CommonLetters, } @@ -1194,6 +1195,12 @@ mod tests { serde_json::to_value(chosen).unwrap()["keyboardLayout"], "commonLetters" ); + let simple: Config = serde_json::from_str(r#"{"keyboardLayout":"simpleQwerty"}"#).unwrap(); + assert_eq!(simple.keyboard_layout, KeyboardLayout::SimpleQwerty); + assert_eq!( + serde_json::to_value(simple).unwrap()["keyboardLayout"], + "simpleQwerty" + ); assert!(serde_json::from_str::(r#"{"keyboardLayout":"unknown"}"#).is_err()); } #[test] diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index ff12fe78..dab06d65 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -1980,34 +1980,36 @@ mod tests { height: 720.0, }; for mode in [ControlMode::Point, ControlMode::Mouse] { - let config = Config { - control_mode: mode, - keyboard_layout: KeyboardLayout::CommonLetters, - ..Config::default() - }; - let mut workflow = Workflow::new(config.point(), screen, 1.0).unwrap(); - if mode == ControlMode::Mouse { - workflow.handle(Action::OpenMouse); - } - assert_eq!( - workflow.handle(Action::OpenKeyboard), - Some(Request::OpenKeyboard) - ); - workflow.execution_succeeded(); - assert_eq!(workflow.keyboard.layout, KeyboardLayout::CommonLetters); - workflow.keyboard_layout_pending = true; - workflow.execution_failed("Cannot save scanning settings.".into()); - assert_eq!(workflow.keyboard.layout, KeyboardLayout::CommonLetters); - assert!(!workflow.keyboard.suspended()); - let next = Config { - keyboard_layout: KeyboardLayout::Qwerty, - ..config - }; - workflow.apply_config(next.point(), false); - assert_eq!(workflow.keyboard.layout, KeyboardLayout::Qwerty); - workflow.keyboard_closed(); - if mode == ControlMode::Mouse { - assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); + for layout in [KeyboardLayout::CommonLetters, KeyboardLayout::SimpleQwerty] { + let config = Config { + control_mode: mode, + keyboard_layout: layout, + ..Config::default() + }; + let mut workflow = Workflow::new(config.point(), screen, 1.0).unwrap(); + if mode == ControlMode::Mouse { + workflow.handle(Action::OpenMouse); + } + assert_eq!( + workflow.handle(Action::OpenKeyboard), + Some(Request::OpenKeyboard) + ); + workflow.execution_succeeded(); + assert_eq!(workflow.keyboard.layout, layout); + workflow.keyboard_layout_pending = true; + workflow.execution_failed("Cannot save scanning settings.".into()); + assert_eq!(workflow.keyboard.layout, layout); + assert!(!workflow.keyboard.suspended()); + let next = Config { + keyboard_layout: KeyboardLayout::Qwerty, + ..config + }; + workflow.apply_config(next.point(), false); + assert_eq!(workflow.keyboard.layout, KeyboardLayout::Qwerty); + workflow.keyboard_closed(); + if mode == ControlMode::Mouse { + assert_eq!(workflow.phase(), Phase::Workflow(WorkflowPhase::Mouse)); + } } } } diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 13d147a9..a84257e6 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -198,6 +198,22 @@ fn rows_for(page: Page, mac: bool, layout: crate::point_scan::KeyboardLayout) -> use Key::{Character as C, Named as N}; if page != Page::Letters || layout == KeyboardLayout::Qwerty { let mut result = rows(page, mac); + if page == Page::Numbers && layout == KeyboardLayout::SimpleQwerty { + let before_modifiers = result.len() - 2; + result.splice( + before_modifiers..before_modifiers, + [ + vec![ + C('[', '{'), + C(']', '}'), + C(';', ':'), + C('\'', '@'), + C('#', '~'), + ], + vec![C('\\', '|'), C(',', '<'), C('.', '>'), C('/', '?')], + ], + ); + } result.last_mut().unwrap().push(Key::Layout); return result; } @@ -206,6 +222,28 @@ fn rows_for(page: Page, mac: bool, layout: crate::point_scan::KeyboardLayout) -> .map(|letter| C(letter, letter.to_ascii_uppercase())) .collect::>() }; + if layout == KeyboardLayout::SimpleQwerty { + let mut top = letters("qwertyuiop"); + top.push(N("Backspace")); + let mut middle = letters("asdfghjkl"); + middle.push(N("Enter")); + let mut bottom = vec![Key::Modifier(0)]; + bottom.extend(letters("zxcvbnm")); + bottom.push(C(' ', ' ')); + return vec![ + top, + middle, + bottom, + vec![ + Key::Close, + Key::Page(Page::Letters), + Key::Page(Page::Functions), + Key::Page(Page::Numbers), + Key::Dock, + Key::Layout, + ], + ]; + } let mut first = vec![C(' ', ' ')]; first.extend(letters("etaoi")); first.push(N("Backspace")); @@ -601,7 +639,17 @@ impl Keyboard { } let mark = stroke.character()?; let supported = matches!(mark, '.' | ',' | '!' | '?' | ':' | ';'); - (supported && (self.page == Page::Letters || mark == '!')).then_some(mark) + let simple_punctuation = self.page == Page::Numbers + && self.layout == crate::point_scan::KeyboardLayout::SimpleQwerty + && matches!( + stroke.key, + Key::Character(',', '<') + | Key::Character('.', '>') + | Key::Character(';', ':') + | Key::Character('/', '?') + ); + (supported && (self.page == Page::Letters || simple_punctuation || mark == '!')) + .then_some(mark) } fn choose_with_context(&mut self, key: Key, context: Option) -> Option { self.discard_stale_context(context); @@ -646,6 +694,9 @@ impl Keyboard { Key::Layout => { return Some(Output::Layout(match self.layout { crate::point_scan::KeyboardLayout::Qwerty => { + crate::point_scan::KeyboardLayout::SimpleQwerty + } + crate::point_scan::KeyboardLayout::SimpleQwerty => { crate::point_scan::KeyboardLayout::CommonLetters } crate::point_scan::KeyboardLayout::CommonLetters => { @@ -866,7 +917,8 @@ impl Keyboard { if page == self.page { " •" } else { "" } ), Key::Layout => match self.layout { - crate::point_scan::KeyboardLayout::Qwerty => "Use common letters", + crate::point_scan::KeyboardLayout::Qwerty => "Use simple QWERTY", + crate::point_scan::KeyboardLayout::SimpleQwerty => "Use common letters", crate::point_scan::KeyboardLayout::CommonLetters => "Use QWERTY", } .into(), @@ -916,6 +968,7 @@ impl Keyboard { _ if self.positioning => crate::scan_panel::POSITION_PAGE, Page::Letters => match self.layout { crate::point_scan::KeyboardLayout::Qwerty => "Letters · QWERTY", + crate::point_scan::KeyboardLayout::SimpleQwerty => "Letters · Simple QWERTY", crate::point_scan::KeyboardLayout::CommonLetters => { "Letters · Common letters first" } @@ -1858,15 +1911,123 @@ mod tests { } } #[test] + fn simple_qwerty_has_three_letter_rows_and_keeps_removed_keys_reachable() { + use crate::point_scan::KeyboardLayout; + let letters = rows_for(Page::Letters, false, KeyboardLayout::SimpleQwerty); + let line = |text: &str| { + text.chars() + .map(|c| Key::Character(c, c.to_ascii_uppercase())) + .collect::>() + }; + let mut top = line("qwertyuiop"); + top.push(Key::Named("Backspace")); + let mut middle = line("asdfghjkl"); + middle.push(Key::Named("Enter")); + let mut bottom = vec![Key::Modifier(0)]; + bottom.extend(line("zxcvbnm")); + bottom.push(Key::Character(' ', ' ')); + assert_eq!(&letters[..3], &[top, middle, bottom]); + assert_eq!(letters.len(), 4); + assert!(letters[3].contains(&Key::Layout)); + let numbers = rows_for(Page::Numbers, false, KeyboardLayout::SimpleQwerty); + assert_eq!( + numbers[numbers.len() - 4], + vec![ + Key::Character('[', '{'), + Key::Character(']', '}'), + Key::Character(';', ':'), + Key::Character('\'', '@'), + Key::Character('#', '~'), + ] + ); + assert_eq!( + numbers[numbers.len() - 3], + vec![ + Key::Character('\\', '|'), + Key::Character(',', '<'), + Key::Character('.', '>'), + Key::Character('/', '?'), + ] + ); + let functions = rows_for(Page::Functions, false, KeyboardLayout::SimpleQwerty); + for key in rows(Page::Letters, false).into_iter().flatten() { + assert!( + letters + .iter() + .chain(&numbers) + .chain(&functions) + .flatten() + .any(|available| *available == key), + "missing {key:?}" + ); + } + assert_eq!( + rows_for(Page::Numbers, false, KeyboardLayout::Qwerty), + rows_for(Page::Numbers, false, KeyboardLayout::CommonLetters) + ); + } + #[test] + fn simple_qwerty_keeps_predictions_above_the_three_letter_rows() { + use crate::point_scan::KeyboardLayout; + let mut keyboard = Keyboard::new(false).with_layout(KeyboardLayout::SimpleQwerty); + keyboard.enable_predictions(true); + keyboard.predictions( + Some(crate::prediction::worker::Batch { + token: 9, + words: vec!["hello".into()], + }), + false, + ); + assert_eq!(keyboard.rows[0][0], Key::Prediction(0)); + assert_eq!(keyboard.rows[1][0], Key::Character('q', 'Q')); + assert_eq!(keyboard.rows.len(), 5); + } + #[test] + fn simple_qwerty_prose_punctuation_on_numbers_keeps_decimal_literal() { + use crate::point_scan::KeyboardLayout; + let mut keyboard = Keyboard::new(false).with_layout(KeyboardLayout::SimpleQwerty); + keyboard.choose(Key::Page(Page::Numbers)); + assert!(matches!( + keyboard.choose_with_context(Key::Character('.', '.'), context(1)), + Some(Output::Stroke(_)) + )); + keyboard.succeeded_with_context(context(1)); + for (key, mark) in [ + (Key::Character(',', '<'), ','), + (Key::Character(';', ':'), ';'), + (Key::Character('.', '>'), '.'), + ] { + assert!( + matches!(keyboard.choose_with_context(key, context(1)), Some(Output::Punctuation(p)) if p.mark == mark) + ); + keyboard.succeeded_with_context(context(1)); + } + keyboard.reset_context(); + keyboard.choose_with_context(Key::Modifier(0), context(1)); + assert!( + matches!(keyboard.choose_with_context(Key::Character('/', '?'), context(1)), Some(Output::Punctuation(p)) if p.mark == '?') + ); + keyboard.succeeded_with_context(context(1)); + keyboard.choose_with_context(Key::Page(Page::Letters), context(1)); + assert_eq!(keyboard.label(Key::Character('a', 'A')), "A"); + } + #[test] fn layout_key_requests_change_and_saved_layout_rebuilds_without_losing_shift() { use crate::point_scan::KeyboardLayout; let mut keyboard = Keyboard::new(false); keyboard.choose(Key::Modifier(0)); assert_eq!( keyboard.choose(Key::Layout), - Some(Output::Layout(KeyboardLayout::CommonLetters)) + Some(Output::Layout(KeyboardLayout::SimpleQwerty)) ); assert_eq!(keyboard.layout, KeyboardLayout::Qwerty); + keyboard.set_layout(KeyboardLayout::SimpleQwerty); + assert_eq!(keyboard.modifiers[0], Modifier::Once); + assert_eq!(keyboard.rows[0][0], Key::Character('q', 'Q')); + assert_eq!( + keyboard.choose(Key::Layout), + Some(Output::Layout(KeyboardLayout::CommonLetters)) + ); keyboard.set_layout(KeyboardLayout::CommonLetters); assert_eq!(keyboard.modifiers[0], Modifier::Once); assert_eq!(keyboard.rows[0][0], Key::Character(' ', ' ')); @@ -1899,7 +2060,7 @@ mod tests { ); assert_eq!( keyboard.handle(Action::Select), - Some(Output::Layout(KeyboardLayout::CommonLetters)) + Some(Output::Layout(KeyboardLayout::SimpleQwerty)) ); keyboard.layout_change_failed(); assert!(!keyboard.suspended()); diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index 699e9532..2be75500 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -283,7 +283,7 @@ it("customises individual settings, restores defaults and returns focus without fireEvent.click(screen.getByRole("checkbox", { name: "Word prediction" })); fireEvent.click(screen.getByRole("checkbox", { name: "Word prediction" })); mocks.invoke.mockClear(); - expect(screen.getByText(/The scanning keyboard spaces punctuation for you/)).toBeInTheDocument(); + expect(screen.getByText(/The scanning keyboard spaces prose punctuation for you/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Reverse" })); await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { config: expect.objectContaining({ scanPreferences: expect.objectContaining({ keyboard: { direction: "reverse" }, menu: {} }) }) @@ -311,7 +311,12 @@ it("offers accessible saved keyboard layouts and keeps the prior choice when sav fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" })); expect(screen.getByRole("group", { name: "Keyboard layout" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "QWERTY" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Simple QWERTY" })).toBeInTheDocument(); expect(screen.queryByText(/Static English key order/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Simple QWERTY" })); + await waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith("set_keyboard_layout", { layout: "simpleQwerty" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Simple QWERTY" })).toHaveAttribute("aria-pressed", "true")); + expect(screen.getByText(/Three familiar letter rows/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Common letters first" })); await waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith("set_keyboard_layout", { layout: "commonLetters" })); await waitFor(() => expect(screen.getByRole("button", { name: "Common letters first" })).toHaveAttribute("aria-pressed", "true")); diff --git a/src/scanning/useScanning.ts b/src/scanning/useScanning.ts index 2d9a14fc..c40eb725 100644 --- a/src/scanning/useScanning.ts +++ b/src/scanning/useScanning.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; export type ScannerColor = "red" | "green" | "blue" | "yellow" | "white"; -export type KeyboardLayout = "qwerty" | "commonLetters"; +export type KeyboardLayout = "qwerty" | "simpleQwerty" | "commonLetters"; export type PointScanConfig = { controlMode: "point" | "mouse"; scanPreferences?: import("./preferences").ScanPreferences; diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index 4135163d..3c26cdd4 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -136,7 +136,7 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl } {area === 'keyboard' && <> - + legend="Keyboard layout" disabled={disabled || !!pending} @@ -144,15 +144,18 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl onChange={chooseKeyboardLayout} options={[ { value: 'qwerty', label: 'QWERTY' }, + { value: 'simpleQwerty', label: 'Simple QWERTY' }, { value: 'commonLetters', label: 'Common letters first' }, ]} note={{ summary: config.keyboardLayout === 'commonLetters' ? 'Static English key order: Space E T A O I Backspace; N S H R D L U Enter; C M F W Y P V B; G K J Q X Z Tab Caps lock. Punctuation and modifiers follow.' - : 'Familiar QWERTY key rows: Q W E R T Y U I O P; A S D F G H J K L; Z X C V B N M. The keyboard footer can switch layouts at any time.' }} + : config.keyboardLayout === 'simpleQwerty' + ? 'Three familiar letter rows: Q W E R T Y U I O P Backspace; A S D F G H J K L Enter; Shift Z X C V B N M Space. Punctuation is on Numbers.' + : 'Familiar QWERTY key rows: Q W E R T Y U I O P; A S D F G H J K L; Z X C V B N M. The keyboard footer can switch layouts at any time.' }} /> -

Common letters first puts frequent English letters and Space early in row scanning. You can switch layouts from the keyboard footer; the choice is saved.

+

Simple QWERTY keeps the letter order with fewer keys on its Letters page. Common letters first puts frequent English letters and Space early in row scanning. The keyboard footer cycles through all three layouts; the choice is saved.

-

The scanning keyboard spaces punctuation for you. Period, question mark, and exclamation mark capitalize the next letter. The Numbers page period stays a decimal point.

+

The scanning keyboard spaces prose punctuation for you. In Simple QWERTY, those marks are on Numbers; the separate numeric period stays a decimal point. Period, question mark, and exclamation mark capitalize the next letter.

} {(area === 'keyboard' || area === 'mouse') && update('panelAvoidsPointer', value)} /> diff --git a/src/settings/ScanningSection.tsx b/src/settings/ScanningSection.tsx index 8facdc57..9000266b 100644 --- a/src/settings/ScanningSection.tsx +++ b/src/settings/ScanningSection.tsx @@ -41,7 +41,7 @@ export function ScanningSection({ controller }: { controller: ScanningController

Select starts the last mode used. Choose Mouse from the Point action menu or assign Open mouse to a switch. Mouse has an Actions tile for editing, windows, browser, media and other commands, so you can stay in Mouse. Modified clicks use the current pointer position. Choose Switch to Point in the Mouse panel or assign Open point to a switch. Stop scanning or Escape ends the session without changing the mode Select will start next time.

In Mouse, select a direction to move with the saved pointer speed and Remote repeat settings. Press and release a switch to stop repeating and scan the panel again. If repeat is off, each selection moves one step.

Assign switch actions in the Switches page. Actions run on release; holding a switch pauses the scan highlight. During repeating Mouse movement or scrolling, the stop switch keeps the action going until release and ignores its assigned actions. Manual scanning needs Select, Next and Previous. After clicking or reaching the pass limit, use Select to start again.

-

The scanning keyboard offers QWERTY and Common letters first. Choose a saved layout in Keyboard settings or use the keyboard footer to switch while typing. Common letters first puts frequent English letters and Space earlier in the scan.

+

The scanning keyboard offers QWERTY, Simple QWERTY, and Common letters first. Simple QWERTY keeps three letter rows with punctuation on Numbers. Choose a saved layout in Keyboard settings or cycle layouts from the keyboard footer while typing.

Assigned keys stay reserved while Switchify runs. Escape resets the scan. Mobile connections pause local scanning until they end.

On Windows, assigned keys remain switches with Shift, Ctrl, Alt or Windows held, even in the background. Other keys and Switchify-generated shortcuts still work normally.