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
13 changes: 13 additions & 0 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,19 @@ pub fn cancel_dictation(coord: CoordinatorState<'_>) {
coord.cancel_dictation();
}

#[tauri::command]
pub async fn handle_window_hotkey_event(
coord: CoordinatorState<'_>,
event_type: String,
key: String,
code: String,
repeat: bool,
) -> Result<(), String> {
coord
.handle_window_hotkey_event(event_type, key, code, repeat)
.await
}

#[cfg(debug_assertions)]
#[tauri::command]
pub async fn inject_hotkey_click_for_dev(coord: CoordinatorState<'_>) -> Result<(), String> {
Expand Down
125 changes: 123 additions & 2 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! insertion, persists history, emits `capsule:state` events to the capsule
//! window.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::Instant;
Expand Down Expand Up @@ -86,6 +87,7 @@ struct Inner {
recorder: Mutex<Option<Recorder>>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
hotkey_trigger_held: AtomicBool,
}

impl Coordinator {
Expand All @@ -109,6 +111,7 @@ impl Coordinator {
recorder: Mutex::new(None),
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
}),
}
}
Expand Down Expand Up @@ -167,6 +170,16 @@ impl Coordinator {
cancel_session(&self.inner);
}

pub async fn handle_window_hotkey_event(
&self,
event_type: String,
key: String,
code: String,
repeat: bool,
) -> Result<(), String> {
handle_window_hotkey_event(&self.inner, event_type, key, code, repeat).await
}

#[cfg(any(debug_assertions, test))]
pub async fn inject_hotkey_click_for_dev(&self) -> Result<(), String> {
log::info!("[coord] dev hotkey injection started");
Expand Down Expand Up @@ -248,10 +261,10 @@ fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
let inner_cloned = Arc::clone(&inner);
match evt {
HotkeyEvent::Pressed => {
async_runtime::spawn(async move { handle_pressed(&inner_cloned).await });
async_runtime::spawn(async move { handle_pressed_edge(&inner_cloned).await });
}
HotkeyEvent::Released => {
async_runtime::spawn(async move { handle_released(&inner_cloned).await });
async_runtime::spawn(async move { handle_released_edge(&inner_cloned).await });
}
HotkeyEvent::Cancelled => {
cancel_session(&inner_cloned);
Expand All @@ -260,6 +273,13 @@ fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
}
}

async fn handle_pressed_edge(inner: &Arc<Inner>) {
let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst);
if !was_held {
handle_pressed(inner).await;
}
}

async fn handle_pressed(inner: &Arc<Inner>) {
let mode = inner.prefs.get().hotkey.mode;
let phase = inner.state.lock().phase;
Expand All @@ -284,6 +304,13 @@ async fn handle_pressed(inner: &Arc<Inner>) {
}
}

async fn handle_released_edge(inner: &Arc<Inner>) {
let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst);
if was_held {
handle_released(inner).await;
}
}

async fn handle_released(inner: &Arc<Inner>) {
let mode = inner.prefs.get().hotkey.mode;
let phase = inner.state.lock().phase;
Expand All @@ -303,6 +330,67 @@ async fn handle_released(inner: &Arc<Inner>) {
}
}

async fn handle_window_hotkey_event(
inner: &Arc<Inner>,
event_type: String,
key: String,
code: String,
repeat: bool,
) -> Result<(), String> {
if event_type == "keydown" && key == "Escape" {
cancel_session(inner);
return Ok(());
}

#[cfg(not(target_os = "windows"))]
{
let _ = (inner, event_type, key, code, repeat);
return Ok(());
}

#[cfg(target_os = "windows")]
{
let trigger = inner.prefs.get().hotkey.trigger;
if !window_key_matches_trigger(trigger, &key, &code) {
return Ok(());
}

match event_type.as_str() {
"keydown" => {
if repeat {
return Ok(());
}
log::info!(
"[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}"
);
handle_pressed_edge(inner).await;
}
"keyup" => {
log::info!("[window-hotkey] released trigger={trigger:?} code={code}");
handle_released_edge(inner).await;
}
_ => {}
}
Ok(())
}
}

#[cfg(any(target_os = "windows", test))]
fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, code: &str) -> bool {
use crate::types::HotkeyTrigger;

match trigger {
HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight",
HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft",
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => {
(key == "Alt" || key == "AltGraph") && code == "AltRight"
}
HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltRight",
HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight",
HotkeyTrigger::Fn => key == "Control" && code == "ControlRight",
}
}

// ─────────────────────────── session lifecycle ───────────────────────────

