fix(captures): use platform hotkey defaults

This commit is contained in:
Jamie Pine
2026-04-25 10:28:34 -07:00
parent 29f99a8622
commit 2a1bb6f936
8 changed files with 77 additions and 20 deletions
@@ -30,7 +30,7 @@ import { useProfiles } from '@/lib/hooks/useProfiles';
import { usePlatform } from '@/platform/PlatformContext'; import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore'; import { useServerStore } from '@/stores/serverStore';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes'; import { defaultChordKeys, displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
import type { Qwen3ModelSize, VoiceProfileResponse, WhisperModelSize } from '@/lib/api/types'; import type { Qwen3ModelSize, VoiceProfileResponse, WhisperModelSize } from '@/lib/api/types';
import { SettingRow, SettingSection } from './SettingRow'; import { SettingRow, SettingSection } from './SettingRow';
@@ -135,8 +135,8 @@ export function CapturesPage() {
const allowAutoPaste = settings?.allow_auto_paste ?? true; const allowAutoPaste = settings?.allow_auto_paste ?? true;
const defaultVoiceId = settings?.default_playback_voice_id ?? null; const defaultVoiceId = settings?.default_playback_voice_id ?? null;
const hotkeyEnabled = settings?.hotkey_enabled ?? false; const hotkeyEnabled = settings?.hotkey_enabled ?? false;
const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? ['MetaRight', 'AltGr']; const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? defaultChordKeys('push');
const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? ['MetaRight', 'AltGr', 'Space']; const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? defaultChordKeys('toggle');
// Mock-only settings — not yet wired to a backend. Keep local so the UI // Mock-only settings — not yet wired to a backend. Keep local so the UI
// still responds while Phase 7 (hotkey / clipboard / paste) catches up. // still responds while Phase 7 (hotkey / clipboard / paste) catches up.
+2 -2
View File
@@ -213,9 +213,9 @@ export interface CaptureSettings {
/** Whether the global keyboard hotkey is armed. Off by default — turning /** Whether the global keyboard hotkey is armed. Off by default — turning
* this on triggers the macOS Input Monitoring TCC prompt. */ * this on triggers the macOS Input Monitoring TCC prompt. */
hotkey_enabled: boolean; hotkey_enabled: boolean;
/** rdev::Key variant names. Defaults: ["MetaRight","AltGr"]. */ /** keytap key names. Defaults are platform-specific right-hand modifiers. */
chord_push_to_talk_keys: string[]; chord_push_to_talk_keys: string[];
/** rdev::Key variant names. Defaults: ["MetaRight","AltGr","Space"]. */ /** keytap key names. Toggle adds Space to the platform-specific PTT chord. */
chord_toggle_to_talk_keys: string[]; chord_toggle_to_talk_keys: string[];
} }
+12 -5
View File
@@ -1,13 +1,13 @@
/** /**
* Stable key-name vocabulary shared with the Rust `key_codes` module. * Stable key-name vocabulary shared with the Rust `key_codes` module.
* *
* The chord persistence layer stores rdev `Key` variant names ("MetaRight", * The chord persistence layer stores keytap `Key` variant names ("MetaRight",
* "AltGr", "KeyA", …) so the same array round-trips losslessly between * "AltGr", "KeyA", …) so the same array round-trips losslessly between
* the picker UI, the SQLite settings row, and the global hotkey listener. * the picker UI, the SQLite settings row, and the global hotkey listener.
* *
* This module owns the conversions between three vocabularies: * This module owns the conversions between three vocabularies:
* - browser `KeyboardEvent` (`event.code` like "MetaRight" / "AltRight") * - browser `KeyboardEvent` (`event.code` like "MetaRight" / "AltRight")
* - canonical chord key names (matches rdev variants) * - canonical chord key names (matches keytap variants)
* - human display labels ("⌘", "⌥", "A", …) * - human display labels ("⌘", "⌥", "A", …)
*/ */
@@ -16,8 +16,8 @@
* `null` for keys we don't support in chords (dead keys, IME composition, * `null` for keys we don't support in chords (dead keys, IME composition,
* etc.). * etc.).
* *
* Browser quirk: right-Option on macOS is reported as `"AltRight"`; rdev * Browser quirk: right-Option on macOS is reported as `"AltRight"`; keytap
* calls it `"AltGr"`. Normalize to rdev's name so the Rust side recognizes * calls it `"AltGr"`. Normalize to keytap's name so the Rust side recognizes
* it without an aliasing layer. * it without an aliasing layer.
*/ */
export function canonicalKeyFromEvent(event: KeyboardEvent): string | null { export function canonicalKeyFromEvent(event: KeyboardEvent): string | null {
@@ -53,7 +53,7 @@ export function canonicalKeyFromEvent(event: KeyboardEvent): string | null {
default: default:
// Browser names like "MetaRight", "MetaLeft", "ControlLeft", // Browser names like "MetaRight", "MetaLeft", "ControlLeft",
// "ShiftRight", "Space", "KeyA", "Digit1", "F5" all match the // "ShiftRight", "Space", "KeyA", "Digit1", "F5" all match the
// rdev variant names directly. // keytap variant names directly.
if ( if (
/^(Meta|Control|Shift)(Left|Right)$/.test(code) || /^(Meta|Control|Shift)(Left|Right)$/.test(code) ||
/^Key[A-Z]$/.test(code) || /^Key[A-Z]$/.test(code) ||
@@ -72,6 +72,13 @@ export function canonicalKeyFromEvent(event: KeyboardEvent): string | null {
const PLATFORM_IS_MAC = const PLATFORM_IS_MAC =
typeof navigator !== 'undefined' && /mac/i.test(navigator.platform); typeof navigator !== 'undefined' && /mac/i.test(navigator.platform);
export function defaultChordKeys(mode: 'push' | 'toggle'): string[] {
const base = PLATFORM_IS_MAC
? ['MetaRight', 'AltGr']
: ['ControlRight', 'ShiftRight'];
return mode === 'toggle' ? [...base, 'Space'] : base;
}
/** /**
* Pretty label for a canonical key name. Picks platform-appropriate * Pretty label for a canonical key name. Picks platform-appropriate
* modifier glyphs so macOS users see ⌘ and Windows/Linux users see Win. * modifier glyphs so macOS users see ⌘ and Windows/Linux users see Win.
+10 -2
View File
@@ -17,11 +17,17 @@ Adding a new migration:
(idempotent) and print a short message when it does real work. (idempotent) and print a short message when it does real work.
""" """
import json
import logging import logging
import sqlite3 import sqlite3
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from ..utils.capture_chords import (
default_push_to_talk_chord,
default_toggle_to_talk_chord,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -200,6 +206,8 @@ def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None:
if "capture_settings" not in tables: if "capture_settings" not in tables:
return return
columns = _get_columns(inspector, "capture_settings") columns = _get_columns(inspector, "capture_settings")
push_default = json.dumps(default_push_to_talk_chord())
toggle_default = json.dumps(default_toggle_to_talk_chord())
if "allow_auto_paste" not in columns: if "allow_auto_paste" not in columns:
_add_column( _add_column(
engine, engine,
@@ -218,14 +226,14 @@ def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None:
_add_column( _add_column(
engine, engine,
"capture_settings", "capture_settings",
"chord_push_to_talk_keys TEXT NOT NULL DEFAULT '[\"MetaRight\",\"AltGr\"]'", f"chord_push_to_talk_keys TEXT NOT NULL DEFAULT '{push_default}'",
"chord_push_to_talk_keys", "chord_push_to_talk_keys",
) )
if "chord_toggle_to_talk_keys" not in columns: if "chord_toggle_to_talk_keys" not in columns:
_add_column( _add_column(
engine, engine,
"capture_settings", "capture_settings",
"chord_toggle_to_talk_keys TEXT NOT NULL DEFAULT '[\"MetaRight\",\"AltGr\",\"Space\"]'", f"chord_toggle_to_talk_keys TEXT NOT NULL DEFAULT '{toggle_default}'",
"chord_toggle_to_talk_keys", "chord_toggle_to_talk_keys",
) )
if "hotkey_enabled" not in columns: if "hotkey_enabled" not in columns:
+9 -5
View File
@@ -6,6 +6,11 @@ import uuid
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from ..utils.capture_chords import (
default_push_to_talk_chord,
default_toggle_to_talk_chord,
)
Base = declarative_base() Base = declarative_base()
@@ -205,14 +210,13 @@ class CaptureSettings(Base):
# "Voicebox would like to receive keystrokes from any application" dialog # "Voicebox would like to receive keystrokes from any application" dialog
# before they've even opened the Captures tab. # before they've even opened the Captures tab.
hotkey_enabled = Column(Boolean, nullable=False, default=False) hotkey_enabled = Column(Boolean, nullable=False, default=False)
# Lists of rdev::Key variant names (e.g. "MetaRight", "AltGr"). Right-hand # Lists of keytap key names (e.g. "MetaRight", "ControlRight"). Right-hand
# modifiers by default so they don't collide with left-hand system # modifiers by default so they don't collide with left-hand shortcuts.
# shortcuts (Cmd+Opt+I devtools, Cmd+Opt+Esc force-quit).
chord_push_to_talk_keys = Column( chord_push_to_talk_keys = Column(
JSON, nullable=False, default=lambda: ["MetaRight", "AltGr"] JSON, nullable=False, default=default_push_to_talk_chord
) )
chord_toggle_to_talk_keys = Column( chord_toggle_to_talk_keys = Column(
JSON, nullable=False, default=lambda: ["MetaRight", "AltGr", "Space"] JSON, nullable=False, default=default_toggle_to_talk_chord
) )
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+9 -2
View File
@@ -6,6 +6,11 @@ from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime
from .utils.capture_chords import (
default_push_to_talk_chord,
default_toggle_to_talk_chord,
)
class VoiceProfileCreate(BaseModel): class VoiceProfileCreate(BaseModel):
"""Request model for creating a voice profile.""" """Request model for creating a voice profile."""
@@ -253,9 +258,11 @@ class CaptureSettingsResponse(BaseModel):
allow_auto_paste: bool = True allow_auto_paste: bool = True
default_playback_voice_id: Optional[str] = None default_playback_voice_id: Optional[str] = None
hotkey_enabled: bool = False hotkey_enabled: bool = False
chord_push_to_talk_keys: List[str] = Field(default_factory=lambda: ["MetaRight", "AltGr"]) chord_push_to_talk_keys: List[str] = Field(
default_factory=default_push_to_talk_chord
)
chord_toggle_to_talk_keys: List[str] = Field( chord_toggle_to_talk_keys: List[str] = Field(
default_factory=lambda: ["MetaRight", "AltGr", "Space"] default_factory=default_toggle_to_talk_chord
) )
class Config: class Config:
+9 -1
View File
@@ -13,6 +13,10 @@ from sqlalchemy.orm import Session
from ..database import CaptureSettings as DBCaptureSettings from ..database import CaptureSettings as DBCaptureSettings
from ..database import GenerationSettings as DBGenerationSettings from ..database import GenerationSettings as DBGenerationSettings
from ..utils.capture_chords import (
default_push_to_talk_chord,
default_toggle_to_talk_chord,
)
SINGLETON_ID = 1 SINGLETON_ID = 1
@@ -21,7 +25,11 @@ SINGLETON_ID = 1
def _get_or_create_capture_row(db: Session) -> DBCaptureSettings: def _get_or_create_capture_row(db: Session) -> DBCaptureSettings:
row = db.query(DBCaptureSettings).filter(DBCaptureSettings.id == SINGLETON_ID).first() row = db.query(DBCaptureSettings).filter(DBCaptureSettings.id == SINGLETON_ID).first()
if row is None: if row is None:
row = DBCaptureSettings(id=SINGLETON_ID) row = DBCaptureSettings(
id=SINGLETON_ID,
chord_push_to_talk_keys=default_push_to_talk_chord(),
chord_toggle_to_talk_keys=default_toggle_to_talk_chord(),
)
db.add(row) db.add(row)
db.commit() db.commit()
db.refresh(row) db.refresh(row)
+23
View File
@@ -0,0 +1,23 @@
"""Platform defaults for capture hotkey chords."""
from __future__ import annotations
import sys
MAC_PUSH_TO_TALK = ["MetaRight", "AltGr"]
MAC_TOGGLE_TO_TALK = ["MetaRight", "AltGr", "Space"]
NON_MAC_PUSH_TO_TALK = ["ControlRight", "ShiftRight"]
NON_MAC_TOGGLE_TO_TALK = ["ControlRight", "ShiftRight", "Space"]
def default_push_to_talk_chord() -> list[str]:
if sys.platform == "darwin":
return MAC_PUSH_TO_TALK.copy()
return NON_MAC_PUSH_TO_TALK.copy()
def default_toggle_to_talk_chord() -> list[str]:
if sys.platform == "darwin":
return MAC_TOGGLE_TO_TALK.copy()
return NON_MAC_TOGGLE_TO_TALK.copy()