Skip to content

Commit 8105a51

Browse files
enaboappsOwenMcGirrOwen McGirr
authored
Add native QWERTY switch-scanning keyboard (#790)
* Add native QWERTY switch-scanning keyboard (#789) * Verify keyboard focus handoff and fix native key rendering * Use rectangular image helper for macOS keyboard tiles * Pad and vertically centre Windows keyboard labels Measure wrapped key text before drawing and centre it inside scaled horizontal and vertical margins. Keep action-menu and modifier-overlay placement unchanged. * Preserve room for wrapped keyboard labels on smaller displays * Refine switch keyboard layout and scan feedback Use weighted editing keys, a dedicated wide Space bar and one copy of each modifier. Add keyboard-specific visual roles, active-state indicators, rounded key artwork and a solid panel. Clarify row return and scan target feedback while preserving input and switch timing. * Round and pad the scanning keyboard panel Render a transparent rounded backdrop with scaled outer padding. Keep the backdrop cached during key scanning and verify transparent corners and inset control bounds. * Skip empty native labels to prevent keyboard opening crash Avoid passing an empty UTF-16 slice to DrawTextW for the rounded keyboard backdrop. Cover empty labels through the native drawing path and render the full keyboard in the interactive overlay smoke test. * Remove number row from the letters keyboard Move Backspace beside QWERTY and retain the removed digits and UK symbols on the Numbers page. Cover character availability and the letters layout in regression tests. --------- Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com> Co-authored-by: Owen McGirr <owenmcgirr@Owens-Mac-Studio-2.local>
1 parent 2f6ea59 commit 8105a51

17 files changed

Lines changed: 1601 additions & 28 deletions

docs/qwerty-keyboard.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# QWERTY scanning keyboard
2+
3+
Switchify has its own UK English keyboard on Windows and macOS. It uses the saved local switches or the existing Android remote scanning actions. It does not use the operating system's on-screen keyboard.
4+
5+
Choose a point and select **Type here** to click that point once and open the keyboard. To keep the existing input focus without clicking, select **More → Keyboard**. The keyboard does not take focus or accept mouse clicks.
6+
7+
Scan a row, select it, then scan and select a key. The escape slot returns to rows. After typing, scanning starts again at the first row. Existing automatic/manual movement, speed, pause, reverse and inactivity suspension apply. The keyboard stays open after Space, Backspace, Enter and Tab. **Close** returns to point scanning.
8+
9+
Pages:
10+
11+
- **Letters:** UK QWERTY and punctuation, with Backspace beside the top letter row.
12+
- **Functions:** Esc, F1–F12, arrows, Home, End, Page Up, Page Down and Delete. Windows also has Insert, Print Screen, Scroll Lock and Pause.
13+
- **Numbers:** digits, decimal point, arithmetic operators, UK number-row symbols (including £), Enter and Backspace. Digits are independent of hardware Num Lock.
14+
15+
The bottom control row starts with Close keyboard, followed by Letters, Navigation, Numbers and docking. Space has a dedicated wide key. Editing and modifier keys are wider than ordinary characters; duplicate Shift and Caps controls are removed from the letters page. A header names the current page and scan target. The return-to-rows slot highlights only the header. Active modifiers and the current page have an indicator separate from the scan highlight. Modifiers are available on every page. Shift, Ctrl, Alt/Option and Windows/Command cycle through **off → once → locked → off**. The label shows once or locked. Caps is a keyboard-local uppercase setting; Shift reverses its letter case. Page and position controls do not consume a one-shot modifier.
16+
17+
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.
18+
19+
The keyboard closes when the foreground target or 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. No typed text is logged, and no prediction data, context reader, protocol extension or persistent keyboard settings are added.
20+
21+
## Manual validation
22+
23+
Use synthetic text in Notepad and a browser on Windows, and TextEdit and a browser on macOS. Launch macOS through `npm run macos:run` to retain its Accessibility identity.
24+
25+
1. Use Type here and verify exactly one click focuses the intended field. Use More → Keyboard and verify no click or focus change occurs.
26+
2. Type lowercase letters, uppercase letters with Shift/Caps, and UK punctuation including £, @ and double quotes. Test Space, Backspace, Enter and Tab.
27+
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.
28+
4. Visit every page. Verify row/key highlighting, reverse movement, row escape, suspension/resume, returning to the first row after a key and Close.
29+
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.
30+
6. Switch foreground apps, disconnect remote scanning, change display configuration and close Switchify. Verify overlays disappear and owned input is released.
31+
32+
Automated tests use fake input adapters only. Native manual results must be recorded separately; compilation and unit tests do not establish live application compatibility.

src-tauri/src/input.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ fn pointer_button(button: MouseButton) -> Button {
154154
#[cfg(target_os = "windows")]
155155
fn windows_alphanumeric_key(name: &str) -> Option<Key> {
156156
Some(match name {
157+
#[cfg(target_os = "windows")]
158+
"Insert" => Key::Insert,
159+
#[cfg(target_os = "windows")]
160+
"PrintScreen" => Key::PrintScr,
161+
#[cfg(target_os = "windows")]
162+
"ScrollLock" => Key::Scroll,
163+
#[cfg(target_os = "windows")]
164+
"Pause" => Key::Pause,
157165
"=" => Key::OEMPlus,
158166
"-" => Key::OEMMinus,
159167
"[" => Key::OEM4,

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ mod protocol;
2424
mod remote_scan;
2525
mod scan_executor;
2626
mod scan_host;
27+
mod scan_keyboard;
2728
mod scan_menu;
2829
mod scan_tile;
2930
mod scan_tree;

src-tauri/src/modifier_overlay_windows.rs

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use windows::Win32::Graphics::Gdi::{
1313
CreateCompatibleDC, CreateDIBSection, CreateFontW, DeleteDC, DeleteObject, DrawTextW, GetDC,
1414
GetMonitorInfoW, MonitorFromPoint, ReleaseDC, SelectObject, SetBkMode, SetTextColor,
1515
AC_SRC_ALPHA, AC_SRC_OVER, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, BLENDFUNCTION,
16-
CLIP_DEFAULT_PRECIS, DEFAULT_CHARSET, DEFAULT_QUALITY, DIB_RGB_COLORS, DT_CENTER,
16+
CLIP_DEFAULT_PRECIS, DEFAULT_CHARSET, DEFAULT_QUALITY, DIB_RGB_COLORS, DT_CALCRECT, DT_CENTER,
1717
DT_SINGLELINE, DT_VCENTER, DT_WORDBREAK, FF_DONTCARE, FW_BOLD, HDC, HGDIOBJ, MONITORINFO,
1818
MONITOR_DEFAULTTONEAREST, OUT_DEFAULT_PRECIS, TRANSPARENT,
1919
};
@@ -411,6 +411,7 @@ fn present(window: HWND, labels: &[String], layout: &Layout) -> Result<(), Strin
411411
layout,
412412
pixmap.data(),
413413
labels.iter().any(|label| label.contains('\n')),
414+
false,
414415
)
415416
}
416417

@@ -466,6 +467,7 @@ fn present_pixmap_with_text(
466467
layout: &Layout,
467468
rgba: &[u8],
468469
wrap: bool,
470+
center_wrapped: bool,
469471
) -> Result<(), String> {
470472
unsafe {
471473
let screen = ScreenDc(GetDC(None));
@@ -534,8 +536,24 @@ fn present_pixmap_with_text(
534536
SetBkMode(memory.0, TRANSPARENT);
535537
SetTextColor(memory.0, COLORREF(0x00ff_ffff));
536538
for (label, chip) in labels.iter().zip(&layout.chips) {
539+
if label.is_empty() {
540+
continue;
541+
}
537542
let mut text = label.encode_utf16().collect::<Vec<_>>();
538543
let mut text_rect = *chip;
544+
if center_wrapped {
545+
let mut measured = text_rect;
546+
let height = DrawTextW(
547+
memory.0,
548+
&mut text,
549+
&mut measured,
550+
DT_CENTER | DT_WORDBREAK | DT_CALCRECT,
551+
);
552+
let vertical_padding = text_rect.top.min((layout.height - height).max(0) / 2);
553+
text_rect.top = vertical_padding;
554+
text_rect.bottom = layout.height - vertical_padding;
555+
text_rect.top += ((text_rect.bottom - text_rect.top - height) / 2).max(0);
556+
}
539557
let _ = DrawTextW(
540558
memory.0,
541559
&mut text,
@@ -595,6 +613,35 @@ pub(crate) fn present_scan_tile(
595613
tile: &crate::scanning::FrameTile,
596614
) -> Result<(), String> {
597615
let pixmap = crate::scan_tile::bitmap(tile)?;
616+
if tile.icon == crate::scan_menu::Item::KeyboardKey {
617+
let width = pixmap.width() as i32;
618+
let height = pixmap.height() as i32;
619+
let horizontal_padding = (8.0 * tile.scale).round().max(1.0) as i32;
620+
let vertical_padding = (6.0 * tile.scale).round().max(1.0) as i32;
621+
let horizontal_padding = horizontal_padding.min((width - 1).max(0) / 2);
622+
let vertical_padding = vertical_padding.min((height - 1).max(0) / 2);
623+
let layout = Layout {
624+
x: tile.rect.x.round() as i32,
625+
y: tile.rect.y.round() as i32,
626+
width,
627+
height,
628+
scale: tile.scale * crate::scan_tile::keyboard_font_size(tile) / FONT_SIZE,
629+
chips: vec![RECT {
630+
left: horizontal_padding,
631+
top: vertical_padding,
632+
right: width - horizontal_padding,
633+
bottom: height - vertical_padding,
634+
}],
635+
};
636+
return present_pixmap_with_text(
637+
window,
638+
std::slice::from_ref(&tile.text),
639+
&layout,
640+
pixmap.data(),
641+
true,
642+
true,
643+
);
644+
}
598645
let size = pixmap.width() as i32;
599646
let layout = Layout {
600647
x: tile.rect.x.round() as i32,
@@ -615,6 +662,7 @@ pub(crate) fn present_scan_tile(
615662
&layout,
616663
pixmap.data(),
617664
true,
665+
false,
618666
)
619667
}
620668

@@ -646,6 +694,34 @@ pub(crate) fn present_scan_prompt(
646694

647695
#[cfg(test)]
648696
mod tests {
697+
#[test]
698+
fn empty_keyboard_backdrop_label_never_reaches_native_text_drawing() {
699+
let layout = super::Layout {
700+
x: 0,
701+
y: 0,
702+
width: 100,
703+
height: 50,
704+
scale: 1.0,
705+
chips: vec![windows::Win32::Foundation::RECT {
706+
left: 8,
707+
top: 6,
708+
right: 92,
709+
bottom: 44,
710+
}],
711+
};
712+
let pixels = vec![255; 100 * 50 * 4];
713+
for wrapped in [false, true] {
714+
let result = super::present_pixmap_with_text(
715+
windows::Win32::Foundation::HWND::default(),
716+
&[String::new()],
717+
&layout,
718+
&pixels,
719+
wrapped,
720+
wrapped,
721+
);
722+
assert!(result.is_err());
723+
}
724+
}
649725
use super::*;
650726
use windows::Win32::Foundation::{LPARAM, WPARAM};
651727
use windows::Win32::UI::WindowsAndMessaging::{
@@ -800,6 +876,24 @@ mod tests {
800876
#[test]
801877
fn native_window_render_smoke_becomes_visible_and_hides() {
802878
let mut host = NativeHost::new().unwrap();
879+
let keyboard = crate::scan_keyboard::Keyboard::new(false);
880+
let frame = keyboard.frame(
881+
crate::scanning::Rect {
882+
x: 0.0,
883+
y: 0.0,
884+
width: 1280.0,
885+
height: 720.0,
886+
},
887+
1.0,
888+
crate::scanning::ScannerColor::default(),
889+
);
890+
for tile in &frame.tiles {
891+
super::present_scan_tile(host.window, tile).unwrap();
892+
unsafe {
893+
assert!(IsWindowVisible(host.window).as_bool());
894+
}
895+
}
896+
host.hide();
803897
host.render(&["Ctrl".into(), "Shift".into()]).unwrap();
804898
unsafe {
805899
assert!(IsWindowVisible(host.window).as_bool());

src-tauri/src/overlay_macos.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,20 @@ pub(crate) fn image_from_rgba(
228228
height: usize,
229229
logical_size: f64,
230230
) -> Result<Retained<NSImage>, String> {
231+
image_from_rgba_rect(rgba, width, height, logical_size, logical_size)
232+
}
233+
234+
pub(crate) fn image_from_rgba_rect(
235+
rgba: &[u8],
236+
width: usize,
237+
height: usize,
238+
logical_width: f64,
239+
logical_height: f64,
240+
) -> Result<Retained<NSImage>, String> {
241+
if !logical_height.is_finite() || logical_height <= 0.0 {
242+
return Err("Invalid overlay height.".into());
243+
}
244+
let logical_size = logical_width;
231245
let layout = MacBitmapLayout::new(rgba.len(), width, height, logical_size)?;
232246
let representation = unsafe {
233247
NSBitmapImageRep::initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bitmapFormat_bytesPerRow_bitsPerPixel(
@@ -251,13 +265,13 @@ pub(crate) fn image_from_rgba(
251265
}
252266
representation.setSize(NSSize {
253267
width: layout.logical_size,
254-
height: layout.logical_size,
268+
height: logical_height,
255269
});
256270
let image = NSImage::initWithSize(
257271
NSImage::alloc(),
258272
NSSize {
259273
width: layout.logical_size,
260-
height: layout.logical_size,
274+
height: logical_height,
261275
},
262276
);
263277
image.addRepresentation(&representation as &NSImageRep);

src-tauri/src/point_scan_runtime.rs

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,26 @@ use tauri::{AppHandle, Manager};
1111
pub struct Environment {
1212
display: Display,
1313
foreground: usize,
14+
keyboard_area: Rect,
15+
handoff: Option<FocusHandoff>,
16+
}
17+
#[derive(Clone)]
18+
struct FocusHandoff {
19+
target: usize,
20+
ready_at: std::time::Instant,
21+
}
22+
impl FocusHandoff {
23+
fn verify(&self, now: std::time::Instant, foreground: usize) -> Result<bool, String> {
24+
if now < self.ready_at {
25+
return Ok(false);
26+
}
27+
if foreground != self.target {
28+
return Err(
29+
"The selected target did not receive focus. Please select it again.".into(),
30+
);
31+
}
32+
Ok(true)
33+
}
1434
}
1535
pub struct PointScan;
1636
pub type Controller = scanning_runtime::Controller<PointScan>;
@@ -59,13 +79,43 @@ impl Adapter for PointScan {
5979
crate::scan_executor::cleanup()?;
6080
crate::point_scan_prepare(app)
6181
}
62-
fn activate(app: &AppHandle, request: Request) -> Result<(), String> {
82+
fn activate(app: &AppHandle, request: Request) -> Result<Option<Environment>, String> {
6383
crate::point_scan_ready(app)?;
6484
match request {
85+
Request::OpenKeyboard { point: Some(point) } => {
86+
let target = crate::scan_host::target_at(point)?;
87+
crate::scan_executor::activate(request)?;
88+
let (_, mut environment) =
89+
new_engine(app, app.state::<Controller>().view().config)?;
90+
// Allow the one requested click to establish focus before accepting
91+
// any key. No typing or scan advancement occurs during this handoff.
92+
environment.handoff = Some(FocusHandoff {
93+
target,
94+
ready_at: std::time::Instant::now() + std::time::Duration::from_millis(150),
95+
});
96+
return Ok(Some(environment));
97+
}
6598
Request::Setting(setting) => scanning_runtime::update_point_setting(app, setting),
6699
Request::Display(next) => scanning_runtime::restart_point_on_display(app, next),
67100
request => crate::scan_executor::activate(request),
68101
}
102+
.map(|()| None)
103+
}
104+
fn settle_environment(
105+
app: &AppHandle,
106+
environment: Option<&mut Environment>,
107+
) -> Result<bool, String> {
108+
if let Some(environment) = environment {
109+
if let Some(handoff) = &environment.handoff {
110+
crate::point_scan_ready(app)?;
111+
if !handoff.verify(std::time::Instant::now(), crate::scan_host::foreground()?)? {
112+
return Ok(false);
113+
}
114+
environment.foreground = handoff.target;
115+
environment.handoff = None;
116+
}
117+
}
118+
Ok(true)
69119
}
70120
fn cleanup(_app: &AppHandle) -> Result<(), String> {
71121
crate::scan_executor::cleanup()
@@ -81,7 +131,7 @@ fn new_engine(app: &AppHandle, config: Config) -> Result<(Workflow, Environment)
81131
} else {
82132
1.0
83133
};
84-
let e = Workflow::new(
134+
let mut e = Workflow::new(
85135
config.point(),
86136
Rect {
87137
x: display.x.into(),
@@ -91,11 +141,20 @@ fn new_engine(app: &AppHandle, config: Config) -> Result<(Workflow, Environment)
91141
},
92142
units,
93143
)?;
144+
let keyboard_area = crate::scan_host::work_area(Rect {
145+
x: display.x.into(),
146+
y: display.y.into(),
147+
width: display.width.into(),
148+
height: display.height.into(),
149+
})?;
150+
e.set_keyboard_area(keyboard_area);
94151
Ok((
95152
e,
96153
Environment {
97154
display,
98155
foreground: crate::scan_host::foreground()?,
156+
keyboard_area,
157+
handoff: None,
99158
},
100159
))
101160
}
@@ -109,6 +168,36 @@ fn validate_display(app: &AppHandle, display: Option<&Environment>) -> Result<()
109168
if !displays.contains(&expected.display) {
110169
return Err("Display geometry changed. Scanning restarts.".into());
111170
}
171+
let d = &expected.display;
172+
if crate::scan_host::work_area(Rect {
173+
x: d.x.into(),
174+
y: d.y.into(),
175+
width: d.width.into(),
176+
height: d.height.into(),
177+
})? != expected.keyboard_area
178+
{
179+
return Err("Display work area changed. Scanning restarts.".into());
180+
}
112181
}
113182
Ok(())
114183
}
184+
185+
#[cfg(test)]
186+
mod tests {
187+
use super::*;
188+
189+
#[test]
190+
fn focus_handoff_waits_and_only_accepts_the_clicked_target() {
191+
let start = std::time::Instant::now();
192+
let handoff = FocusHandoff {
193+
target: 42,
194+
ready_at: start + std::time::Duration::from_millis(150),
195+
};
196+
assert_eq!(handoff.verify(start, 7), Ok(false));
197+
assert_eq!(handoff.verify(start, 42), Ok(false));
198+
assert_eq!(handoff.verify(handoff.ready_at, 42), Ok(true));
199+
// A failed activation and an unrelated app stealing focus both fail closed.
200+
assert!(handoff.verify(handoff.ready_at, 7).is_err());
201+
assert!(handoff.verify(handoff.ready_at, 99).is_err());
202+
}
203+
}

0 commit comments

Comments
 (0)