Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src-tauri/src/point_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub enum ControlMode {
pub enum KeyboardLayout {
#[default]
Qwerty,
SimpleQwerty,
CommonLetters,
}

Expand Down Expand Up @@ -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::<Config>(r#"{"keyboardLayout":"unknown"}"#).is_err());
}
#[test]
Expand Down
58 changes: 30 additions & 28 deletions src-tauri/src/point_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}
}
Expand Down
169 changes: 165 additions & 4 deletions src-tauri/src/scan_keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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::<Vec<_>>()
};
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"));
Expand Down Expand Up @@ -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<TypingContext>) -> Option<Output> {
self.discard_stale_context(context);
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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::<Vec<_>>()
};
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(' ', ' '));
Expand Down Expand Up @@ -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());
Expand Down
7 changes: 6 additions & 1 deletion src/PointScan.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} }) })
Expand Down Expand Up @@ -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"));
Expand Down
2 changes: 1 addition & 1 deletion src/scanning/useScanning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 7 additions & 4 deletions src/settings/ScannerPreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,23 +136,26 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl

</>}
{area === 'keyboard' && <>
<SettingGroup title="Keyboard layout" description="Choose the order of keys on the Letters page. The Numbers and Navigation pages stay the same.">
<SettingGroup title="Keyboard layout" description="Choose the Letters page layout. Simple QWERTY puts punctuation on its Numbers page; Navigation stays the same.">
<OptionGroup<PointScanConfig['keyboardLayout']>
legend="Keyboard layout"
disabled={disabled || !!pending}
value={config.keyboardLayout}
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.' }}
/>
<p className="setting-note">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.</p>
<p className="setting-note">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.</p>
</SettingGroup>
<p className="setting-note">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.</p>
<p className="setting-note">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.</p>
</>}
{(area === 'keyboard' || area === 'mouse') && <SettingGroup title="Panel position" description="Choose where the keyboard and mouse panels sit from Position on either panel.">
<Toggle label="Move away from the pointer" checked={config.panelAvoidsPointer} disabled={disabled} onChange={value => update('panelAvoidsPointer', value)} />
Expand Down
Loading
Loading