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
20 changes: 20 additions & 0 deletions src-tauri/src/point_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub struct Config {
#[serde(deserialize_with = "crate::scan_preferences::deserialize_preferences")]
pub scan_preferences: crate::scan_preferences::Preferences,
pub word_prediction: bool,
pub keyboard_wait_after_typing: bool,
pub scanner_color: crate::scanning::ScannerColor,
pub mode: Mode,
pub automatic: bool,
Expand All @@ -36,6 +37,7 @@ impl Default for Config {
Self {
scan_preferences: Default::default(),
word_prediction: true,
keyboard_wait_after_typing: false,
scanner_color: Default::default(),
mode: Mode::Line,
automatic: true,
Expand Down Expand Up @@ -84,6 +86,7 @@ impl Config {
menu_scan: self.resolved(crate::scan_preferences::Area::Menu),
keyboard_scan: self.resolved(crate::scan_preferences::Area::Keyboard),
word_prediction: self.word_prediction,
keyboard_wait_after_typing: self.keyboard_wait_after_typing,
scanner_color: self.resolved(crate::scan_preferences::Area::Point).color,
mode: self.mode,
speed: self.speed,
Expand All @@ -106,6 +109,7 @@ pub struct PointSettings {
pub menu_scan: crate::scan_preferences::Resolved,
pub keyboard_scan: crate::scan_preferences::Resolved,
pub word_prediction: bool,
pub keyboard_wait_after_typing: bool,
pub scanner_color: crate::scanning::ScannerColor,
pub mode: Mode,
pub speed: usize,
Expand Down Expand Up @@ -545,6 +549,21 @@ mod tests {
assert!(!restored.word_prediction);
}
#[test]
fn keyboard_wait_defaults_off_and_round_trips() {
let legacy: Config = serde_json::from_str("{}").unwrap();
assert!(!legacy.keyboard_wait_after_typing);
for enabled in [false, true] {
let config = Config {
keyboard_wait_after_typing: enabled,
..Default::default()
};
let restored: Config =
serde_json::from_value(serde_json::to_value(config).unwrap()).unwrap();
assert_eq!(restored.keyboard_wait_after_typing, enabled);
assert_eq!(restored.point().keyboard_wait_after_typing, enabled);
}
}
#[test]
fn auto_select_delays_are_bounded_and_round_trip() {
for delay in [100, 500, 1000, 100_000] {
let config = Config {
Expand Down Expand Up @@ -942,6 +961,7 @@ mod tests {
json["autoSelectDelayMs"] = serde_json::json!(1000);
json["scannerColor"] = serde_json::json!("blue");
json["wordPrediction"] = serde_json::json!(true);
json["keyboardWaitAfterTyping"] = serde_json::json!(false);
json["scanPreferences"] =
serde_json::to_value(crate::scan_preferences::Preferences::default()).unwrap();
assert_eq!(serde_json::to_value(&config).unwrap(), json);
Expand Down
67 changes: 63 additions & 4 deletions src-tauri/src/point_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ impl Workflow {
keyboard: crate::scan_keyboard::Keyboard::configured(
cfg!(target_os = "macos"),
keyboard_options,
),
)
.with_wait_after_typing(config.keyboard_wait_after_typing),
keyboard_area: screen,
point: Engine::new(config, screen, scale)?,
stage: Stage::Idle,
Expand Down Expand Up @@ -138,7 +139,8 @@ impl Workflow {
self.keyboard = crate::scan_keyboard::Keyboard::configured(
cfg!(target_os = "macos"),
self.point.config.keyboard_scan,
);
)
.with_wait_after_typing(self.point.config.keyboard_wait_after_typing);
self.parent_menu.clear();
self.stage = Stage::Point;
self.point.start();
Expand Down Expand Up @@ -179,7 +181,8 @@ impl Workflow {
self.keyboard = crate::scan_keyboard::Keyboard::configured(
cfg!(target_os = "macos"),
self.point.config.keyboard_scan,
);
)
.with_wait_after_typing(self.point.config.keyboard_wait_after_typing);
return Some(Request::OpenKeyboard {
point: (item == Item::TypeHere).then_some(self.source),
});
Expand Down Expand Up @@ -297,7 +300,8 @@ impl Technique for Workflow {
self.keyboard = crate::scan_keyboard::Keyboard::configured(
cfg!(target_os = "macos"),
self.point.config.keyboard_scan,
);
)
.with_wait_after_typing(self.point.config.keyboard_wait_after_typing);
self.stage = Stage::Idle;
self.parent_menu.clear();
self.pending = None;
Expand Down Expand Up @@ -604,6 +608,61 @@ mod tests {
assert!(w.frame().tiles.is_empty());
}
#[test]
fn typing_wait_survives_pause_hold_and_predictions_but_not_stop_or_config_reset() {
let mut s = session(true);
let config = Config {
keyboard_wait_after_typing: true,
block_interval_ms: 250,
..Default::default()
};
s.technique.apply_config(config.point(), false);
s.action(Action::Select);
s.technique.selected(Item::Keyboard);
s.technique.execution_succeeded();
assert_eq!(
s.technique.phase(),
Phase::Workflow(WorkflowPhase::Keyboard)
);
assert!(s.action(Action::Select).is_none());
assert!(matches!(
s.action(Action::Select),
Some(Request::Keyboard(_))
));
s.technique.execution_succeeded();
assert_eq!(
s.technique.phase(),
Phase::Workflow(WorkflowPhase::KeyboardSuspended)
);
let waiting = s.frame();
s.action(Action::Pause);
s.tick(250, false);
s.action(Action::Pause);
s.tick(250, true);
s.tick(250, false);
assert_eq!(s.frame(), waiting);
assert!(s.action(Action::Select).is_none());
assert_eq!(
s.technique.phase(),
Phase::Workflow(WorkflowPhase::Keyboard)
);
let resumed = s.frame();
s.tick(249, false);
assert_eq!(s.frame(), resumed);
s.tick(1, false);
assert_ne!(s.frame(), resumed);
s.action(Action::Stop);
assert!(!s.active());
s.technique.execution_succeeded();
assert_eq!(s.technique.phase(), Phase::default());
s.technique.apply_config(config.point(), true);
s.technique.selected(Item::Keyboard);
s.technique.execution_succeeded();
assert_eq!(
s.technique.phase(),
Phase::Workflow(WorkflowPhase::Keyboard)
);
}
#[test]
fn keyboard_key_acknowledgement_is_exactly_once_and_reset_discards_state() {
let mut s = session(false);
let w = &mut s.technique;
Expand Down
152 changes: 152 additions & 0 deletions src-tauri/src/scan_keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ pub struct Keyboard {
predictions: Option<crate::prediction::worker::Batch>,
queued_predictions: Option<crate::prediction::worker::Batch>,
prefer_predictions: bool,
wait_after_typing: bool,
waiting_after_typing: bool,
}
impl Keyboard {
#[cfg(test)]
Expand All @@ -208,8 +210,14 @@ impl Keyboard {
predictions: None,
queued_predictions: None,
prefer_predictions: true,
wait_after_typing: false,
waiting_after_typing: false,
}
}
pub fn with_wait_after_typing(mut self, enabled: bool) -> Self {
self.wait_after_typing = enabled;
self
}
pub fn enable_predictions(&mut self, enabled: bool) {
if self.prediction_enabled == enabled {
return;
Expand Down Expand Up @@ -239,6 +247,11 @@ impl Keyboard {
self.queued_predictions = None;
return;
}
if self.waiting_after_typing {
self.predictions = batch;
self.queued_predictions = None;
return;
}
if self.prediction_row_active() {
if self.predictions.as_ref().map(|b| b.token) != batch.as_ref().map(|b| b.token) {
self.predictions = None;
Expand Down Expand Up @@ -290,6 +303,7 @@ impl Keyboard {
self.scan.suspended
}
fn restart(&mut self) {
self.waiting_after_typing = false;
self.prefer_predictions = true;
self.scan.restart();
self.skip_disabled(false);
Expand Down Expand Up @@ -373,6 +387,11 @@ impl Keyboard {
}
self.activation = None;
self.restart();
if self.wait_after_typing && self.scan.options.automatic {
self.waiting_after_typing = true;
self.prefer_predictions = false;
self.scan.suspended = true;
}
}
}
pub fn failed(&mut self) {
Expand Down Expand Up @@ -554,6 +573,8 @@ impl Keyboard {
};
let text = if self.error {
"Input failed · Select to try again".to_owned()
} else if self.waiting_after_typing {
"Press Select to continue typing.".to_owned()
} else if self.scan.suspended {
"Keyboard paused · Select to resume".to_owned()
} else if self.scan.nav.escaping() {
Expand Down Expand Up @@ -599,6 +620,137 @@ impl Keyboard {
mod tests {
use super::*;
#[test]
fn typing_wait_consumes_resume_and_restarts_a_full_interval() {
use crate::scan_preferences::{Direction, Pattern, Resolved};
for direction in [Direction::Forward, Direction::Reverse] {
for pattern in [Pattern::Grouped, Pattern::Linear] {
for automatic in [false, true] {
for enabled in [false, true] {
let mut k = Keyboard::configured(
false,
Resolved {
direction,
pattern,
automatic,
pass_limit: 1,
..Default::default()
},
)
.with_wait_after_typing(enabled);
assert!(matches!(
k.choose(Key::Character('a', 'A')),
Some(Output::Stroke(_))
));
assert!(k.handle(Action::Select).is_none());
k.succeeded();
assert_eq!(k.waiting_after_typing, automatic && enabled);
assert_eq!(k.suspended(), automatic && enabled);
if automatic && enabled {
let position = k.scan.position(&k.rows);
k.advance(5000, 1000);
assert_eq!(k.scan.position(&k.rows), position);
k.handle(Action::Next);
k.handle(Action::Back);
k.handle(Action::Reverse);
assert!(k.waiting_after_typing);
assert!(k.handle(Action::Select).is_none());
assert!(!k.waiting_after_typing);
k.advance(999, 1000);
assert_eq!(k.scan.position(&k.rows), position);
k.advance(1, 1000);
assert_ne!(k.scan.position(&k.rows), position);
}
}
}
}
}
}
#[test]
fn predictions_and_duplicate_completion_cannot_resume_typing_wait() {
let mut k = Keyboard::new(false).with_wait_after_typing(true);
k.enable_predictions(true);
k.predictions(
Some(crate::prediction::worker::Batch {
token: 1,
words: vec!["hello".into()],
}),
false,
);
assert!(matches!(
k.choose(Key::Prediction(0)),
Some(Output::Prediction { token: 1, index: 0 })
));
k.succeeded();
for token in 2..5 {
k.predictions(
Some(crate::prediction::worker::Batch {
token,
words: vec!["world".into()],
}),
false,
);
k.succeeded();
k.advance(5000, 1000);
assert!(k.waiting_after_typing);
let frame = k.frame(
Rect {
x: 0.0,
y: 0.0,
width: 1000.0,
height: 800.0,
},
1.0,
ScannerColor::Blue,
);
assert!(frame
.tiles
.iter()
.any(|t| t.text == "Press Select to continue typing."));
assert!(!frame.tiles.iter().any(|t| t.selected));
}
assert!(k.handle(Action::Select).is_none());
assert_eq!(k.predictions.as_ref().unwrap().token, 4);
assert!(k.handle(Action::Select).is_none());
assert_eq!(
k.handle(Action::Select),
Some(Output::Prediction { token: 4, index: 0 })
);
assert!(k.handle(Action::Select).is_none());
}
#[test]
fn keyboard_controls_remain_immediate_and_failures_keep_error_recovery() {
for key in [
Key::Modifier(0),
Key::Caps,
Key::Dock,
Key::Page(Page::Numbers),
] {
let mut k = Keyboard::new(false).with_wait_after_typing(true);
assert!(k.choose(key).is_none());
assert!(!k.waiting_after_typing);
assert!(!k.suspended());
}
for key in [
Key::Character(' ', ' '),
Key::Named("Backspace"),
Key::Named("ArrowLeft"),
] {
let mut k = Keyboard::new(false).with_wait_after_typing(true);
k.choose(key);
k.succeeded();
assert!(k.waiting_after_typing);
}
let mut k = Keyboard::new(false).with_wait_after_typing(true);
k.choose(Key::Named("Enter"));
k.failed();
assert!(!k.waiting_after_typing);
assert!(k.error);
assert!(k.suspended());
assert!(k.handle(Action::Select).is_none());
assert!(!k.error);
assert!(!k.waiting_after_typing);
}
#[test]
fn arriving_predictions_receive_a_full_scan_interval() {
let mut keyboard = Keyboard::new(false);
keyboard.enable_predictions(true);
Expand Down
17 changes: 17 additions & 0 deletions src/PointScan.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,20 @@ it("brings the area header into view with help expanded and restores the overvie
else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
}
});

it("saves the keyboard after-typing choice and preserves it while manual", async () => {
mocks.invoke.mockImplementation(async (command, args) => command === "get_point_scan" ? initial : { ...initial, config: args.config });
render(<PointScan />);
await screen.findByText(initial.message);
expect(screen.queryByText("After typing")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Customise keyboard" }));
expect(screen.getByRole("button", { name: "Continue scanning" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Wait for Select" }));
await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { config: { ...defaultPointScanConfig, keyboardWaitAfterTyping: true } }));
fireEvent.click(screen.getByRole("checkbox", { name: "Automatic scanning" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Wait for Select" })).toBeDisabled());
expect(screen.getByRole("button", { name: "Wait for Select" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("checkbox", { name: "Automatic scanning" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Wait for Select" })).toBeEnabled());
expect(screen.getByRole("button", { name: "Wait for Select" })).toHaveAttribute("aria-pressed", "true");
});
Loading
Loading