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_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,22 @@ impl Adapter for PointScan {
crate::prediction::record(stroke, result.is_ok(), scope);
return result.map(|()| None);
}
Request::KeyboardPunctuation(mut punctuation) => {
let scope = crate::prediction::InputScope::capture();
if punctuation.owned_space != crate::prediction::keyboard_input_context() {
punctuation.owned_space = None;
}
let removed_space = punctuation.owned_space.is_some();
let result =
crate::scan_executor::activate(Request::KeyboardPunctuation(punctuation));
crate::prediction::record_punctuation(
punctuation.mark,
removed_space,
result.is_ok(),
scope,
);
return result.map(|()| None);
}
Request::OpenKeyboard | Request::OpenMouse | Request::OpenPoint => {
crate::prediction::stop();
crate::scan_executor::activate(request)
Expand Down Expand Up @@ -246,12 +262,16 @@ impl Adapter for PointScan {
| Request::MouseMove { .. }
| Request::MouseMoveAbsolute { .. }
| Request::MouseScroll { .. }
| Request::KeyboardPunctuation(_)
)
}
fn deferred(request: &Request) -> bool {
matches!(request, Request::Prediction { .. })
}
fn poll(app: &AppHandle, technique: &mut Workflow, captured_keys: &[String]) {
if technique.keyboard_open() {
crate::prediction::start_keyboard_activity(captured_keys);
}
let enabled = technique.prediction_enabled();
crate::prediction::poll(app, technique.prediction_keyboard(), enabled, captured_keys);
}
Expand Down
12 changes: 10 additions & 2 deletions src-tauri/src/point_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub enum Request {
OpenMouse,
OpenPoint,
Keyboard(crate::scan_keyboard::Stroke),
KeyboardPunctuation(crate::scan_keyboard::Punctuation),
MouseMove {
dx: i32,
dy: i32,
Expand Down Expand Up @@ -536,7 +537,8 @@ impl Technique for Workflow {
if self.stage == Stage::KeyboardOpening {
self.stage = Stage::Keyboard;
} else if self.stage == Stage::Keyboard {
self.keyboard.succeeded();
self.keyboard
.succeeded_with_context(crate::prediction::keyboard_input_context());
}
}
fn start(&mut self) {
Expand Down Expand Up @@ -615,10 +617,16 @@ impl Technique for Workflow {
return self.selected(Item::Keyboard);
}
match self.stage {
Stage::Keyboard => match self.keyboard.handle(action) {
Stage::Keyboard => match self
.keyboard
.handle_with_context(action, crate::prediction::keyboard_input_context())
{
Some(crate::scan_keyboard::Output::Stroke(stroke)) => {
return Some(Request::Keyboard(stroke))
}
Some(crate::scan_keyboard::Output::Punctuation(punctuation)) => {
return Some(Request::KeyboardPunctuation(punctuation))
}
Some(crate::scan_keyboard::Output::Prediction { token, index }) => {
return Some(Request::Prediction { token, index })
}
Expand Down
83 changes: 71 additions & 12 deletions src-tauri/src/prediction/activity.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,37 @@
//! A content-free input epoch. Never suppresses or records external input.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{OnceLock, RwLock};
static EPOCH: AtomicU64 = AtomicU64::new(0);
static HEALTHY: AtomicBool = AtomicBool::new(false);
static IGNORED: OnceLock<RwLock<Vec<u32>>> = OnceLock::new();
fn ignored() -> &'static RwLock<Vec<u32>> {
IGNORED.get_or_init(|| RwLock::new(Vec::new()))
}
pub fn set_ignored(mut keys: Vec<u32>) {
keys.sort_unstable();
keys.dedup();
let mut current = ignored().write().unwrap_or_else(|error| error.into_inner());
if *current != keys {
*current = keys;
// An assignment change makes earlier ownership claims unsafe.
changed();
}
}
fn ignored_key(code: u32) -> bool {
ignored()
.read()
.unwrap_or_else(|error| error.into_inner())
.binary_search(&code)
.is_ok()
}
#[cfg(any(target_os = "macos", test))]
fn first_disable(flag: &AtomicBool) -> bool {
!flag.swap(true, Ordering::SeqCst)
}
#[cfg(any(target_os = "macos", test))]
fn needs_exit_invalidation(flag: &AtomicBool) -> bool {
!flag.load(Ordering::SeqCst)
}
pub fn snapshot() -> (u64, bool) {
(EPOCH.load(Ordering::SeqCst), HEALTHY.load(Ordering::SeqCst))
}
Expand All @@ -24,16 +54,12 @@ fn changed() {
}

#[cfg(target_os = "windows")]
pub fn start(ignored: Vec<u32>) -> bool {
use std::cell::RefCell;
pub fn start() -> bool {
use windows_sys::Win32::{System::LibraryLoader::GetModuleHandleW, UI::WindowsAndMessaging::*};
thread_local! { static IGNORE: RefCell<Vec<u32>> = const { RefCell::new(Vec::new()) }; }
unsafe extern "system" fn keyboard(code: i32, wp: usize, lp: isize) -> isize {
if code >= 0 && matches!(wp as u32, WM_KEYDOWN | WM_SYSKEYDOWN) {
let key = unsafe { &*(lp as *const KBDLLHOOKSTRUCT) };
if !crate::input::own_input(key.dwExtraInfo as i64)
&& !IGNORE.with(|i| i.borrow().contains(&key.vkCode))
{
if !crate::input::own_input(key.dwExtraInfo as i64) && !ignored_key(key.vkCode) {
changed();
}
}
Expand All @@ -55,7 +81,6 @@ pub fn start(ignored: Vec<u32>) -> bool {
}
let (tx, rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || unsafe {
IGNORE.with(|i| *i.borrow_mut() = ignored);
let k = SetWindowsHookExW(
WH_KEYBOARD_LL,
Some(keyboard),
Expand Down Expand Up @@ -92,11 +117,13 @@ pub fn start(ignored: Vec<u32>) -> bool {
.unwrap_or(false)
}
#[cfg(target_os = "macos")]
pub fn start(ignored: Vec<u32>) -> bool {
pub fn start() -> bool {
use core_graphics::event::{
CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType,
CallbackResult,
};
let disabled = std::sync::Arc::new(AtomicBool::new(false));
let callback_disabled = disabled.clone();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let ready = tx.clone();
Expand All @@ -116,12 +143,16 @@ pub fn start(ignored: Vec<u32>) -> bool {
kind,
CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput
) {
unavailable();
if first_disable(&callback_disabled) {
unavailable();
}
// Returning from the run loop drops this tap before a retry.
core_foundation::runloop::CFRunLoop::get_current().stop();
return CallbackResult::Keep;
}
if !crate::input::own_input(event.get_integer_value_field(42))
&& (!matches!(kind, CGEventType::KeyDown)
|| !ignored.contains(&(event.get_integer_value_field(9) as u32)))
|| !ignored_key(event.get_integer_value_field(9) as u32))
{
changed();
}
Expand All @@ -135,12 +166,40 @@ pub fn start(ignored: Vec<u32>) -> bool {
},
);
let _ = tx.send(false);
unavailable();
if needs_exit_invalidation(&disabled) {
unavailable();
}
});
rx.recv_timeout(std::time::Duration::from_millis(500))
.unwrap_or(false)
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
pub fn start(_: Vec<u32>) -> bool {
pub fn start() -> bool {
false
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn reassignment_updates_ignored_keys_and_invalidates_old_context() {
set_ignored(vec![32]);
assert!(ignored_key(32));
let before = snapshot().0;
set_ignored(vec![120]);
assert!(snapshot().0 > before);
assert!(!ignored_key(32));
assert!(ignored_key(120));
set_ignored(Vec::new());
}

#[test]
fn disabled_tap_invalidates_once_before_its_run_loop_exits() {
let disabled = AtomicBool::new(false);
assert!(needs_exit_invalidation(&disabled));
assert!(first_disable(&disabled));
assert!(!first_disable(&disabled));
assert!(!needs_exit_invalidation(&disabled));
}
}
107 changes: 102 additions & 5 deletions src-tauri/src/prediction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use std::{
cell::RefCell,
path::Path,
process::{Child, ChildStdin, Command, Stdio},
sync::atomic::{AtomicU8, Ordering},
sync::mpsc,
thread::JoinHandle,
time::{Duration, Instant},
Expand Down Expand Up @@ -259,6 +260,49 @@ impl Service {
}
}
thread_local! { static SERVICE: RefCell<Service> = RefCell::new(Service::default()); }
// 0 = idle, 1 = starting, 2 = running. A failed or disabled hook can retry.
static KEYBOARD_ACTIVITY: AtomicU8 = AtomicU8::new(0);

fn claim_keyboard_activity_start(state: &AtomicU8, healthy: bool) -> bool {
if healthy {
return false;
}
let _ = state.compare_exchange(2, 0, Ordering::SeqCst, Ordering::SeqCst);
state
.compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}

pub fn start_keyboard_activity(ignored: &[String]) {
activity::set_ignored(
ignored
.iter()
.filter_map(|name| crate::switch_input::prediction_key_code(name))
.collect(),
);
if claim_keyboard_activity_start(&KEYBOARD_ACTIVITY, activity::snapshot().1) {
std::thread::spawn(move || {
let started = activity::start();
if !started {
// Avoid a busy retry loop while native input access is unavailable.
std::thread::sleep(Duration::from_secs(1));
}
KEYBOARD_ACTIVITY.store(if started { 2 } else { 0 }, Ordering::SeqCst);
});
}
}

pub fn keyboard_input_context() -> Option<crate::scan_keyboard::TypingContext> {
let (epoch, healthy) = activity::snapshot();
if !healthy {
return None;
}
let foreground = crate::scan_host::foreground().ok()?;
(foreground != 0).then_some(crate::scan_keyboard::TypingContext {
foreground,
activity: epoch,
})
}
pub fn stop() {
SERVICE.with(|s| *s.borrow_mut() = Service::default());
}
Expand All @@ -284,6 +328,22 @@ pub fn record(stroke: Stroke, success: bool, scope: InputScope) {
s.reset = true;
});
}
pub fn record_punctuation(mark: char, removed_space: bool, success: bool, scope: InputScope) {
SERVICE.with(|s| {
let mut s = s.borrow_mut();
s.generation = s.generation.wrapping_add(1);
s.last = None;
if success && scope.unchanged() {
if removed_space {
s.queue_edit(Edit::Backspace, scope);
}
s.queue_edit(Edit::Append(format!("{mark} ")), scope);
} else {
s.edit.clear();
s.reset = true;
}
});
}
pub fn select(token: u64, index: usize) -> Result<(), String> {
SERVICE.with(|s| {
let mut s = s.borrow_mut();
Expand Down Expand Up @@ -331,7 +391,7 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign
SERVICE.with(|slot| {
let mut s = slot.borrow_mut();
let case = (
keyboard.modifiers[0] != Modifier::Off,
keyboard.prediction_shift(),
keyboard.caps,
keyboard.modifiers[1..].iter().any(|m| *m != Modifier::Off),
);
Expand Down Expand Up @@ -395,17 +455,25 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign
if !scope.unchanged() {
return Err(());
}
let trailing_space = text.ends_with(' ');
let contains_letter = text.chars().any(char::is_alphabetic);
s.queue_edit(Edit::Append(text), scope);
Ok(())
Ok((trailing_space, contains_letter))
})
} else {
Err(())
};
s.generation = s.generation.wrapping_add(1);
s.last = None;
keyboard.predictions(None, false);
if result.is_ok() {
keyboard.succeeded();
if let Ok((trailing_space, contains_letter)) = result {
if keyboard.succeeded() {
keyboard.prediction_inserted(
trailing_space,
contains_letter,
keyboard_input_context(),
);
}
} else {
s.edit.clear();
s.reset = true;
Expand Down Expand Up @@ -442,7 +510,7 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign
generation: s.generation,
edits: s.take_edits(),
revision: s.edit_revision,
shift: keyboard.modifiers[0] != Modifier::Off,
shift: keyboard.prediction_shift(),
caps: keyboard.caps,
}
};
Expand All @@ -459,6 +527,35 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign
mod tests {
use super::*;
#[test]
fn activity_observer_retries_after_failure_or_lost_hook() {
let state = AtomicU8::new(0);
assert!(claim_keyboard_activity_start(&state, false));
assert!(!claim_keyboard_activity_start(&state, false));
state.store(0, Ordering::SeqCst); // first hook attempt failed
assert!(claim_keyboard_activity_start(&state, false));
state.store(2, Ordering::SeqCst); // second hook started
assert!(!claim_keyboard_activity_start(&state, true));
assert!(claim_keyboard_activity_start(&state, false)); // hook later stopped
}
#[test]
fn punctuation_records_the_backspace_and_insert_as_one_successful_edit() {
stop();
record_punctuation('.', true, true, InputScope::capture());
SERVICE.with(|slot| {
let service = slot.borrow();
assert_eq!(service.edit.len(), 2);
assert!(matches!(service.edit[0].edit, Edit::Backspace));
assert!(matches!(service.edit[1].edit, Edit::Append(ref text) if text == ". "));
});
record_punctuation('?', true, false, InputScope::capture());
SERVICE.with(|slot| {
let service = slot.borrow();
assert!(service.edit.is_empty());
assert!(service.reset);
});
stop();
}
#[test]
fn reopening_discards_failed_workers_and_pending_acceptance() {
for failed in [false, true] {
SERVICE.with(|slot| {
Expand Down
Loading
Loading