async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
Expand Down Expand Up @@ -892,6 +980,7 @@ fn enabled_hotwords(inner: &Arc<Inner>) -> Vec<DictionaryHotword> {
#[cfg(test)]
mod tests {
use super::*;
use crate::types::HotkeyTrigger;

#[tokio::test]
async fn hotkey_injection_gate_logs_pressed_and_cancels() {
Expand All @@ -907,6 +996,38 @@ mod tests {
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN");
}

#[test]
fn window_key_matcher_mirrors_windows_trigger_aliases() {
let cases = [
(HotkeyTrigger::RightControl, "Control", "ControlRight"),
(HotkeyTrigger::LeftControl, "Control", "ControlLeft"),
(HotkeyTrigger::RightOption, "Alt", "AltRight"),
(HotkeyTrigger::RightAlt, "AltGraph", "AltRight"),
(HotkeyTrigger::RightCommand, "Meta", "MetaRight"),
// Mirrors Windows trigger_to_vk_code aliases.
(HotkeyTrigger::LeftOption, "Alt", "AltRight"),
(HotkeyTrigger::Fn, "Control", "ControlRight"),
];
for (trigger, key, code) in cases {
assert!(
window_key_matches_trigger(trigger, key, code),
"{trigger:?} should match {key}/{code}"
);
}

assert!(!window_key_matches_trigger(
HotkeyTrigger::RightControl,
"Control",
"ControlLeft"
));
assert!(!window_key_matches_trigger(
HotkeyTrigger::LeftOption,
"Alt",
"AltLeft"
));
assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn"));
}
}

fn enabled_phrases(inner: &Arc<Inner>) -> Vec<String> {
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ pub fn run() {
commands::start_dictation,
commands::stop_dictation,
commands::cancel_dictation,
commands::handle_window_hotkey_event,
#[cfg(debug_assertions)]
commands::inject_hotkey_click_for_dev,
commands::repolish,
Expand Down
41 changes: 40 additions & 1 deletion openless-all/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { Capsule } from './components/Capsule';
import { FloatingShell } from './components/FloatingShell';
import { Onboarding } from './components/Onboarding';
import { detectOS } from './components/WindowChrome';
import { checkAccessibilityPermission, checkMicrophonePermission, isTauri } from './lib/ipc';
import {
checkAccessibilityPermission,
checkMicrophonePermission,
handleWindowHotkeyEvent,
isTauri,
} from './lib/ipc';
import { HotkeySettingsProvider } from './state/HotkeySettingsContext';

interface AppProps {
Expand Down Expand Up @@ -53,6 +58,28 @@ export function App({ isCapsule }: AppProps) {
};
}, [os]);

useEffect(() => {
if (!isTauri || os !== 'win') return;
const forwardKey = (event: KeyboardEvent) => {
if (!isWindowHotkeyCandidate(event)) return;
console.debug(
`[ui-key] type=${event.type} key=${event.key} code=${event.code} repeat=${event.repeat}`,
);
void handleWindowHotkeyEvent(
event.type as 'keydown' | 'keyup',
event.key,
event.code,
event.repeat,
).catch(error => console.warn('[window-hotkey] forward failed', error));
};
window.addEventListener('keydown', forwardKey, true);
window.addEventListener('keyup', forwardKey, true);
return () => {
window.removeEventListener('keydown', forwardKey, true);
window.removeEventListener('keyup', forwardKey, true);
};
}, [os]);

if (gate === 'checking') {
return <StartupShell />;
}
Expand All @@ -63,6 +90,18 @@ export function App({ isCapsule }: AppProps) {
);
}

function isWindowHotkeyCandidate(event: KeyboardEvent): boolean {
return (
event.key === 'Escape' ||
event.code === 'ControlRight' ||
event.code === 'ControlLeft' ||
event.code === 'AltRight' ||
event.code === 'AltLeft' ||
event.code === 'MetaRight' ||
event.code === 'Fn'
);
}

function StartupShell() {
return (
<div
Expand Down
9 changes: 9 additions & 0 deletions openless-all/app/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ export function cancelDictation(): Promise<void> {
return invokeOrMock('cancel_dictation', undefined, () => undefined);
}

export function handleWindowHotkeyEvent(
eventType: 'keydown' | 'keyup',
key: string,
code: string,
repeat: boolean,
): Promise<void> {
return invokeOrMock('handle_window_hotkey_event', { event_type: eventType, key, code, repeat }, () => undefined);
}

// ── Polish ─────────────────────────────────────────────────────────────
export function repolish(rawText: string, mode: PolishMode): Promise<string> {
return invokeOrMock('repolish', { rawText, mode }, () => rawText);
Expand Down
Loading