From 687faead63fc511432483c98d9631a570ec8b2e3 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 12:40:06 +0100 Subject: [PATCH 1/4] Stop the keyboard and mouse panel depending on other windows The panels open and keep working with no window in front. A change of window no longer ends a panel or resets the keyboard's modifiers, Caps or highlight, and a change to the work area moves the keyboard to fit. What belongs to the text of one window is still let go. Closes #941 Co-Authored-By: Claude Fable 5.1 --- docs/point-scan.md | 2 +- docs/qwerty-keyboard.md | 4 +- docs/word-prediction.md | 2 +- src-tauri/src/point_scan_runtime.rs | 129 ++++++++++++++++++++-------- src-tauri/src/point_workflow.rs | 46 ++++++++-- src-tauri/src/scan_keyboard.rs | 21 +++-- 6 files changed, 149 insertions(+), 55 deletions(-) diff --git a/docs/point-scan.md b/docs/point-scan.md index 080ec6d3..ce8fc576 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -48,7 +48,7 @@ Drag retains the source and scans a destination on the same display without hold `point_workflow` separates point selection, menu navigation and typed action requests. Its current selection policy always opens the menu; a future auto-select policy can reuse `default_click` without changing the executor. No auto-select timer or setting exists. The reusable `scan_menu` defines stable action IDs and rows. Shared frames carry menu tiles, labels and highlight strips; all native windows remain click-through and nonactivating. -The desktop view adds menu, menuSuspended, dragDestination, dragConfirmation and executing phases. Existing point phases, saved settings and Bluetooth commands retain their shapes. Foreground changes, display changes, mobile connections, switch editing/learning and shutdown cancel the workflow. Targets and foreground identity remain in memory and are never logged or emitted. Automated action tests use fake input only; physical Windows/macOS focus, scaling, target, scrolling and drag checks remain required for hardware qualification. +The desktop view adds menu, menuSuspended, dragDestination, dragConfirmation and executing phases. Existing point phases, saved settings and Bluetooth commands retain their shapes. Foreground changes, display changes, mobile connections, switch editing/learning and shutdown cancel the workflow. Foreground and display changes cancel only point scanning and its menus; the keyboard and the mouse panel stay open, see [the keyboard](qwerty-keyboard.md). Point scanning can start with no window in front, and is then cancelled if one appears. Targets and foreground identity remain in memory and are never logged or emitted. Automated action tests use fake input only; physical Windows/macOS focus, scaling, target, scrolling and drag checks remain required for hardware qualification. The action menu uses a fixed grid of square icon tiles with labels beneath the artwork, on the same dark rounded panel chrome as the scanning keyboard (`TileRole::Panel`). A yellow border and amber background identify the current row or item. The same artwork is drawn on Windows and macOS. Menu titles stay above the shared panel when a scan opens a menu or changes submenus, without taking keyboard focus. diff --git a/docs/qwerty-keyboard.md b/docs/qwerty-keyboard.md index 94272256..a975aab2 100644 --- a/docs/qwerty-keyboard.md +++ b/docs/qwerty-keyboard.md @@ -16,7 +16,7 @@ The bottom control row starts with Close keyboard, followed by Letters, Navigati Modifier keys are pressed only around each emitted shortcut and immediately released. Selecting a locked modifier does not hold that operating-system key while the scanner runs. Ordinary characters use text injection; command combinations and navigation use native key events. Native shortcuts retain the operating system's layout semantics. -The keyboard remains open when the foreground application changes, clears its modifiers and prediction buffer, and sends subsequent keys to the new foreground application. It closes when the display environment changes or its scan session ends. Failed input clears keyboard modifiers and requires Select to resume. Stop, disconnect and application exit use the shared deterministic input cleanup. Word prediction can be enabled in Scanning settings. Five suggestions appear on the Letters page. Selecting a suggestion inserts its missing suffix and a space. Prediction uses local, read-only data; no personal vocabulary is saved. See [word prediction](word-prediction.md) for context availability and validation. +The keyboard and the mouse panel do not depend on other windows. They open and keep working when no window is in front. When the foreground application changes the keyboard stays open and keeps its modifiers, Caps and highlight, and sends subsequent keys to the new foreground application. Only what belongs to the text of one window is let go: the suggestions and prediction context, a space or capital the keyboard owns, and any key still held down. When its display or work area changes, for example when a taskbar hides, the keyboard moves to fit; if its display is removed it moves to the display under the pointer. It closes when its scan session ends. Failed input clears keyboard modifiers and requires Select to resume. Stop, disconnect and application exit use the shared deterministic input cleanup. Word prediction can be enabled in Scanning settings. Five suggestions appear on the Letters page. Selecting a suggestion inserts its missing suffix and a space. Prediction uses local, read-only data; no personal vocabulary is saved. See [word prediction](word-prediction.md) for context availability and validation. ## Manual validation @@ -27,6 +27,6 @@ Use synthetic text in Notepad and a browser on Windows, and TextEdit and a brows 3. Test Ctrl+A/C/V on Windows and Command+A/C/V on macOS. Cycle once/locked/off, including Shift with another modifier, and confirm no modifier remains physically held between selections. 4. Visit every page. Verify row/key highlighting, reverse movement, row escape, suspension/resume, returning to the beginning after a key and Close. Set Start again from to Where I selected and After a selection to Wait for Select for the keyboard, with automatic scanning; verify the highlight stays on the typed key, waits for Select, and returns to the beginning after a suggestion. 5. Move the keyboard between the top and bottom. Check a scaled display and a secondary display, including negative coordinates. Verify the keyboard fits the work area and never activates its native windows. -6. Switch foreground apps and verify the keyboard remains open with cleared modifiers and predictions. Disconnect remote scanning, change display configuration and close Switchify; verify overlays disappear and owned input is released. +6. Switch foreground apps with a locked modifier and verify the keyboard remains open with the modifier, Caps and highlight unchanged and the suggestions cleared. Show the desktop so that no window is in front and verify the keyboard and mouse panel open and scan. Hide or move the taskbar or Dock and verify the keyboard moves to fit without closing. Disconnect remote scanning and close Switchify; verify overlays disappear and owned input is released. Automated tests use fake input adapters only. Native manual results must be recorded separately; compilation and unit tests do not establish live application compatibility. diff --git a/docs/word-prediction.md b/docs/word-prediction.md index 672c0522..bd340f0c 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -99,7 +99,7 @@ For native validation, use disposable synthetic text in Notepad and a browser on 2. Type `w`, then `a`, accept `water`, and continue typing. Existing or pasted text must never become prediction context. 3. Change pages and docking, type punctuation and numbers, and return to Letters. Verify context survives these layout changes and Backspace edits the tracked buffer. 4. Use external keyboard/mouse activity. Verify suggestions clear and a new first letter starts fresh context. Then type `hel`, press an arrow key, and type `l`: verify no suggestions appear and the badge reads “Suggestions resume next word” until a space is typed. Repeat with Retry predictions and with a shortcut in place of the arrow key. -5. Change foreground apps with locked modifiers selected. Verify the keyboard stays open, modifiers and suggestions reset, and later keys go to the new app. +5. Change foreground apps with locked modifiers selected. Verify the keyboard stays open, the modifiers stay locked, suggestions clear, and later keys go to the new app. 6. Close the keyboard, stop scanning, disconnect and exit. Verify input releases and the prediction worker exits. After closing the keyboard one spare worker remains; reopen after a few seconds and within two minutes, and verify the loading badge clears almost immediately, then verify the spare exits after two minutes idle and when scanning stops. Test observer failure separately; typing should remain usable without predictions. Compilation and fake-adapter tests do not establish native application compatibility. Record live results separately. diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index d72020eb..dbbf4377 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -10,9 +10,24 @@ use tauri::{AppHandle, Manager}; #[derive(Clone)] pub struct Environment { display: Display, - foreground: usize, + /// The window in front, if there is one. + foreground: Option, + /// The keyboard or mouse panel is open. No window matters to a panel. + panel: bool, keyboard_area: Rect, } +/// A point is chosen over one window and must not be used on another. +fn window_changed(environment: &Environment, foreground: Option) -> bool { + !environment.panel && environment.foreground != foreground +} +fn bounds(display: &Display) -> Rect { + Rect { + x: display.x.into(), + y: display.y.into(), + width: display.width.into(), + height: display.height.into(), + } +} pub struct PointScan; pub type Controller = scanning_runtime::Controller; pub type View = scanning_runtime::View; @@ -217,16 +232,12 @@ impl Adapter for PointScan { let current = display_navigation::current_display(cursor, &displays) .ok_or("No scanning display is available.")? .clone(); - let rect = Rect { - x: current.x.into(), - y: current.y.into(), - width: current.width.into(), - height: current.height.into(), - }; + let rect = bounds(¤t); let area = crate::scan_host::work_area(rect)?; environment.display = current; environment.keyboard_area = area; - environment.foreground = crate::scan_host::foreground()?; + environment.foreground = crate::scan_host::foreground().ok(); + environment.panel = true; technique.set_mouse_area( area, rect, @@ -249,19 +260,48 @@ impl Adapter for PointScan { } else if technique.follows_foreground() { // There may be no window in front while one is coming forward. match crate::scan_host::foreground() { - Ok(foreground) => environment.foreground = foreground, + Ok(foreground) => environment.foreground = Some(foreground), Err(_) if technique.awaits_foreground() => return Ok(false), Err(error) => return Err(error), } + environment.panel = false; } else if technique.keyboard_open() { crate::point_scan_ready(app)?; - let foreground = crate::scan_host::foreground()?; + // The keyboard stays as it is whatever is in front. Only what + // was typed, suggested or held down belongs to one window. + let foreground = crate::scan_host::foreground().ok(); if environment.foreground != foreground { crate::scan_executor::cleanup()?; crate::prediction::reset(); - technique.foreground_changed(); + technique.typing_moved(); environment.foreground = foreground; } + environment.panel = true; + // The keyboard moves to fit its display rather than closing. + let (cursor, displays) = + display_navigation::displays(app).map_err(|e| e.message)?; + let current = if displays.contains(&environment.display) { + environment.display.clone() + } else { + display_navigation::current_display(cursor, &displays) + .ok_or("No scanning display is available.")? + .clone() + }; + let rect = bounds(¤t); + let area = crate::scan_host::work_area(rect)?; + if environment.display != current || environment.keyboard_area != area { + technique.set_keyboard_display( + area, + rect, + if cfg!(target_os = "windows") { + current.scale_factor + } else { + 1.0 + }, + ); + environment.display = current; + environment.keyboard_area = area; + } // An unreadable pointer only stops the panel moving; it never stops scanning. technique.set_pointer(if technique.panel_avoids_pointer() { display_navigation::pointer(app).ok() @@ -269,6 +309,11 @@ impl Adapter for PointScan { None }); } else { + if environment.panel { + // Point scanning begins again over whatever is in front now. + environment.panel = false; + environment.foreground = crate::scan_host::foreground().ok(); + } technique.set_pointer(None); } } @@ -311,28 +356,15 @@ fn new_engine(app: &AppHandle, config: Config) -> Result<(Workflow, Environment) } else { 1.0 }; - let mut e = Workflow::new( - config.point(), - Rect { - x: display.x.into(), - y: display.y.into(), - width: display.width.into(), - height: display.height.into(), - }, - units, - )?; - let keyboard_area = crate::scan_host::work_area(Rect { - x: display.x.into(), - y: display.y.into(), - width: display.width.into(), - height: display.height.into(), - })?; + let mut e = Workflow::new(config.point(), bounds(&display), units)?; + let keyboard_area = crate::scan_host::work_area(bounds(&display))?; e.set_keyboard_area(keyboard_area); Ok(( e, Environment { display, - foreground: crate::scan_host::foreground()?, + foreground: crate::scan_host::foreground().ok(), + panel: false, keyboard_area, }, )) @@ -340,21 +372,14 @@ fn new_engine(app: &AppHandle, config: Config) -> Result<(Workflow, Environment) fn validate_display(app: &AppHandle, display: Option<&Environment>) -> Result<(), String> { crate::point_scan_ready(app)?; if let Some(expected) = display { - if crate::scan_host::foreground()? != expected.foreground { + if window_changed(expected, crate::scan_host::foreground().ok()) { return Err("Foreground application changed. Select a new point.".into()); } let (_, displays) = display_navigation::displays(app).map_err(|e| e.message)?; if !displays.contains(&expected.display) { return Err("Display geometry changed. Scanning restarts.".into()); } - let d = &expected.display; - if crate::scan_host::work_area(Rect { - x: d.x.into(), - y: d.y.into(), - width: d.width.into(), - height: d.height.into(), - })? != expected.keyboard_area - { + if crate::scan_host::work_area(bounds(&expected.display))? != expected.keyboard_area { return Err("Display work area changed. Scanning restarts.".into()); } } @@ -363,6 +388,36 @@ fn validate_display(app: &AppHandle, display: Option<&Environment>) -> Result<() #[cfg(test)] mod tests { + #[test] + fn only_a_chosen_point_is_held_to_its_window() { + let environment = |panel, foreground| super::Environment { + display: super::Display { + name: String::new(), + scale_factor: 1.0, + x: 0, + y: 0, + width: 1920, + height: 1080, + }, + foreground, + panel, + keyboard_area: super::Rect { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1040.0, + }, + }; + for now in [None, Some(1), Some(2)] { + for held in [None, Some(1)] { + assert!(!super::window_changed(&environment(true, held), now)); + assert_eq!( + super::window_changed(&environment(false, held), now), + held != now + ); + } + } + } use super::*; use crate::scanning::Technique; diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 66b0eaab..b576060e 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -313,8 +313,8 @@ impl Workflow { _ => None, } } - pub fn foreground_changed(&mut self) { - self.keyboard.reset_context(); + pub fn typing_moved(&mut self) { + self.keyboard.typing_moved(); } pub fn prediction_enabled(&self) -> bool { self.point.config.word_prediction @@ -323,6 +323,12 @@ impl Workflow { pub fn set_keyboard_area(&mut self, area: Rect) { self.keyboard_area = area; } + /// The display the open keyboard is on changed, or was replaced. + pub fn set_keyboard_display(&mut self, area: Rect, screen: Rect, scale: f64) { + self.keyboard_area = area; + self.point.screen = screen; + self.point.units_per_logical_pixel = scale; + } pub fn panel_avoids_pointer(&self) -> bool { self.point.config.panel_avoids_pointer } @@ -1663,7 +1669,7 @@ mod tests { assert!(idle.active()); } #[test] - fn foreground_change_keeps_keyboard_layout_but_clears_context_and_modifiers() { + fn another_window_in_front_takes_the_suggestions_and_nothing_else() { let mut s = session(false); s.action(Action::OpenKeyboard); s.technique.execution_succeeded(); @@ -1677,14 +1683,40 @@ mod tests { }), false, ); - s.technique.foreground_changed(); + s.action(Action::Next); + s.action(Action::Next); + s.action(Action::Select); + s.action(Action::Next); + let selected = |s: &Session| { + let tiles = s.frame().tiles; + let selected: Vec<_> = tiles.into_iter().filter(|t| t.selected).collect(); + assert_eq!(selected.len(), 1); + selected[0].text.clone() + }; + let key = selected(&s); + s.technique.typing_moved(); assert!(s.technique.keyboard_open()); assert_eq!( - s.technique.keyboard.modifiers, - [crate::scan_keyboard::Modifier::Off; 4] + s.technique.keyboard.modifiers[1], + crate::scan_keyboard::Modifier::Locked ); - assert!(!s.technique.keyboard.caps); + assert!(s.technique.keyboard.caps); + assert_eq!(selected(&s), key); assert!(!s.frame().tiles.iter().any(|t| t.text == "water")); + // The keyboard follows its display without closing. + let area = Rect { + x: 1920.0, + y: 0.0, + width: 1280.0, + height: 680.0, + }; + let before = s.frame().tiles[0].rect; + s.technique.set_keyboard_display(area, area, 1.0); + assert!(s.technique.keyboard_open()); + assert_eq!(selected(&s), key); + let after = s.frame().tiles[0].rect; + assert_ne!(after, before); + assert!(after.x >= area.x && after.y + after.height <= area.y + area.height); } #[test] fn keyboard_opening_waits_for_cleanup_and_failure_stays_in_menu() { diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 3baf9093..2724fad5 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -685,16 +685,23 @@ impl Keyboard { self.clear_auto_capital(); } } - pub fn reset_context(&mut self) { - self.modifiers = [Modifier::Off; 4]; - self.caps = false; - self.activation = None; + /// Typing continues in another window. What was typed and suggested + /// stays behind; the keys latched and the highlight do not change. + pub fn typing_moved(&mut self) { self.owned_space = None; - self.capitalize_next = false; - self.auto_shift = false; - self.capital_context = None; + self.clear_auto_capital(); self.pending_typed = None; self.predictions(None, self.prediction_failed); + // A suggestion being typed was abandoned with its window. + if self.activation.take().is_some() { + self.restart(); + } + } + #[cfg(test)] + fn reset_context(&mut self) { + self.modifiers = [Modifier::Off; 4]; + self.caps = false; + self.typing_moved(); self.restart(); } pub fn failed(&mut self) { From d18504be5561e69b5576f2a357d5fc2516660f94 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 12:47:42 +0100 Subject: [PATCH 2/4] Let a point scan follow the window in front until a switch is used Review follow-up: every point scan now follows whichever window is in front, or none, until a switch is used in it, so a window coming forward as a panel closes no longer ends it. The wait for a window is gone with the need for it. The keyboard keeps its display by name, stays put while displays cannot be read, and a keyboard that fails to open from the mouse panel returns to the panel. Co-Authored-By: Claude Fable 5.1 --- docs/point-scan.md | 2 +- docs/qwerty-keyboard.md | 2 +- src-tauri/src/point_scan_runtime.rs | 122 +++++++++++++++++----------- src-tauri/src/point_workflow.rs | 80 +++++++++++------- src-tauri/src/scan_keyboard.rs | 52 +++++++++++- 5 files changed, 175 insertions(+), 83 deletions(-) diff --git a/docs/point-scan.md b/docs/point-scan.md index ce8fc576..164c7fb4 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -48,7 +48,7 @@ Drag retains the source and scans a destination on the same display without hold `point_workflow` separates point selection, menu navigation and typed action requests. Its current selection policy always opens the menu; a future auto-select policy can reuse `default_click` without changing the executor. No auto-select timer or setting exists. The reusable `scan_menu` defines stable action IDs and rows. Shared frames carry menu tiles, labels and highlight strips; all native windows remain click-through and nonactivating. -The desktop view adds menu, menuSuspended, dragDestination, dragConfirmation and executing phases. Existing point phases, saved settings and Bluetooth commands retain their shapes. Foreground changes, display changes, mobile connections, switch editing/learning and shutdown cancel the workflow. Foreground and display changes cancel only point scanning and its menus; the keyboard and the mouse panel stay open, see [the keyboard](qwerty-keyboard.md). Point scanning can start with no window in front, and is then cancelled if one appears. Targets and foreground identity remain in memory and are never logged or emitted. Automated action tests use fake input only; physical Windows/macOS focus, scaling, target, scrolling and drag checks remain required for hardware qualification. +The desktop view adds menu, menuSuspended, dragDestination, dragConfirmation and executing phases. Existing point phases, saved settings and Bluetooth commands retain their shapes. Mobile connections, switch editing/learning and shutdown cancel the workflow. Foreground and display changes cancel point scanning and its menus once a switch has been used in the scan, so that a point is never used on a window it was not chosen over. Until then a point scan follows whichever window is in front, and can run with none in front. The keyboard and the mouse panel stay open through both kinds of change; see [the keyboard](qwerty-keyboard.md). Targets and foreground identity remain in memory and are never logged or emitted. Automated action tests use fake input only; physical Windows/macOS focus, scaling, target, scrolling and drag checks remain required for hardware qualification. The action menu uses a fixed grid of square icon tiles with labels beneath the artwork, on the same dark rounded panel chrome as the scanning keyboard (`TileRole::Panel`). A yellow border and amber background identify the current row or item. The same artwork is drawn on Windows and macOS. Menu titles stay above the shared panel when a scan opens a menu or changes submenus, without taking keyboard focus. diff --git a/docs/qwerty-keyboard.md b/docs/qwerty-keyboard.md index a975aab2..6362cbb7 100644 --- a/docs/qwerty-keyboard.md +++ b/docs/qwerty-keyboard.md @@ -16,7 +16,7 @@ The bottom control row starts with Close keyboard, followed by Letters, Navigati Modifier keys are pressed only around each emitted shortcut and immediately released. Selecting a locked modifier does not hold that operating-system key while the scanner runs. Ordinary characters use text injection; command combinations and navigation use native key events. Native shortcuts retain the operating system's layout semantics. -The keyboard and the mouse panel do not depend on other windows. They open and keep working when no window is in front. When the foreground application changes the keyboard stays open and keeps its modifiers, Caps and highlight, and sends subsequent keys to the new foreground application. Only what belongs to the text of one window is let go: the suggestions and prediction context, a space or capital the keyboard owns, and any key still held down. When its display or work area changes, for example when a taskbar hides, the keyboard moves to fit; if its display is removed it moves to the display under the pointer. It closes when its scan session ends. Failed input clears keyboard modifiers and requires Select to resume. Stop, disconnect and application exit use the shared deterministic input cleanup. Word prediction can be enabled in Scanning settings. Five suggestions appear on the Letters page. Selecting a suggestion inserts its missing suffix and a space. Prediction uses local, read-only data; no personal vocabulary is saved. See [word prediction](word-prediction.md) for context availability and validation. +The keyboard and the mouse panel do not depend on other windows. They open and keep working when no window is in front. When the foreground application changes the keyboard stays open and keeps its modifiers, Caps and highlight, and sends subsequent keys to the new foreground application. Only what belongs to the text of one window is let go: the suggestions and prediction context, a space or capital the keyboard owns, and any input still held. Suggestions are unavailable while no window is in front. When its display or work area changes, for example when a taskbar hides, the keyboard moves to fit; if its display is removed it moves to the display under the pointer, and while the displays cannot be read it stays where it is. It closes when its scan session ends. Failed input clears keyboard modifiers and requires Select to resume. Stop, disconnect and application exit use the shared deterministic input cleanup. Word prediction can be enabled in Scanning settings. Five suggestions appear on the Letters page. Selecting a suggestion inserts its missing suffix and a space. Prediction uses local, read-only data; no personal vocabulary is saved. See [word prediction](word-prediction.md) for context availability and validation. ## Manual validation diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index dbbf4377..94a9fd1d 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -20,6 +20,19 @@ pub struct Environment { fn window_changed(environment: &Environment, foreground: Option) -> bool { !environment.panel && environment.foreground != foreground } +/// Where the open keyboard belongs: on its own display while that exists, +/// however it has changed, and otherwise on the display under the pointer. +fn keyboard_display<'a>( + current: &Display, + displays: &'a [Display], + cursor: (f64, f64), +) -> Option<&'a Display> { + displays + .iter() + .find(|display| *display == current) + .or_else(|| displays.iter().find(|display| display.name == current.name)) + .or_else(|| display_navigation::current_display(cursor, displays)) +} fn bounds(display: &Display) -> Rect { Rect { x: display.x.into(), @@ -258,12 +271,7 @@ impl Adapter for PointScan { settings.mouse_repeat_enabled, ); } else if technique.follows_foreground() { - // There may be no window in front while one is coming forward. - match crate::scan_host::foreground() { - Ok(foreground) => environment.foreground = Some(foreground), - Err(_) if technique.awaits_foreground() => return Ok(false), - Err(error) => return Err(error), - } + environment.foreground = crate::scan_host::foreground().ok(); environment.panel = false; } else if technique.keyboard_open() { crate::point_scan_ready(app)?; @@ -278,18 +286,20 @@ impl Adapter for PointScan { } environment.panel = true; // The keyboard moves to fit its display rather than closing. - let (cursor, displays) = - display_navigation::displays(app).map_err(|e| e.message)?; - let current = if displays.contains(&environment.display) { - environment.display.clone() - } else { - display_navigation::current_display(cursor, &displays) - .ok_or("No scanning display is available.")? - .clone() - }; - let rect = bounds(¤t); - let area = crate::scan_host::work_area(rect)?; - if environment.display != current || environment.keyboard_area != area { + // While the displays cannot be read it stays where it is. + let fitted = display_navigation::displays(app) + .ok() + .and_then(|(cursor, displays)| { + keyboard_display(&environment.display, &displays, cursor).cloned() + }) + .and_then(|display| { + let area = crate::scan_host::work_area(bounds(&display)).ok()?; + Some((display, area)) + }); + if let Some((current, area)) = fitted.filter(|(display, area)| { + environment.display != *display || environment.keyboard_area != *area + }) { + let rect = bounds(¤t); technique.set_keyboard_display( area, rect, @@ -371,7 +381,8 @@ fn new_engine(app: &AppHandle, config: Config) -> Result<(Workflow, Environment) } fn validate_display(app: &AppHandle, display: Option<&Environment>) -> Result<(), String> { crate::point_scan_ready(app)?; - if let Some(expected) = display { + // An open panel fits itself to its display and needs no window. + if let Some(expected) = display.filter(|environment| !environment.panel) { if window_changed(expected, crate::scan_host::foreground().ok()) { return Err("Foreground application changed. Select a new point.".into()); } @@ -388,36 +399,6 @@ fn validate_display(app: &AppHandle, display: Option<&Environment>) -> Result<() #[cfg(test)] mod tests { - #[test] - fn only_a_chosen_point_is_held_to_its_window() { - let environment = |panel, foreground| super::Environment { - display: super::Display { - name: String::new(), - scale_factor: 1.0, - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - foreground, - panel, - keyboard_area: super::Rect { - x: 0.0, - y: 0.0, - width: 1920.0, - height: 1040.0, - }, - }; - for now in [None, Some(1), Some(2)] { - for held in [None, Some(1)] { - assert!(!super::window_changed(&environment(true, held), now)); - assert_eq!( - super::window_changed(&environment(false, held), now), - held != now - ); - } - } - } use super::*; use crate::scanning::Technique; @@ -493,4 +474,47 @@ mod tests { assert!(!PointScan::preserve_visuals(&Request::OpenKeyboard)); assert!(!PointScan::preserve_visuals(&Request::DragStart((10, 20)))); } + fn display(name: &str, x: i32, width: u32) -> Display { + Display { + name: name.into(), + scale_factor: 1.0, + x, + y: 0, + width, + height: 1080, + } + } + #[test] + fn only_a_chosen_point_is_held_to_its_window() { + let environment = |panel, foreground| Environment { + display: display("one", 0, 1920), + foreground, + panel, + keyboard_area: bounds(&display("one", 0, 1920)), + }; + for now in [None, Some(1), Some(2)] { + for held in [None, Some(1)] { + assert!(!window_changed(&environment(true, held), now)); + assert_eq!(window_changed(&environment(false, held), now), held != now); + } + } + } + #[test] + fn the_keyboard_stays_on_its_display_while_that_exists() { + let one = display("one", 0, 1920); + let two = display("two", 1920, 1280); + let over_two = (2000.0, 10.0); + let both = [one.clone(), two.clone()]; + assert_eq!(keyboard_display(&one, &both, over_two), Some(&one)); + // A display that changed size is still the keyboard's. + let resized = [display("one", 0, 1280), two.clone()]; + assert_eq!( + keyboard_display(&one, &resized, over_two), + Some(&resized[0]) + ); + // One that was removed gives way to the display under the pointer. + let left = [two.clone()]; + assert_eq!(keyboard_display(&one, &left, (10.0, 10.0)), Some(&two)); + assert_eq!(keyboard_display(&one, &[], over_two), None); + } } diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index b576060e..793e88e8 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -135,10 +135,8 @@ pub struct Workflow { elapsed: u64, pending: Option, error: Option, - /// A scan that started by itself, in which nothing has been chosen yet. + /// A point scan in which no switch has been used yet. following: bool, - /// Times no window was in front of such a scan. - unseen: u64, } impl Workflow { pub fn new(config: PointSettings, screen: Rect, scale: f64) -> Result { @@ -176,7 +174,6 @@ impl Workflow { pending: None, error: None, following: false, - unseen: 0, }) } fn new_keyboard(&self) -> crate::scan_keyboard::Keyboard { @@ -362,31 +359,23 @@ impl Workflow { fn used(&mut self) { if self.point.config.scan.next_scan == crate::scan_preferences::NextScan::Automatic { self.new_point(); - self.following = true; - self.unseen = 0; } else { self.stage = Stage::Idle; } } fn new_point(&mut self) { self.parent_menu.clear(); + self.begin_point(); + } + fn begin_point(&mut self) { self.stage = Stage::Point; self.point.start(); + self.following = true; } - /// What was just done may bring another window forward. Until something - /// is chosen in the scan that followed, no point depends on which. - pub fn follows_foreground(&mut self) -> bool { - let follows = self.following && self.stage == Stage::Point; - if !follows { - self.unseen = 0; - } - follows - } - /// No window is in front. Scanning holds still for up to a second in - /// all in case one is coming forward, and then ends as it always has. - pub fn awaits_foreground(&mut self) -> bool { - self.unseen += 1; - self.unseen * crate::scanning::TICK_MS <= 1000 + /// A window may come forward or go as a point scan begins. Until a + /// switch is used in the scan, no point depends on which is in front. + pub fn follows_foreground(&self) -> bool { + self.following && self.stage == Stage::Point } fn restore_actions(&mut self) { if let Some(menu) = self.parent_menu.pop() { @@ -408,13 +397,15 @@ impl Workflow { self.elapsed = 0; self.error = None; self.return_to_mouse = false; + // A point chosen earlier is not kept for a window that may change. + self.source = (0, 0); + self.destination = (0, 0); Some(Request::OpenMouse) } fn open_point(&mut self) -> Option { self.point.config.control_mode = crate::point_scan::ControlMode::Point; self.reset(); - self.stage = Stage::Point; - self.point.start(); + self.begin_point(); Some(Request::OpenPoint) } fn mouse_key(&mut self, key: crate::scan_mouse::Key) -> Option { @@ -600,7 +591,10 @@ impl Technique for Workflow { self.keyboard.failed(); return; } - if self.mouse_open() { + // The keyboard did not open from the mouse panel, which is still there. + let returning = self.stage == Stage::KeyboardOpening && self.return_to_mouse; + self.return_to_mouse &= !returning; + if self.mouse_open() || returning { self.stage = Stage::Mouse; self.move_repeat = None; self.scroll_repeat = None; @@ -632,8 +626,7 @@ impl Technique for Workflow { if mode == crate::point_scan::ControlMode::Mouse { let _ = self.open_mouse(); } else { - self.stage = Stage::Point; - self.point.start(); + self.begin_point(); } } fn reset(&mut self) { @@ -1118,20 +1111,19 @@ mod tests { assert_eq!(s.frame() != Frame::default(), again, "{next_scan:?}"); assert_eq!(s.technique.follows_foreground(), again); if again { - // No window in front is waited out, but not for ever. - let waited = (0..100) - .take_while(|_| s.technique.awaits_foreground()) - .count() as u64; - assert_eq!(waited, 1000 / crate::scanning::TICK_MS); - assert!(s.technique.follows_foreground()); // Once something is chosen, the window is held to. s.tick(33, false); assert!(s.technique.follows_foreground()); s.action(Action::Select); assert!(!s.technique.follows_foreground()); s.action(Action::Cancel); + // A scan begun with Select follows until a switch is used in it. s.action(Action::Select); + assert!(s.technique.follows_foreground()); + s.action(Action::Next); assert!(!s.technique.follows_foreground()); + s.action(Action::Cancel); + s.action(Action::Select); assert_eq!( s.technique.phase(), Phase::Point(crate::point_scan::Phase::X) @@ -1669,6 +1661,32 @@ mod tests { assert!(idle.active()); } #[test] + fn a_keyboard_that_fails_to_open_from_the_mouse_panel_returns_to_it() { + let mut s = session(false); + open(&mut s); + assert_ne!(s.technique.source, (0, 0)); + assert_eq!( + s.technique.selected(Item::MousePanel), + Some(Request::OpenMouse) + ); + // The point chosen before is not kept. + assert_eq!(s.technique.source, (0, 0)); + assert_eq!( + s.technique.mouse_key(crate::scan_mouse::Key::Keyboard), + Some(Request::OpenKeyboard) + ); + s.technique.execution_failed("Cleanup failed".into()); + assert_eq!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::MouseSuspended) + ); + assert!(!s.technique.return_to_mouse); + assert_eq!( + s.frame().tiles.last().unwrap().text, + "Mouse action failed · Select to resume" + ); + } + #[test] fn another_window_in_front_takes_the_suggestions_and_nothing_else() { let mut s = session(false); s.action(Action::OpenKeyboard); diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 2724fad5..8adc658b 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -1046,9 +1046,59 @@ mod tests { keyboard.prediction_inserted(true, true, context(3)); assert!(!keyboard.capitalize_next); assert_eq!(keyboard.owned_space, context(3)); - keyboard.reset_context(); + keyboard.typing_moved(); assert_eq!(keyboard.owned_space, None); } + #[test] + fn typing_in_another_window_leaves_the_keyboard_as_it_is() { + let mut keyboard = Keyboard::new(false); + // A capital the keyboard chose goes; keys the user latched stay. + keyboard.choose_with_context(Key::Character('.', '>'), context(1)); + keyboard.succeeded_with_context(context(1)); + assert_eq!(keyboard.modifiers[0], Modifier::Once); + keyboard.modifiers[1] = Modifier::Once; + keyboard.modifiers[2] = Modifier::Locked; + keyboard.caps = true; + keyboard.typing_moved(); + assert_eq!( + keyboard.modifiers, + [ + Modifier::Off, + Modifier::Once, + Modifier::Locked, + Modifier::Off + ] + ); + assert!(keyboard.caps && !keyboard.capitalize_next); + // A Shift the user latched is theirs. + let mut keyboard = Keyboard::new(false); + keyboard.choose_with_context(Key::Modifier(0), context(1)); + keyboard.typing_moved(); + assert_eq!(keyboard.modifiers[0], Modifier::Once); + // A wait, a pause and a failure are kept. + let mut keyboard = Keyboard::new(false).with_wait_after_typing(true); + keyboard.handle(Action::Select); + assert!(keyboard.handle(Action::Select).is_some()); + keyboard.succeeded(); + keyboard.typing_moved(); + assert!(keyboard.waiting_after_typing && keyboard.suspended()); + let mut keyboard = Keyboard::new(false); + keyboard.failed(); + keyboard.typing_moved(); + assert!(keyboard.error && keyboard.suspended()); + // A key on its way when the window changed is given up, and its + // late arrival changes nothing. + let mut keyboard = Keyboard::new(false); + keyboard.handle(Action::Select); + keyboard.handle(Action::Next); + assert!(keyboard.handle(Action::Select).is_some()); + assert_eq!(keyboard.handle(Action::Select), None); + keyboard.typing_moved(); + assert!(!keyboard.succeeded()); + assert!(keyboard.scan.row_scan()); + assert_eq!(keyboard.handle(Action::Select), None); + assert!(keyboard.handle(Action::Select).is_some()); + } #[test] fn automatic_capital_does_not_toggle_manual_caps_or_change_shortcuts() { From 8ca86f3144fe9864ba332a8cef0096c9b43c63a8 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 12:52:12 +0100 Subject: [PATCH 3/4] Follow the window after a change of mode, and keep the mouse panel open Review follow-up: a scan restarted by a change of mode follows the window like any other. A scan that follows is not checked against the window it has just taken up. The mouse panel stays where it is while displays cannot be read, as the keyboard does. Co-Authored-By: Claude Fable 5.1 --- docs/point-scan.md | 2 +- docs/qwerty-keyboard.md | 2 +- src-tauri/src/point_scan_runtime.rs | 63 ++++++++++++++++++----------- src-tauri/src/point_workflow.rs | 28 +++++++++++-- 4 files changed, 66 insertions(+), 29 deletions(-) diff --git a/docs/point-scan.md b/docs/point-scan.md index 164c7fb4..2f36c0cb 100644 --- a/docs/point-scan.md +++ b/docs/point-scan.md @@ -48,7 +48,7 @@ Drag retains the source and scans a destination on the same display without hold `point_workflow` separates point selection, menu navigation and typed action requests. Its current selection policy always opens the menu; a future auto-select policy can reuse `default_click` without changing the executor. No auto-select timer or setting exists. The reusable `scan_menu` defines stable action IDs and rows. Shared frames carry menu tiles, labels and highlight strips; all native windows remain click-through and nonactivating. -The desktop view adds menu, menuSuspended, dragDestination, dragConfirmation and executing phases. Existing point phases, saved settings and Bluetooth commands retain their shapes. Mobile connections, switch editing/learning and shutdown cancel the workflow. Foreground and display changes cancel point scanning and its menus once a switch has been used in the scan, so that a point is never used on a window it was not chosen over. Until then a point scan follows whichever window is in front, and can run with none in front. The keyboard and the mouse panel stay open through both kinds of change; see [the keyboard](qwerty-keyboard.md). Targets and foreground identity remain in memory and are never logged or emitted. Automated action tests use fake input only; physical Windows/macOS focus, scaling, target, scrolling and drag checks remain required for hardware qualification. +The desktop view adds menu, menuSuspended, dragDestination, dragConfirmation and executing phases. Existing point phases, saved settings and Bluetooth commands retain their shapes. Mobile connections, switch editing/learning and shutdown cancel the workflow. A change of display or work area cancels point scanning and its menus. So does a change of the window in front, once a switch has moved or chosen anything in the scan, so that a point is never used on a window it was not chosen over. Until then a point scan follows whichever window is in front, and can run with none in front. If no window is in front at the moment of the first choice and one appears afterwards, the scan is cancelled. The keyboard and the mouse panel stay open through both kinds of change; see [the keyboard](qwerty-keyboard.md). Targets and foreground identity remain in memory and are never logged or emitted. Automated action tests use fake input only; physical Windows/macOS focus, scaling, target, scrolling and drag checks remain required for hardware qualification. The action menu uses a fixed grid of square icon tiles with labels beneath the artwork, on the same dark rounded panel chrome as the scanning keyboard (`TileRole::Panel`). A yellow border and amber background identify the current row or item. The same artwork is drawn on Windows and macOS. Menu titles stay above the shared panel when a scan opens a menu or changes submenus, without taking keyboard focus. diff --git a/docs/qwerty-keyboard.md b/docs/qwerty-keyboard.md index 6362cbb7..986dc0ce 100644 --- a/docs/qwerty-keyboard.md +++ b/docs/qwerty-keyboard.md @@ -16,7 +16,7 @@ The bottom control row starts with Close keyboard, followed by Letters, Navigati Modifier keys are pressed only around each emitted shortcut and immediately released. Selecting a locked modifier does not hold that operating-system key while the scanner runs. Ordinary characters use text injection; command combinations and navigation use native key events. Native shortcuts retain the operating system's layout semantics. -The keyboard and the mouse panel do not depend on other windows. They open and keep working when no window is in front. When the foreground application changes the keyboard stays open and keeps its modifiers, Caps and highlight, and sends subsequent keys to the new foreground application. Only what belongs to the text of one window is let go: the suggestions and prediction context, a space or capital the keyboard owns, and any input still held. Suggestions are unavailable while no window is in front. When its display or work area changes, for example when a taskbar hides, the keyboard moves to fit; if its display is removed it moves to the display under the pointer, and while the displays cannot be read it stays where it is. It closes when its scan session ends. Failed input clears keyboard modifiers and requires Select to resume. Stop, disconnect and application exit use the shared deterministic input cleanup. Word prediction can be enabled in Scanning settings. Five suggestions appear on the Letters page. Selecting a suggestion inserts its missing suffix and a space. Prediction uses local, read-only data; no personal vocabulary is saved. See [word prediction](word-prediction.md) for context availability and validation. +The keyboard and the mouse panel do not depend on other windows. They open and keep working when no window is in front. When the foreground application changes the keyboard stays open and keeps its modifiers, Caps and highlight, and sends subsequent keys to the new foreground application. Only what belongs to the text of one window is let go: the suggestions and prediction context, a space or capital the keyboard owns, and any input still held. Suggestions are unavailable while no window is in front. When its display or work area changes, for example when a taskbar hides, the keyboard moves to fit; if its display is removed it moves to the display under the pointer, and while the displays cannot be read it stays where it is. The mouse panel does the same on the display under the pointer. It closes when its scan session ends. Failed input clears keyboard modifiers and requires Select to resume. Stop, disconnect and application exit use the shared deterministic input cleanup. Word prediction can be enabled in Scanning settings. Five suggestions appear on the Letters page. Selecting a suggestion inserts its missing suffix and a space. Prediction uses local, read-only data; no personal vocabulary is saved. See [word prediction](word-prediction.md) for context availability and validation. ## Manual validation diff --git a/src-tauri/src/point_scan_runtime.rs b/src-tauri/src/point_scan_runtime.rs index 94a9fd1d..b542cdf9 100644 --- a/src-tauri/src/point_scan_runtime.rs +++ b/src-tauri/src/point_scan_runtime.rs @@ -14,11 +14,13 @@ pub struct Environment { foreground: Option, /// The keyboard or mouse panel is open. No window matters to a panel. panel: bool, + /// Nothing is chosen yet in the point scan, which takes up any window. + following: bool, keyboard_area: Rect, } /// A point is chosen over one window and must not be used on another. fn window_changed(environment: &Environment, foreground: Option) -> bool { - !environment.panel && environment.foreground != foreground + !environment.panel && !environment.following && environment.foreground != foreground } /// Where the open keyboard belongs: on its own display while that exists, /// however it has changed, and otherwise on the display under the pointer. @@ -239,29 +241,35 @@ impl Adapter for PointScan { technique: Option<&mut Workflow>, ) -> Result { if let (Some(environment), Some(technique)) = (environment, technique) { + environment.following = false; if technique.mouse_open() { - let (cursor, displays) = - display_navigation::displays(app).map_err(|e| e.message)?; - let current = display_navigation::current_display(cursor, &displays) - .ok_or("No scanning display is available.")? - .clone(); - let rect = bounds(¤t); - let area = crate::scan_host::work_area(rect)?; - environment.display = current; - environment.keyboard_area = area; environment.foreground = crate::scan_host::foreground().ok(); environment.panel = true; - technique.set_mouse_area( - area, - rect, - if cfg!(target_os = "windows") { - environment.display.scale_factor - } else { - 1.0 - }, - displays.len(), - ); - technique.set_pointer(Some(cursor)); + // While the displays cannot be read the panel stays where it is. + let placed = + display_navigation::displays(app) + .ok() + .and_then(|(cursor, displays)| { + let current = + display_navigation::current_display(cursor, &displays)?.clone(); + let area = crate::scan_host::work_area(bounds(¤t)).ok()?; + Some((cursor, displays.len(), current, area)) + }); + if let Some((cursor, displays, current, area)) = placed { + technique.set_mouse_area( + area, + bounds(¤t), + if cfg!(target_os = "windows") { + current.scale_factor + } else { + 1.0 + }, + displays, + ); + technique.set_pointer(Some(cursor)); + environment.display = current; + environment.keyboard_area = area; + } let settings = app.state::().snapshot().settings; technique.set_mouse_settings( settings.pointer_scale_percent, @@ -273,6 +281,7 @@ impl Adapter for PointScan { } else if technique.follows_foreground() { environment.foreground = crate::scan_host::foreground().ok(); environment.panel = false; + environment.following = true; } else if technique.keyboard_open() { crate::point_scan_ready(app)?; // The keyboard stays as it is whatever is in front. Only what @@ -375,6 +384,7 @@ fn new_engine(app: &AppHandle, config: Config) -> Result<(Workflow, Environment) display, foreground: crate::scan_host::foreground().ok(), panel: false, + following: false, keyboard_area, }, )) @@ -486,16 +496,21 @@ mod tests { } #[test] fn only_a_chosen_point_is_held_to_its_window() { - let environment = |panel, foreground| Environment { + let environment = |panel, following, foreground| Environment { display: display("one", 0, 1920), foreground, panel, + following, keyboard_area: bounds(&display("one", 0, 1920)), }; for now in [None, Some(1), Some(2)] { for held in [None, Some(1)] { - assert!(!window_changed(&environment(true, held), now)); - assert_eq!(window_changed(&environment(false, held), now), held != now); + assert!(!window_changed(&environment(true, false, held), now)); + assert!(!window_changed(&environment(false, true, held), now)); + assert_eq!( + window_changed(&environment(false, false, held), now), + held != now + ); } } } diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 793e88e8..658139b5 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -135,7 +135,7 @@ pub struct Workflow { elapsed: u64, pending: Option, error: Option, - /// A point scan in which no switch has been used yet. + /// A point scan in which no switch has moved or chosen anything yet. following: bool, } impl Workflow { @@ -230,8 +230,7 @@ impl Workflow { self.move_repeat = None; self.scroll_repeat = None; self.parent_menu.clear(); - self.stage = Stage::Point; - self.point.start(); + self.begin_point(); } } pub fn prediction_keyboard(&mut self) -> Option<&mut crate::scan_keyboard::Keyboard> { @@ -1661,6 +1660,29 @@ mod tests { assert!(idle.active()); } #[test] + fn a_point_scan_follows_the_window_only_while_nothing_is_chosen() { + let mut s = session(false); + s.action(Action::Select); + assert!(s.technique.follows_foreground()); + // Pausing chooses nothing. + s.action(Action::Pause); + s.action(Action::Pause); + assert!(s.technique.follows_foreground()); + s.action(Action::Select); + assert!(!s.technique.follows_foreground()); + s.action(Action::Select); + // The source of a drag was chosen over a window. + s.technique.selected(Item::Drag); + assert_eq!( + s.technique.phase(), + Phase::Workflow(WorkflowPhase::DragDestination) + ); + assert!(!s.technique.follows_foreground()); + // A scan restarted by a change of mode has nothing chosen in it. + s.technique.apply_config(Config::default().point(), true); + assert!(s.technique.follows_foreground()); + } + #[test] fn a_keyboard_that_fails_to_open_from_the_mouse_panel_returns_to_it() { let mut s = session(false); open(&mut s); From c8dec2563067d3cc3ae6b69c1252558af60454da Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 28 Sep 2026 13:07:49 +0100 Subject: [PATCH 4/4] Describe how every point scan follows the window in front The one-second wait described for automatic restart is gone. Co-Authored-By: Claude Fable 5.1 --- docs/scanner-architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/scanner-architecture.md b/docs/scanner-architecture.md index ebb06627..cbfdbc9d 100644 --- a/docs/scanner-architecture.md +++ b/docs/scanner-architecture.md @@ -102,7 +102,7 @@ Rules that hold whatever is chosen: - `wait` has no effect in an area that is not automatic, except that point scanning always waits unless `automatic` is chosen. - A point scan that starts by itself still stops at the pass limit. -A point scan that started by itself takes up whichever window is in front until a switch is used in it, because the click before it may have brought another window forward. From then on, a change of window ends the scan as usual. While such a scan has no window in front, it holds still for up to a second, then ends as usual. +Every point scan takes up whichever window is in front, or none, until a switch moves or chooses something in it. A click may have brought another window forward, and a panel may just have closed. From then on, a change of window ends the scan as usual. A change of display or work area ends a point scan at any time. The keyboard and the mouse panel are not tied to any window or display; see [the keyboard](qwerty-keyboard.md). `keyboardWaitAfterTyping`, saved by earlier versions, still makes the keyboard wait. It applies only while the keyboard has no `nextScan` of its own and the shared value is `standard`. Settings no longer offers or changes it; the saved value is kept.