From 2a1bb6f9367cf9fd53734eab45dc30d5c96d9bbb Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 25 Apr 2026 10:28:34 -0700 Subject: [PATCH] fix(captures): use platform hotkey defaults --- app/src/components/ServerTab/CapturesPage.tsx | 6 ++--- app/src/lib/api/types.ts | 4 ++-- app/src/lib/utils/keyCodes.ts | 17 ++++++++++---- backend/database/migrations.py | 12 ++++++++-- backend/database/models.py | 14 +++++++---- backend/models.py | 11 +++++++-- backend/services/settings.py | 10 +++++++- backend/utils/capture_chords.py | 23 +++++++++++++++++++ 8 files changed, 77 insertions(+), 20 deletions(-) create mode 100644 backend/utils/capture_chords.py diff --git a/app/src/components/ServerTab/CapturesPage.tsx b/app/src/components/ServerTab/CapturesPage.tsx index 9c903ee9..cd26a866 100644 --- a/app/src/components/ServerTab/CapturesPage.tsx +++ b/app/src/components/ServerTab/CapturesPage.tsx @@ -30,7 +30,7 @@ import { useProfiles } from '@/lib/hooks/useProfiles'; import { usePlatform } from '@/platform/PlatformContext'; import { useServerStore } from '@/stores/serverStore'; 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 { SettingRow, SettingSection } from './SettingRow'; @@ -135,8 +135,8 @@ export function CapturesPage() { const allowAutoPaste = settings?.allow_auto_paste ?? true; const defaultVoiceId = settings?.default_playback_voice_id ?? null; const hotkeyEnabled = settings?.hotkey_enabled ?? false; - const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? ['MetaRight', 'AltGr']; - const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? ['MetaRight', 'AltGr', 'Space']; + const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? defaultChordKeys('push'); + const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? defaultChordKeys('toggle'); // Mock-only settings — not yet wired to a backend. Keep local so the UI // still responds while Phase 7 (hotkey / clipboard / paste) catches up. diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts index dfdb6790..37ca4667 100644 --- a/app/src/lib/api/types.ts +++ b/app/src/lib/api/types.ts @@ -213,9 +213,9 @@ export interface CaptureSettings { /** Whether the global keyboard hotkey is armed. Off by default — turning * this on triggers the macOS Input Monitoring TCC prompt. */ 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[]; - /** 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[]; } diff --git a/app/src/lib/utils/keyCodes.ts b/app/src/lib/utils/keyCodes.ts index 1a74e16b..03d2f1e1 100644 --- a/app/src/lib/utils/keyCodes.ts +++ b/app/src/lib/utils/keyCodes.ts @@ -1,13 +1,13 @@ /** * 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 * the picker UI, the SQLite settings row, and the global hotkey listener. * * This module owns the conversions between three vocabularies: * - 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", …) */ @@ -16,8 +16,8 @@ * `null` for keys we don't support in chords (dead keys, IME composition, * etc.). * - * Browser quirk: right-Option on macOS is reported as `"AltRight"`; rdev - * calls it `"AltGr"`. Normalize to rdev's name so the Rust side recognizes + * Browser quirk: right-Option on macOS is reported as `"AltRight"`; keytap + * calls it `"AltGr"`. Normalize to keytap's name so the Rust side recognizes * it without an aliasing layer. */ export function canonicalKeyFromEvent(event: KeyboardEvent): string | null { @@ -53,7 +53,7 @@ export function canonicalKeyFromEvent(event: KeyboardEvent): string | null { default: // Browser names like "MetaRight", "MetaLeft", "ControlLeft", // "ShiftRight", "Space", "KeyA", "Digit1", "F5" all match the - // rdev variant names directly. + // keytap variant names directly. if ( /^(Meta|Control|Shift)(Left|Right)$/.test(code) || /^Key[A-Z]$/.test(code) || @@ -72,6 +72,13 @@ export function canonicalKeyFromEvent(event: KeyboardEvent): string | null { const PLATFORM_IS_MAC = 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 * modifier glyphs so macOS users see ⌘ and Windows/Linux users see Win. diff --git a/backend/database/migrations.py b/backend/database/migrations.py index 9636b976..d353b58c 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -17,11 +17,17 @@ Adding a new migration: (idempotent) and print a short message when it does real work. """ +import json import logging import sqlite3 from sqlalchemy import inspect, text +from ..utils.capture_chords import ( + default_push_to_talk_chord, + default_toggle_to_talk_chord, +) + logger = logging.getLogger(__name__) @@ -200,6 +206,8 @@ def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None: if "capture_settings" not in tables: return 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: _add_column( engine, @@ -218,14 +226,14 @@ def _migrate_capture_settings(engine, inspector, tables: set[str]) -> None: _add_column( engine, "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", ) if "chord_toggle_to_talk_keys" not in columns: _add_column( engine, "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", ) if "hotkey_enabled" not in columns: diff --git a/backend/database/models.py b/backend/database/models.py index 5d78d950..6ef2213e 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -6,6 +6,11 @@ import uuid from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean, JSON 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() @@ -205,14 +210,13 @@ class CaptureSettings(Base): # "Voicebox would like to receive keystrokes from any application" dialog # before they've even opened the Captures tab. hotkey_enabled = Column(Boolean, nullable=False, default=False) - # Lists of rdev::Key variant names (e.g. "MetaRight", "AltGr"). Right-hand - # modifiers by default so they don't collide with left-hand system - # shortcuts (Cmd+Opt+I devtools, Cmd+Opt+Esc force-quit). + # Lists of keytap key names (e.g. "MetaRight", "ControlRight"). Right-hand + # modifiers by default so they don't collide with left-hand shortcuts. 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( - 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) diff --git a/backend/models.py b/backend/models.py index f760bd1a..06f321ac 100644 --- a/backend/models.py +++ b/backend/models.py @@ -6,6 +6,11 @@ from pydantic import BaseModel, Field from typing import Optional, List from datetime import datetime +from .utils.capture_chords import ( + default_push_to_talk_chord, + default_toggle_to_talk_chord, +) + class VoiceProfileCreate(BaseModel): """Request model for creating a voice profile.""" @@ -253,9 +258,11 @@ class CaptureSettingsResponse(BaseModel): allow_auto_paste: bool = True default_playback_voice_id: Optional[str] = None 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( - default_factory=lambda: ["MetaRight", "AltGr", "Space"] + default_factory=default_toggle_to_talk_chord ) class Config: diff --git a/backend/services/settings.py b/backend/services/settings.py index bf938ba2..31e3bf96 100644 --- a/backend/services/settings.py +++ b/backend/services/settings.py @@ -13,6 +13,10 @@ from sqlalchemy.orm import Session from ..database import CaptureSettings as DBCaptureSettings from ..database import GenerationSettings as DBGenerationSettings +from ..utils.capture_chords import ( + default_push_to_talk_chord, + default_toggle_to_talk_chord, +) SINGLETON_ID = 1 @@ -21,7 +25,11 @@ SINGLETON_ID = 1 def _get_or_create_capture_row(db: Session) -> DBCaptureSettings: row = db.query(DBCaptureSettings).filter(DBCaptureSettings.id == SINGLETON_ID).first() 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.commit() db.refresh(row) diff --git a/backend/utils/capture_chords.py b/backend/utils/capture_chords.py new file mode 100644 index 00000000..7092f6c6 --- /dev/null +++ b/backend/utils/capture_chords.py @@ -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()