From 9673640b4215070b1acc919433953f04c0b52dcc Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Sun, 25 Oct 2026 20:48:18 +0200 Subject: [PATCH 1/3] ci: run frontend unit tests in the macOS job --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa451c1..4686177 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,10 @@ jobs: run: npm ci working-directory: frontend + - name: Test frontend + run: npm test + working-directory: frontend + - name: Build frontend run: npm run build working-directory: frontend From 887b3adca4d5316e0eb926137e80f4c2ac09be11 Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Sun, 25 Oct 2026 20:48:18 +0200 Subject: [PATCH 2/3] fix(hotkey): show platform-specific hotkey labels in recording UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-app and native recording overlays hard-coded "Stop ⌥" and "Cancel esc", so Windows users saw macOS symbols and nobody saw their configured keys. - frontend/src/hotkeyLabels.ts formats key names per platform; App uses it for settings, the Home badge and the RecordingOverlay labels. - hotkey.ShortLabel is the Go equivalent. Both are tested against the shared table in internal/hotkey/testdata/short_labels.json. - overlay.SetHotkeyLabels updates the native overlays on macOS and Windows (no-op elsewhere). app.go refreshes it from config at startup, on re-registration and after each hotkey change, under a lock so concurrent changes cannot leave a stale label. - Native buttons grow to fit their captions; Windows hit testing uses the same rectangles that are drawn, and the Windows overlay test now clicks the labelled layout. --- app.go | 25 +++++- frontend/src/App.tsx | 61 ++------------ frontend/src/RecordingOverlay.test.tsx | 76 +++++++++++++++++ frontend/src/RecordingOverlay.tsx | 10 ++- frontend/src/hotkeyLabels.test.ts | 51 ++++++++++++ frontend/src/hotkeyLabels.ts | 94 ++++++++++++++++++++++ internal/hotkey/labels.go | 70 ++++++++++++++++ internal/hotkey/labels_test.go | 30 +++++++ internal/hotkey/testdata/short_labels.json | 36 +++++++++ internal/overlay/appearance.go | 33 ++++++-- internal/overlay/appearance_test.go | 82 ++++++++++++++++++- internal/overlay/overlay_darwin.go | 89 +++++++++++++++----- internal/overlay/overlay_other.go | 3 + internal/overlay/overlay_windows.go | 23 ++++-- internal/overlay/overlay_windows_test.go | 57 +++++++++---- 15 files changed, 633 insertions(+), 107 deletions(-) create mode 100644 frontend/src/RecordingOverlay.test.tsx create mode 100644 frontend/src/hotkeyLabels.test.ts create mode 100644 frontend/src/hotkeyLabels.ts create mode 100644 internal/hotkey/labels.go create mode 100644 internal/hotkey/labels_test.go create mode 100644 internal/hotkey/testdata/short_labels.json diff --git a/app.go b/app.go index abc846d..c99af37 100644 --- a/app.go +++ b/app.go @@ -100,6 +100,8 @@ type App struct { statsManager *models.StatsManager hotkeyManager *hotkey.Manager overlay *overlay.Overlay + // Serializes native overlay label refreshes. + overlayLabelsMu sync.Mutex mu sync.Mutex state RecordingState @@ -226,6 +228,7 @@ func (a *App) startup(ctx context.Context) { cancelKey = "escape" } a.hotkeyManager.SetCancelKey(cancelKey) + a.refreshOverlayHotkeyLabels() // Register global hotkey if err := a.hotkeyManager.Register(func() { @@ -1034,7 +1037,9 @@ func (a *App) SetRecordingHotkey(keyName string) error { } // Save to config - return a.configManager.SetRecordingHotkey(keyName) + err := a.configManager.SetRecordingHotkey(keyName) + a.refreshOverlayHotkeyLabels() + return err } // GetRecordingHotkey returns the current recording hotkey string @@ -1063,7 +1068,22 @@ func (a *App) SetCancelHotkey(keyName string) error { } // Save to config - return a.configManager.SetCancelHotkey(keyName) + err := a.configManager.SetCancelHotkey(keyName) + a.refreshOverlayHotkeyLabels() + return err +} + +// refreshOverlayHotkeyLabels shows the configured keys on the native +// overlay's Stop and Cancel buttons. The config keeps a change in memory even +// if saving fails, so it matches the hotkey manager. Reading and pushing under +// one lock means concurrent hotkey changes cannot leave a stale label behind. +func (a *App) refreshOverlayHotkeyLabels() { + if a.overlay == nil { + return + } + a.overlayLabelsMu.Lock() + defer a.overlayLabelsMu.Unlock() + a.overlay.SetHotkeyLabels(hotkey.ShortLabel(a.GetRecordingHotkey()), hotkey.ShortLabel(a.GetCancelHotkey())) } // GetCancelHotkey returns the current cancel hotkey string @@ -1137,6 +1157,7 @@ func (a *App) ReregisterHotkey() error { cancelKey = "escape" } a.hotkeyManager.SetCancelKey(cancelKey) + a.refreshOverlayHotkeyLabels() // Register the hotkey if err := a.hotkeyManager.Register(func() { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e0d1be..3b8de6f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import './App.css'; import { RecordingOverlay } from './RecordingOverlay'; +import { formatHotkeyLabel, formatHotkeyShortLabel } from './hotkeyLabels'; import { Onboarding } from './Onboarding'; import { ModelsLibrary } from './ModelsLibrary'; import { useModelDownload } from './useModelDownload'; @@ -603,62 +604,8 @@ function App() { return () => document.removeEventListener('mousedown', handleClickOutside); }, [isCapturingHotkey, isCapturingCancelKey]); - // Format key name for display - const formatKeyDisplay = (keyName: string): string => { - const k = keyName.toLowerCase(); - switch (k) { - case 'rightoption': case 'rightalt': return platform === 'darwin' ? 'Right ⌥' : 'Right Alt'; - case 'leftoption': case 'leftalt': return platform === 'darwin' ? 'Left ⌥' : 'Left Alt'; - case 'rightcommand': case 'rightcmd': return platform === 'darwin' ? 'Right ⌘' : 'Right Win'; - case 'leftcommand': case 'leftcmd': return platform === 'darwin' ? 'Left ⌘' : 'Left Win'; - case 'rightctrl': case 'rightcontrol': return 'Right Ctrl'; - case 'leftctrl': case 'leftcontrol': return 'Left Ctrl'; - case 'rightshift': return 'Right Shift'; - case 'leftshift': return 'Left Shift'; - case 'rightwin': case 'rightsuper': return 'Right Win'; - case 'leftwin': case 'leftsuper': return 'Left Win'; - case 'capslock': return 'Caps Lock'; - case 'fn': case 'function': return 'Fn'; - case 'escape': case 'esc': return 'Escape'; - case 'space': return 'Space'; - case 'tab': return 'Tab'; - case 'return': case 'enter': return 'Return'; - case 'backspace': return 'Backspace'; - case 'delete': return 'Delete'; - case 'left': case 'arrowleft': return '←'; - case 'right': case 'arrowright': return '→'; - case 'up': case 'arrowup': return '↑'; - case 'down': case 'arrowdown': return '↓'; - default: - if (k.match(/^f\d+$/)) return k.toUpperCase(); - return k.toUpperCase(); - } - }; - - // Get simple hotkey display for keyboard shortcut badge - const getHotkeyShortDisplay = (keyName: string): string => { - const k = keyName.toLowerCase(); - switch (k) { - case 'rightoption': case 'leftoption': case 'rightalt': case 'leftalt': return '⌥'; - case 'rightcommand': case 'leftcommand': case 'rightcmd': case 'leftcmd': return '⌘'; - case 'rightctrl': case 'leftctrl': case 'rightcontrol': case 'leftcontrol': return '⌃'; - case 'rightshift': case 'leftshift': return '⇧'; - case 'rightwin': case 'leftwin': case 'rightsuper': case 'leftsuper': return '⊞'; - case 'capslock': return '⇪'; - case 'fn': case 'function': return 'Fn'; - case 'escape': case 'esc': return 'Esc'; - case 'space': return '␣'; - case 'tab': return '⇥'; - case 'return': case 'enter': return '↵'; - case 'backspace': return '⌫'; - case 'delete': return '⌦'; - case 'left': case 'arrowleft': return '←'; - case 'right': case 'arrowright': return '→'; - case 'up': case 'arrowup': return '↑'; - case 'down': case 'arrowdown': return '↓'; - default: return k.toUpperCase(); - } - }; + const formatKeyDisplay = (keyName: string): string => formatHotkeyLabel(keyName, platform); + const getHotkeyShortDisplay = (keyName: string): string => formatHotkeyShortLabel(keyName, platform); const formatTime = (seconds: number): string => { const mins = Math.floor(seconds / 60); @@ -1363,6 +1310,8 @@ function App() { isRecording={appState.state === 'recording'} onStop={handleToggleRecording} onCancel={handleCancelRecording} + stopKeyLabel={getHotkeyShortDisplay(currentHotkey)} + cancelKeyLabel={getHotkeyShortDisplay(cancelHotkey)} /> ); diff --git a/frontend/src/RecordingOverlay.test.tsx b/frontend/src/RecordingOverlay.test.tsx new file mode 100644 index 0000000..feba431 --- /dev/null +++ b/frontend/src/RecordingOverlay.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { RecordingOverlay } from './RecordingOverlay'; +import { formatHotkeyShortLabel } from './hotkeyLabels'; + +beforeAll(() => { + // jsdom has no canvas; the overlay skips drawing when there is no context. + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); +}); + +function renderOverlay(stopKeyLabel: string, cancelKeyLabel: string) { + const onStop = vi.fn(); + const onCancel = vi.fn(); + render( + , + ); + return { + onStop, + onCancel, + stop: screen.getByRole('button', { name: /^Stop/ }), + cancel: screen.getByRole('button', { name: /^Cancel/ }), + }; +} + +describe('RecordingOverlay', () => { + it('shows the configured Windows hotkeys instead of macOS symbols', () => { + const { stop, cancel } = renderOverlay( + formatHotkeyShortLabel('rightalt', 'windows'), + formatHotkeyShortLabel('escape', 'windows'), + ); + expect(stop.querySelector('kbd')).toHaveTextContent(/^Alt$/); + expect(cancel.querySelector('kbd')).toHaveTextContent(/^Esc$/); + expect(document.body).not.toHaveTextContent('⌥'); + }); + + it('shows Right Command as ⌘ on macOS', () => { + const { stop } = renderOverlay( + formatHotkeyShortLabel('rightcommand', 'darwin'), + formatHotkeyShortLabel('escape', 'darwin'), + ); + expect(stop.querySelector('kbd')).toHaveTextContent(/^⌘$/); + expect(document.body).not.toHaveTextContent('⌥'); + }); + + it('shows function-key hotkeys', () => { + const { stop, cancel } = renderOverlay('F9', 'F10'); + expect(stop).toHaveTextContent('StopF9'); + expect(cancel).toHaveTextContent('CancelF10'); + }); + + it('omits the key badge when no label is known', () => { + const { stop } = renderOverlay('', 'Esc'); + expect(stop.querySelector('kbd')).toBeNull(); + }); + + it('still wires the Stop and Cancel buttons', () => { + const { stop, cancel, onStop, onCancel } = renderOverlay('Alt', 'Esc'); + fireEvent.click(stop); + fireEvent.click(cancel); + expect(onStop).toHaveBeenCalledOnce(); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('renders nothing when not recording', () => { + const { container } = render( + {}} onCancel={() => {}} stopKeyLabel="Alt" cancelKeyLabel="Esc" />, + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/src/RecordingOverlay.tsx b/frontend/src/RecordingOverlay.tsx index 7fe6ec5..5d91f3e 100644 --- a/frontend/src/RecordingOverlay.tsx +++ b/frontend/src/RecordingOverlay.tsx @@ -6,9 +6,13 @@ interface RecordingOverlayProps { isRecording: boolean; onStop: () => void; onCancel: () => void; + /** Compact label of the recording hotkey, e.g. "⌥" or "Alt". */ + stopKeyLabel: string; + /** Compact label of the cancel hotkey, e.g. "Esc". */ + cancelKeyLabel: string; } -export function RecordingOverlay({ isRecording, onStop, onCancel }: RecordingOverlayProps) { +export function RecordingOverlay({ isRecording, onStop, onCancel, stopKeyLabel, cancelKeyLabel }: RecordingOverlayProps) { const canvasRef = useRef(null); const [waveformData, setWaveformData] = useState([]); @@ -106,11 +110,11 @@ export function RecordingOverlay({ isRecording, onStop, onCancel }: RecordingOve
diff --git a/frontend/src/hotkeyLabels.test.ts b/frontend/src/hotkeyLabels.test.ts new file mode 100644 index 0000000..7d0f667 --- /dev/null +++ b/frontend/src/hotkeyLabels.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { formatHotkeyLabel, formatHotkeyShortLabel } from './hotkeyLabels'; +// Shared with internal/hotkey/labels_test.go so the native overlays match. +import shortLabelCases from '../../internal/hotkey/testdata/short_labels.json'; + +type Platform = 'darwin' | 'windows' | 'linux'; + +describe('formatHotkeyShortLabel', () => { + it('has shared label cases', () => { + expect(shortLabelCases.length).toBeGreaterThan(0); + }); + + for (const platform of ['darwin', 'windows', 'linux'] as Platform[]) { + it(`matches the shared table on ${platform}`, () => { + for (const c of shortLabelCases) { + expect([c.key, formatHotkeyShortLabel(c.key, platform)]).toEqual([c.key, c[platform]]); + } + }); + } + + it('never shows macOS symbols on Windows', () => { + for (const c of shortLabelCases) { + expect(formatHotkeyShortLabel(c.key, 'windows')).not.toMatch(/[⌥⌘⌃⇧⇪⇥↩⌫⌦]/); + } + }); +}); + +describe('formatHotkeyLabel', () => { + it.each([ + ['rightoption', 'darwin', 'Right ⌥'], + ['rightalt', 'windows', 'Right Alt'], + ['leftalt', 'linux', 'Left Alt'], + ['rightcommand', 'darwin', 'Right ⌘'], + ['rightwin', 'windows', 'Right Win'], + ['rightsuper', 'linux', 'Right Super'], + ['leftctrl', 'darwin', 'Left ⌃'], + ['leftctrl', 'windows', 'Left Ctrl'], + ['rightshift', 'windows', 'Right Shift'], + ['capslock', 'windows', 'Caps Lock'], + ['fn', 'darwin', 'Fn'], + ['escape', 'windows', 'Escape'], + ['return', 'darwin', 'Return'], + ['return', 'windows', 'Enter'], + ['space', 'linux', 'Space'], + ['arrowup', 'windows', '↑'], + ['f9', 'windows', 'F9'], + ['q', 'darwin', 'Q'], + ])('%s on %s is %s', (key, platform, want) => { + expect(formatHotkeyLabel(key, platform)).toBe(want); + }); +}); diff --git a/frontend/src/hotkeyLabels.ts b/frontend/src/hotkeyLabels.ts new file mode 100644 index 0000000..79f5de3 --- /dev/null +++ b/frontend/src/hotkeyLabels.ts @@ -0,0 +1,94 @@ +// Display labels for configured hotkey names (e.g. "rightoption", "f9"). +// Keep the compact labels in sync with internal/hotkey/labels.go. + +type Side = 'Left' | 'Right'; + +interface ModifierLabels { + mac: string; + windows: string; + linux: string; +} + +const modifiers: Record = { + alt: { mac: '⌥', windows: 'Alt', linux: 'Alt' }, + meta: { mac: '⌘', windows: 'Win', linux: 'Super' }, + ctrl: { mac: '⌃', windows: 'Ctrl', linux: 'Ctrl' }, + shift: { mac: '⇧', windows: 'Shift', linux: 'Shift' }, +}; + +const modifierAliases: Record = { + option: 'alt', + alt: 'alt', + command: 'meta', + cmd: 'meta', + win: 'meta', + super: 'meta', + control: 'ctrl', + ctrl: 'ctrl', + shift: 'shift', +}; + +function parseModifier(key: string): { side: Side; modifier: ModifierLabels } | null { + const match = key.match(/^(left|right)(.+)$/); + if (!match) return null; + const name = modifierAliases[match[2]]; + if (!name) return null; + return { side: match[1] === 'left' ? 'Left' : 'Right', modifier: modifiers[name] }; +} + +function modifierLabel(modifier: ModifierLabels, platform: string): string { + if (platform === 'darwin') return modifier.mac; + if (platform === 'linux') return modifier.linux; + return modifier.windows; +} + +function arrowLabel(key: string): string | null { + switch (key) { + case 'left': case 'arrowleft': return '←'; + case 'right': case 'arrowright': return '→'; + case 'up': case 'arrowup': return '↑'; + case 'down': case 'arrowdown': return '↓'; + default: return null; + } +} + +/** Compact label for small badges, e.g. "⌥" on macOS or "Alt" on Windows. */ +export function formatHotkeyShortLabel(keyName: string, platform: string): string { + const k = keyName.trim().toLowerCase(); + const mac = platform === 'darwin'; + const modifier = parseModifier(k); + if (modifier) return modifierLabel(modifier.modifier, platform); + switch (k) { + case '': return ''; + case 'capslock': return mac ? '⇪' : 'Caps'; + case 'fn': case 'function': return mac ? 'fn' : 'Fn'; + case 'escape': case 'esc': return 'Esc'; + case 'space': return 'Space'; + case 'tab': return mac ? '⇥' : 'Tab'; + case 'return': case 'enter': return mac ? '↩' : 'Enter'; + case 'backspace': return mac ? '⌫' : 'Bksp'; + case 'delete': return mac ? '⌫' : 'Del'; + case 'forwarddelete': return mac ? '⌦' : 'Del'; + default: return arrowLabel(k) ?? k.toUpperCase(); + } +} + +/** Descriptive label for settings, e.g. "Right ⌥" on macOS or "Right Alt" on Windows. */ +export function formatHotkeyLabel(keyName: string, platform: string): string { + const k = keyName.trim().toLowerCase(); + const mac = platform === 'darwin'; + const modifier = parseModifier(k); + if (modifier) return `${modifier.side} ${modifierLabel(modifier.modifier, platform)}`; + switch (k) { + case 'capslock': return 'Caps Lock'; + case 'fn': case 'function': return 'Fn'; + case 'escape': case 'esc': return 'Escape'; + case 'space': return 'Space'; + case 'tab': return 'Tab'; + case 'return': case 'enter': return mac ? 'Return' : 'Enter'; + case 'backspace': return 'Backspace'; + case 'delete': return 'Delete'; + case 'forwarddelete': return 'Forward Delete'; + default: return arrowLabel(k) ?? k.toUpperCase(); + } +} diff --git a/internal/hotkey/labels.go b/internal/hotkey/labels.go new file mode 100644 index 0000000..e997a1a --- /dev/null +++ b/internal/hotkey/labels.go @@ -0,0 +1,70 @@ +package hotkey + +import ( + "runtime" + "strings" +) + +// ShortLabel returns a compact label for a configured key name on the +// current platform, suitable for small badges such as "Stop ⌥". +func ShortLabel(name string) string { + return ShortLabelFor(name, runtime.GOOS) +} + +// ShortLabelFor returns a compact key label for the given GOOS value. macOS +// uses its modifier symbols; other platforms use short key names. +// Keep in sync with frontend/src/hotkeyLabels.ts. +func ShortLabelFor(name, goos string) string { + k := strings.ToLower(strings.TrimSpace(name)) + mac := goos == "darwin" + pick := func(macLabel, pcLabel string) string { + if mac { + return macLabel + } + return pcLabel + } + switch k { + case "": + return "" + case "rightoption", "leftoption", "rightalt", "leftalt": + return pick("⌥", "Alt") + case "rightcommand", "leftcommand", "rightcmd", "leftcmd", + "rightwin", "leftwin", "rightsuper", "leftsuper": + if goos == "linux" { + return "Super" + } + return pick("⌘", "Win") + case "rightcontrol", "leftcontrol", "rightctrl", "leftctrl": + return pick("⌃", "Ctrl") + case "rightshift", "leftshift": + return pick("⇧", "Shift") + case "capslock": + return pick("⇪", "Caps") + case "fn", "function": + return pick("fn", "Fn") + case "escape", "esc": + return "Esc" + case "space": + return "Space" + case "tab": + return pick("⇥", "Tab") + case "return", "enter": + return pick("↩", "Enter") + case "backspace": + return pick("⌫", "Bksp") + case "delete": + return pick("⌫", "Del") + case "forwarddelete": + return pick("⌦", "Del") + case "left", "arrowleft": + return "←" + case "right", "arrowright": + return "→" + case "up", "arrowup": + return "↑" + case "down", "arrowdown": + return "↓" + default: + return strings.ToUpper(k) + } +} diff --git a/internal/hotkey/labels_test.go b/internal/hotkey/labels_test.go new file mode 100644 index 0000000..93ee4bd --- /dev/null +++ b/internal/hotkey/labels_test.go @@ -0,0 +1,30 @@ +package hotkey + +import ( + "encoding/json" + "os" + "testing" +) + +// testdata/short_labels.json is shared with frontend/src/hotkeyLabels.test.ts +// so the native and in-app overlays show the same labels. +func TestShortLabelFor(t *testing.T) { + data, err := os.ReadFile("testdata/short_labels.json") + if err != nil { + t.Fatal(err) + } + var cases []map[string]string + if err := json.Unmarshal(data, &cases); err != nil { + t.Fatal(err) + } + if len(cases) == 0 { + t.Fatal("no label cases") + } + for _, c := range cases { + for _, goos := range []string{"darwin", "windows", "linux"} { + if got := ShortLabelFor(c["key"], goos); got != c[goos] { + t.Errorf("ShortLabelFor(%q, %q) = %q, want %q", c["key"], goos, got, c[goos]) + } + } + } +} diff --git a/internal/hotkey/testdata/short_labels.json b/internal/hotkey/testdata/short_labels.json new file mode 100644 index 0000000..28db54d --- /dev/null +++ b/internal/hotkey/testdata/short_labels.json @@ -0,0 +1,36 @@ +[ + { "key": "rightoption", "darwin": "⌥", "windows": "Alt", "linux": "Alt" }, + { "key": "leftoption", "darwin": "⌥", "windows": "Alt", "linux": "Alt" }, + { "key": "rightalt", "darwin": "⌥", "windows": "Alt", "linux": "Alt" }, + { "key": "LeftAlt", "darwin": "⌥", "windows": "Alt", "linux": "Alt" }, + { "key": "rightcommand", "darwin": "⌘", "windows": "Win", "linux": "Super" }, + { "key": "leftcmd", "darwin": "⌘", "windows": "Win", "linux": "Super" }, + { "key": "rightwin", "darwin": "⌘", "windows": "Win", "linux": "Super" }, + { "key": "leftsuper", "darwin": "⌘", "windows": "Win", "linux": "Super" }, + { "key": "rightctrl", "darwin": "⌃", "windows": "Ctrl", "linux": "Ctrl" }, + { "key": "leftcontrol", "darwin": "⌃", "windows": "Ctrl", "linux": "Ctrl" }, + { "key": "rightshift", "darwin": "⇧", "windows": "Shift", "linux": "Shift" }, + { "key": "leftshift", "darwin": "⇧", "windows": "Shift", "linux": "Shift" }, + { "key": "capslock", "darwin": "⇪", "windows": "Caps", "linux": "Caps" }, + { "key": "fn", "darwin": "fn", "windows": "Fn", "linux": "Fn" }, + { "key": "escape", "darwin": "Esc", "windows": "Esc", "linux": "Esc" }, + { "key": "esc", "darwin": "Esc", "windows": "Esc", "linux": "Esc" }, + { "key": "space", "darwin": "Space", "windows": "Space", "linux": "Space" }, + { "key": "tab", "darwin": "⇥", "windows": "Tab", "linux": "Tab" }, + { "key": "return", "darwin": "↩", "windows": "Enter", "linux": "Enter" }, + { "key": "enter", "darwin": "↩", "windows": "Enter", "linux": "Enter" }, + { "key": "backspace", "darwin": "⌫", "windows": "Bksp", "linux": "Bksp" }, + { "key": "delete", "darwin": "⌫", "windows": "Del", "linux": "Del" }, + { "key": "forwarddelete", "darwin": "⌦", "windows": "Del", "linux": "Del" }, + { "key": "left", "darwin": "←", "windows": "←", "linux": "←" }, + { "key": "arrowright", "darwin": "→", "windows": "→", "linux": "→" }, + { "key": "up", "darwin": "↑", "windows": "↑", "linux": "↑" }, + { "key": "arrowdown", "darwin": "↓", "windows": "↓", "linux": "↓" }, + { "key": "f1", "darwin": "F1", "windows": "F1", "linux": "F1" }, + { "key": "f9", "darwin": "F9", "windows": "F9", "linux": "F9" }, + { "key": "f12", "darwin": "F12", "windows": "F12", "linux": "F12" }, + { "key": "a", "darwin": "A", "windows": "A", "linux": "A" }, + { "key": "z", "darwin": "Z", "windows": "Z", "linux": "Z" }, + { "key": "7", "darwin": "7", "windows": "7", "linux": "7" }, + { "key": " ", "darwin": "", "windows": "", "linux": "" } +] diff --git a/internal/overlay/appearance.go b/internal/overlay/appearance.go index 3a2caab..cf2149a 100644 --- a/internal/overlay/appearance.go +++ b/internal/overlay/appearance.go @@ -16,8 +16,17 @@ import ( const overlayWidth, overlayHeight = 400, 124 const waveformBars = 56 -var stopBounds = image.Rect(220, 68, 290, 96) -var cancelBounds = image.Rect(300, 68, 375, 96) +// Buttons are right-aligned and never narrower than the original fixed layout. +const buttonTop, buttonBottom, buttonRight = 68, 96, 375 +const stopMinWidth, cancelMinWidth, buttonGap, buttonPadding = 70, 75, 10, 10 + +// buttonText returns a button caption such as "Stop ⌥" or "Cancel Esc". +func buttonText(action, keyLabel string) string { + if keyLabel == "" { + return action + } + return action + " " + keyLabel +} type overlayRenderer struct { scale float64 @@ -78,7 +87,20 @@ func (r *overlayRenderer) round(dst *image.RGBA, left, top, right, bottom, radiu } } -func (r *overlayRenderer) render(status string, history [waveformBars]float32, hover int) *image.RGBA { +// buttons sizes the Stop and Cancel buttons to fit their captions, in +// device-independent pixels. +func (r *overlayRenderer) buttons(stopText, cancelText string) (stop, cancel image.Rectangle) { + width := func(text string, minWidth int) int { + w := int(math.Ceil(float64(font.MeasureString(r.face, text))/64/r.scale)) + 2*buttonPadding + return max(minWidth, w) + } + cancel = image.Rect(buttonRight-width(cancelText, cancelMinWidth), buttonTop, buttonRight, buttonBottom) + stopRight := cancel.Min.X - buttonGap + stop = image.Rect(stopRight-width(stopText, stopMinWidth), buttonTop, stopRight, buttonBottom) + return stop, cancel +} + +func (r *overlayRenderer) render(status, stopText, cancelText string, history [waveformBars]float32, hover int) *image.RGBA { dst := image.NewRGBA(r.background.Bounds()) copy(dst.Pix, r.background.Pix) for i, level := range history { @@ -98,6 +120,7 @@ func (r *overlayRenderer) render(status string, history [waveformBars]float32, h } r.text(dst, status, image.Rect(31, 68, 215, 96), color.NRGBA{180, 180, 180, 255}, false) if status == "Recording…" { + stopBounds, cancelBounds := r.buttons(stopText, cancelText) for i, b := range []image.Rectangle{stopBounds, cancelBounds} { shade := uint8(40) if i == 0 { @@ -108,8 +131,8 @@ func (r *overlayRenderer) render(status string, history [waveformBars]float32, h } r.round(dst, float64(b.Min.X), float64(b.Min.Y), float64(b.Max.X), float64(b.Max.Y), 7, color.NRGBA{shade, shade, shade, 255}) } - r.text(dst, "Stop", stopBounds, color.NRGBA{245, 245, 245, 255}, true) - r.text(dst, "Cancel esc", cancelBounds, color.NRGBA{160, 160, 160, 255}, true) + r.text(dst, stopText, stopBounds, color.NRGBA{245, 245, 245, 255}, true) + r.text(dst, cancelText, cancelBounds, color.NRGBA{160, 160, 160, 255}, true) } return dst } diff --git a/internal/overlay/appearance_test.go b/internal/overlay/appearance_test.go index ce2310e..9649f08 100644 --- a/internal/overlay/appearance_test.go +++ b/internal/overlay/appearance_test.go @@ -2,10 +2,13 @@ package overlay import ( "fmt" + "image" "image/png" "os" "path/filepath" "testing" + + "golang.org/x/image/font" ) func TestOverlayAppearanceAtDisplayScales(t *testing.T) { @@ -22,7 +25,7 @@ func TestOverlayAppearanceAtDisplayScales(t *testing.T) { history[i] = float32(i%15) / 20 } for _, status := range []string{"Recording...", "Transcribing..."} { - frame := r.render(status, history, 0) + frame := r.render(status, "Stop Alt", "Cancel Esc", history, 0) if frame.RGBAAt(0, 0).A != 0 { t.Fatal("outer corner is not transparent") } @@ -59,3 +62,80 @@ func TestOverlayAppearanceAtDisplayScales(t *testing.T) { }) } } + +func TestButtonText(t *testing.T) { + for _, c := range []struct{ action, key, want string }{ + {"Stop", "Alt", "Stop Alt"}, + {"Stop", "⌘", "Stop ⌘"}, + {"Cancel", "Esc", "Cancel Esc"}, + {"Stop", "", "Stop"}, + } { + if got := buttonText(c.action, c.key); got != c.want { + t.Errorf("buttonText(%q, %q) = %q, want %q", c.action, c.key, got, c.want) + } + } +} + +func TestOverlayButtonsFitHotkeyLabels(t *testing.T) { + fontData, _ := os.ReadFile(filepath.Join(os.Getenv("WINDIR"), "Fonts", "seguisb.ttf")) + for _, scale := range []float64{1, 1.5, 2} { + t.Run(fmt.Sprint(scale), func(t *testing.T) { + r, err := newOverlayRenderer(scale, fontData) + if err != nil { + t.Fatal(err) + } + defer r.face.Close() + + // Bare captions keep the original fixed layout and click targets. + stop, cancel := r.buttons("Stop", "Cancel") + if want := image.Rect(220, 68, 290, 96); stop != want { + t.Errorf("stop = %v, want %v", stop, want) + } + if want := image.Rect(300, 68, 375, 96); cancel != want { + t.Errorf("cancel = %v, want %v", cancel, want) + } + + // Captions with keys widen the buttons instead of overflowing them. + for _, labels := range [][2]string{{"Stop Alt", "Cancel Esc"}, {"Stop Shift", "Cancel Space"}, {"Stop Super", "Cancel Enter"}} { + stop, cancel := r.buttons(labels[0], labels[1]) + for i, b := range []image.Rectangle{stop, cancel} { + textWidth := float64(font.MeasureString(r.face, labels[i])) / 64 / scale + if float64(b.Dx()) < textWidth+2*buttonPadding { + t.Errorf("%q does not fit in %v", labels[i], b) + } + } + if cancel.Max.X != buttonRight || stop.Max.X+buttonGap != cancel.Min.X { + t.Errorf("buttons not right-aligned: %v %v", stop, cancel) + } + if stop.Min.X < 130 { + t.Errorf("stop button %v overlaps the status text", stop) + } + } + + // The configured key is actually drawn on the buttons. + var history [waveformBars]float32 + ink := func(texts [2]string) [2]int { + frame := r.render("Recording...", texts[0], texts[1], history, 0) + stop, cancel := r.buttons(texts[0], texts[1]) + var counts [2]int + for i, b := range []image.Rectangle{stop, cancel} { + px := image.Rect(int(float64(b.Min.X)*scale), int(float64(b.Min.Y)*scale), int(float64(b.Max.X)*scale), int(float64(b.Max.Y)*scale)) + for y := px.Min.Y; y < px.Max.Y; y++ { + for x := px.Min.X; x < px.Max.X; x++ { + if frame.RGBAAt(x, y).R > 100 { // text, not the button fill + counts[i]++ + } + } + } + } + return counts + } + plain, labeled := ink([2]string{"Stop", "Cancel"}), ink([2]string{"Stop Alt", "Cancel F9"}) + for i := range plain { + if labeled[i] <= plain[i] { + t.Errorf("button %d does not show its key label: ink %d <= %d", i, labeled[i], plain[i]) + } + } + }) + } +} diff --git a/internal/overlay/overlay_darwin.go b/internal/overlay/overlay_darwin.go index fdd1e64..c5e0f9b 100644 --- a/internal/overlay/overlay_darwin.go +++ b/internal/overlay/overlay_darwin.go @@ -21,6 +21,8 @@ static float gCurrentAudioLevel = 0.0; static float gLevelHistory[50]; static int gLevelHistoryIndex = 0; static id gButtonMonitor = nil; +static NSString *gStopKeyLabel = nil; +static NSString *gCancelKeyLabel = nil; // Callbacks extern void goOverlayStopClicked(void); @@ -82,6 +84,63 @@ static void updateAudioLevel(float level) { gLevelHistoryIndex = (gLevelHistoryIndex + 1) % 50; } +static NSString* buttonTitle(NSString *action, NSString *keyLabel) { + if (keyLabel == nil || [keyLabel length] == 0) { + return action; + } + return [NSString stringWithFormat:@"%@ %@", action, keyLabel]; +} + +// Set button titles from the configured key labels and size the buttons to +// fit, right-aligned. Must run on the main thread. +static void applyButtonTitles(void) { + if (gStopButton == nil || gCancelButton == nil) { + return; + } + NSMutableParagraphStyle *style = [[[NSMutableParagraphStyle alloc] init] autorelease]; + [style setAlignment:NSTextAlignmentCenter]; + NSFont *font = [NSFont systemFontOfSize:12 weight:NSFontWeightMedium]; + + NSDictionary *stopAttrs = @{ + NSForegroundColorAttributeName: [NSColor whiteColor], + NSFontAttributeName: font, + NSParagraphStyleAttributeName: style + }; + NSAttributedString *stopTitle = [[[NSAttributedString alloc] initWithString:buttonTitle(@"Stop", gStopKeyLabel) + attributes:stopAttrs] autorelease]; + + NSDictionary *cancelAttrs = @{ + NSForegroundColorAttributeName: [NSColor colorWithWhite:0.6 alpha:1.0], + NSFontAttributeName: font, + NSParagraphStyleAttributeName: style + }; + NSAttributedString *cancelTitle = [[[NSAttributedString alloc] initWithString:buttonTitle(@"Cancel", gCancelKeyLabel) + attributes:cancelAttrs] autorelease]; + + [gStopButton setAttributedTitle:stopTitle]; + [gCancelButton setAttributedTitle:cancelTitle]; + + CGFloat panelWidth = [[gCancelButton superview] frame].size.width; + CGFloat cancelWidth = MAX(75, ceil([cancelTitle size].width) + 20); + CGFloat stopWidth = MAX(70, ceil([stopTitle size].width) + 20); + CGFloat cancelX = panelWidth - 15 - cancelWidth; + [gCancelButton setFrame:NSMakeRect(cancelX, 12, cancelWidth, 28)]; + [gStopButton setFrame:NSMakeRect(cancelX - 10 - stopWidth, 12, stopWidth, 28)]; +} + +// Update the key labels shown on the Stop and Cancel buttons +static void setOverlayHotkeyLabels(const char *stop, const char *cancel) { + NSString *stopLabel = [[NSString alloc] initWithUTF8String:stop]; + NSString *cancelLabel = [[NSString alloc] initWithUTF8String:cancel]; + dispatch_async(dispatch_get_main_queue(), ^{ + [gStopKeyLabel release]; + gStopKeyLabel = stopLabel; + [gCancelKeyLabel release]; + gCancelKeyLabel = cancelLabel; + applyButtonTitles(); + }); +} + // Create the overlay panel static void createOverlayPanel(void) { if (gOverlayPanel != nil) { @@ -148,10 +207,6 @@ static void createOverlayPanel(void) { [gStatusLabel setStringValue:@"Recording..."]; [contentView addSubview:gStatusLabel]; - // Button styling - NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; - [style setAlignment:NSTextAlignmentCenter]; - // Stop button gStopButton = [[NSButton alloc] initWithFrame:NSMakeRect(panelWidth - 170, 12, 70, 28)]; [gStopButton setButtonType:NSButtonTypeMomentaryPushIn]; @@ -159,14 +214,6 @@ static void createOverlayPanel(void) { gStopButton.wantsLayer = YES; gStopButton.layer.backgroundColor = [[NSColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:1.0] CGColor]; gStopButton.layer.cornerRadius = 6; - - NSDictionary *stopAttrs = @{ - NSForegroundColorAttributeName: [NSColor whiteColor], - NSFontAttributeName: [NSFont systemFontOfSize:12 weight:NSFontWeightMedium], - NSParagraphStyleAttributeName: style - }; - NSAttributedString *stopTitle = [[NSAttributedString alloc] initWithString:@"Stop ⌥" attributes:stopAttrs]; - [gStopButton setAttributedTitle:stopTitle]; [contentView addSubview:gStopButton]; // Cancel button @@ -176,15 +223,8 @@ static void createOverlayPanel(void) { gCancelButton.wantsLayer = YES; gCancelButton.layer.backgroundColor = [[NSColor colorWithRed:0.15 green:0.15 blue:0.15 alpha:1.0] CGColor]; gCancelButton.layer.cornerRadius = 6; - - NSDictionary *cancelAttrs = @{ - NSForegroundColorAttributeName: [NSColor colorWithWhite:0.6 alpha:1.0], - NSFontAttributeName: [NSFont systemFontOfSize:12 weight:NSFontWeightMedium], - NSParagraphStyleAttributeName: style - }; - NSAttributedString *cancelTitle = [[NSAttributedString alloc] initWithString:@"Cancel esc" attributes:cancelAttrs]; - [gCancelButton setAttributedTitle:cancelTitle]; [contentView addSubview:gCancelButton]; + applyButtonTitles(); } // Animation tick @@ -403,6 +443,15 @@ func (o *Overlay) SetCancelCallback(cb func()) { cancelCallbackMu.Unlock() } +// SetHotkeyLabels sets the key labels shown on the Stop and Cancel buttons +func (o *Overlay) SetHotkeyLabels(stop, cancel string) { + cStop := C.CString(stop) + defer C.free(unsafe.Pointer(cStop)) + cCancel := C.CString(cancel) + defer C.free(unsafe.Pointer(cCancel)) + C.setOverlayHotkeyLabels(cStop, cCancel) +} + // Destroy cleans up the overlay func (o *Overlay) Destroy() { o.mu.Lock() diff --git a/internal/overlay/overlay_other.go b/internal/overlay/overlay_other.go index b9c726c..abbd2c5 100644 --- a/internal/overlay/overlay_other.go +++ b/internal/overlay/overlay_other.go @@ -28,5 +28,8 @@ func (o *Overlay) SetStopCallback(cb func()) {} // SetCancelCallback sets the callback for when cancel is clicked (no-op on non-macOS) func (o *Overlay) SetCancelCallback(cb func()) {} +// SetHotkeyLabels sets the Stop and Cancel key labels (no-op on non-macOS) +func (o *Overlay) SetHotkeyLabels(stop, cancel string) {} + // Destroy cleans up the overlay (no-op on non-macOS) func (o *Overlay) Destroy() {} diff --git a/internal/overlay/overlay_windows.go b/internal/overlay/overlay_windows.go index 3fecefe..19dc079 100644 --- a/internal/overlay/overlay_windows.go +++ b/internal/overlay/overlay_windows.go @@ -53,6 +53,9 @@ type Overlay struct { done chan struct{} history [waveformBars]float32 frameErr error + stopKey, cancelKey string + // Button rectangles from the last drawn frame, used for hit testing. + stopRect, cancelRect image.Rectangle } func New() *Overlay { @@ -76,6 +79,13 @@ func (o *Overlay) SetStatus(s string) { o.mu.Lock(); o.status = s; o.mu func (o *Overlay) SetAudioLevel(v float32) { o.mu.Lock(); o.level = v; o.mu.Unlock() } func (o *Overlay) SetStopCallback(cb func()) { o.mu.Lock(); o.stop = cb; o.mu.Unlock() } func (o *Overlay) SetCancelCallback(cb func()) { o.mu.Lock(); o.cancel = cb; o.mu.Unlock() } + +// SetHotkeyLabels sets the key labels shown on the Stop and Cancel buttons. +func (o *Overlay) SetHotkeyLabels(stop, cancel string) { + o.mu.Lock() + o.stopKey, o.cancelKey = stop, cancel + o.mu.Unlock() +} func (o *Overlay) Destroy() { o.mu.Lock() o.destroyed = true @@ -115,10 +125,10 @@ func (o *Overlay) run(ready chan struct{}) { o.mu.Lock() var cb func() if o.status == "Recording..." { - if p.In(stopBounds) { + if p.In(o.stopRect) { cb = o.stop } - if p.In(cancelBounds) { + if p.In(o.cancelRect) { cb = o.cancel } } @@ -229,20 +239,23 @@ func (o *Overlay) drawFrame(hwnd uintptr, renderer *overlayRenderer, surface *la o.history[waveformBars-1] = o.level } status, history := o.status, o.history + stopText, cancelText := buttonText("Stop", o.stopKey), buttonText("Cancel", o.cancelKey) o.mu.Unlock() + stopRect, cancelRect := renderer.buttons(stopText, cancelText) var cursor point call("GetCursorPos", uintptr(unsafe.Pointer(&cursor))) call("ScreenToClient", hwnd, uintptr(unsafe.Pointer(&cursor))) p := image.Pt(int(float64(cursor.x)/renderer.scale), int(float64(cursor.y)/renderer.scale)) hover := 0 - if p.In(stopBounds) { + if p.In(stopRect) { hover = 1 } - if p.In(cancelBounds) { + if p.In(cancelRect) { hover = 2 } - err := surface.present(hwnd, renderer.render(status, history, hover)) + err := surface.present(hwnd, renderer.render(status, stopText, cancelText, history, hover)) o.mu.Lock() + o.stopRect, o.cancelRect = stopRect, cancelRect if err != nil && o.frameErr == nil { logger.Error(err.Error()) } diff --git a/internal/overlay/overlay_windows_test.go b/internal/overlay/overlay_windows_test.go index 71fceb7..5b238d7 100644 --- a/internal/overlay/overlay_windows_test.go +++ b/internal/overlay/overlay_windows_test.go @@ -53,26 +53,53 @@ func TestWindowsOverlayLifecycleAndFocus(t *testing.T) { if result := call("SendMessageW", o.hwnd, 0x21, 0, 0); result != 3 { t.Fatal("mouse clicks can activate overlay") } - clicked := make(chan struct{}, 1) - o.SetStopCallback(func() { clicked <- struct{}{} }) + clicked := make(chan string, 1) + o.SetStopCallback(func() { clicked <- "stop" }) + o.SetCancelCallback(func() { clicked <- "cancel" }) scale := float64(call("GetDpiForWindow", o.hwnd)) / 96 - click := func(x, y int) { - call("SendMessageW", o.hwnd, 0x202, 0, uintptr(int(float64(x)*scale)|(int(float64(y)*scale)<<16))) + click := func(p image.Point, want string) { + t.Helper() + call("SendMessageW", o.hwnd, 0x202, 0, uintptr(int(float64(p.X)*scale)|(int(float64(p.Y)*scale)<<16))) + select { + case got := <-clicked: + if got != want { + t.Fatalf("click at %v triggered %s, want %s", p, got, want) + } + case <-time.After(time.Second): + t.Fatalf("%s callback missing for click at %v", want, p) + } } - click(250, 82) - select { - case <-clicked: - case <-time.After(time.Second): - t.Fatal("stop callback missing") + center := func(r image.Rectangle) image.Point { return r.Min.Add(r.Max).Div(2) } + o.mu.Lock() + stopRect, cancelRect := o.stopRect, o.cancelRect + o.mu.Unlock() + // Unlabeled captions keep the original click targets. + if stopRect != image.Rect(220, 68, 290, 96) || cancelRect != image.Rect(300, 68, 375, 96) { + t.Fatalf("unexpected default buttons: %v %v", stopRect, cancelRect) } + click(image.Pt(250, 82), "stop") + click(image.Pt(330, 82), "cancel") + + // Wide key labels move the buttons; clicks must follow what is drawn. + o.SetHotkeyLabels("Shift", "Space") o.SetAudioLevel(0.8) - o.SetCancelCallback(func() { clicked <- struct{}{} }) - click(330, 82) - select { - case <-clicked: - case <-time.After(time.Second): - t.Fatal("cancel callback missing") + deadline := time.Now().Add(3 * time.Second) + for { + o.mu.Lock() + stopRect, cancelRect = o.stopRect, o.cancelRect + o.mu.Unlock() + if cancelRect.Min.X < 300 || time.Now().After(deadline) { + break + } + time.Sleep(10 * time.Millisecond) + } + if cancelRect.Min.X >= 300 || stopRect.Max.X >= cancelRect.Min.X { + t.Fatalf("buttons did not grow for labels: %v %v", stopRect, cancelRect) } + click(center(stopRect), "stop") + click(center(cancelRect), "cancel") + // The left edge of Cancel used to belong to Stop. + click(image.Pt(cancelRect.Min.X+1, 82), "cancel") o.SetStatus("Transcribing...") o.Hide() waitVisible(false) From c541b61ada468150f66bb32701f7c96c996652c7 Mon Sep 17 00:00:00 2001 From: Nikolay Kolibarov Date: Sun, 25 Oct 2026 21:16:09 +0200 Subject: [PATCH 3/3] fix(hotkey): hide hotkey badges until the real keys are loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Home badge and sidebar hint rendered with the macOS defaults until GetPlatform and the hotkey bindings resolved, so Windows briefly showed ⌥. They now appear once the platform and both keys have loaded. Also extend the shared label table with every alias the formatters accept, so the Go and TypeScript implementations cannot drift. --- frontend/src/App.test.tsx | 45 ++++++++++++++++++++-- frontend/src/App.tsx | 24 ++++++++---- internal/hotkey/testdata/short_labels.json | 16 +++++++- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d657819..909d64b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -23,17 +23,20 @@ function appState(overrides: Record = {}) { }; } -function setup({ state = appState(), models = MODELS, download = IDLE_DOWNLOAD } = {}) { +function setup({ + state = appState(), models = MODELS, download = IDLE_DOWNLOAD, + platform = 'linux' as string | Promise, hotkeys = ['rightalt', 'escape'], +} = {}) { api.IsOnboardingCompleted.mockResolvedValue(true); - api.GetPlatform.mockResolvedValue('linux'); + api.GetPlatform.mockImplementation(() => Promise.resolve(platform)); api.GetState.mockResolvedValue(state); api.GetModels.mockResolvedValue(models); api.GetConfig.mockResolvedValue({ provider: state.currentProvider, model: state.currentModel, autoPaste: true }); api.GetHistory.mockResolvedValue([]); api.GetAudioInputDevices.mockResolvedValue([]); api.GetStats.mockResolvedValue({ averageWPM: 0, wordsThisWeek: 0, recordingsThisWeek: 0, timeSavedThisWeek: 0, totalRecordings: 0, totalWords: 0 }); - api.GetRecordingHotkey.mockResolvedValue('rightalt'); - api.GetCancelHotkey.mockResolvedValue('escape'); + api.GetRecordingHotkey.mockResolvedValue(hotkeys[0]); + api.GetCancelHotkey.mockResolvedValue(hotkeys[1]); api.GetModelDownloadState.mockResolvedValue(download); return render(); } @@ -142,3 +145,37 @@ describe('App model integration', () => { await waitFor(() => expect(api.GetModels.mock.calls.length).toBeGreaterThan(before)); }); }); + +describe('App hotkey labels', () => { + beforeEach(() => { + resetListeners(); + Object.values(api).forEach((fn) => { + fn.mockReset(); + fn.mockImplementation(() => Promise.resolve()); + }); + api.CheckAccessibilityPermission.mockResolvedValue(true); + }); + + it('never flashes macOS symbols on Windows while the platform loads', async () => { + let resolvePlatform!: (p: string) => void; + setup({ platform: new Promise((resolve) => { resolvePlatform = resolve; }) }); + await screen.findByText('Start recording'); + expect(document.querySelector('.action-shortcut')).toBeNull(); + expect(document.querySelector('.hotkey-hint')).toBeNull(); + expect(document.body).not.toHaveTextContent('⌥'); + + await act(async () => resolvePlatform('windows')); + await waitFor(() => expect(document.querySelector('.action-shortcut')).toHaveTextContent(/^Alt$/)); + expect(document.querySelector('.hotkey-hint kbd')).toHaveTextContent(/^Right Alt$/); + expect(document.body).not.toHaveTextContent('⌥'); + }); + + it('shows the configured keys in the recording overlay', async () => { + setup({ platform: 'darwin', hotkeys: ['rightcommand', 'f10'] }); + await waitFor(() => expect(listenerCount('stateChanged')).toBe(1)); + act(() => EventsEmit('stateChanged', appState({ state: 'recording' }))); + const stop = await screen.findByRole('button', { name: /^Stop/ }); + expect(stop.querySelector('kbd')).toHaveTextContent(/^⌘$/); + expect(screen.getByRole('button', { name: /^Cancel/ }).querySelector('kbd')).toHaveTextContent(/^F10$/); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3b8de6f..caaecf5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -133,6 +133,7 @@ function App() { const [cancelHotkey, setCancelHotkey] = useState('escape'); const [showOnboarding, setShowOnboarding] = useState(null); const [platform, setPlatform] = useState('darwin'); + const [hotkeyLabelsReady, setHotkeyLabelsReady] = useState(false); const [isCapturingHotkey, setIsCapturingHotkey] = useState(false); const [isCapturingCancelKey, setIsCapturingCancelKey] = useState(false); const [accessibilityRecovery, setAccessibilityRecovery] = useState('checking'); @@ -256,8 +257,13 @@ function App() { GetHistory().then((h: HistoryItem[]) => setHistory(h)); GetAudioInputDevices().then((devices: AudioInputDevice[]) => setAudioDevices(devices)); GetStats().then((s: UsageStats) => setStats(s)); - GetRecordingHotkey().then((h: string) => setCurrentHotkey(h)); - GetCancelHotkey().then((h: string) => setCancelHotkey(h)); + // Hotkey badges stay hidden until the real keys and platform are known, + // so Windows never flashes the macOS defaults. + Promise.allSettled([ + GetRecordingHotkey().then((h: string) => setCurrentHotkey(h)), + GetCancelHotkey().then((h: string) => setCancelHotkey(h)), + GetPlatform().then((p: string) => setPlatform(p)), + ]).then(() => setHotkeyLabelsReady(true)); LogInfo('[frontend] App initial data load requested'); LogInfo('[frontend] Setting up EventsOn for stateChanged'); @@ -768,10 +774,12 @@ function App() {
-
- {formatKeyDisplay(currentHotkey)} - to record -
+ {hotkeyLabelsReady && ( +
+ {formatKeyDisplay(currentHotkey)} + to record +
+ )}
@@ -894,7 +902,9 @@ function App() { {needsDownload ? 'Download selected model first' : 'Turn your voice to text with a single click'} - {getHotkeyShortDisplay(currentHotkey)} + {hotkeyLabelsReady && ( + {getHotkeyShortDisplay(currentHotkey)} + )}
setCurrentPage('settings')}> diff --git a/internal/hotkey/testdata/short_labels.json b/internal/hotkey/testdata/short_labels.json index 28db54d..058d93d 100644 --- a/internal/hotkey/testdata/short_labels.json +++ b/internal/hotkey/testdata/short_labels.json @@ -32,5 +32,19 @@ { "key": "a", "darwin": "A", "windows": "A", "linux": "A" }, { "key": "z", "darwin": "Z", "windows": "Z", "linux": "Z" }, { "key": "7", "darwin": "7", "windows": "7", "linux": "7" }, - { "key": " ", "darwin": "", "windows": "", "linux": "" } + { "key": " ", "darwin": "", "windows": "", "linux": "" }, + { "key": "leftcommand", "darwin": "⌘", "windows": "Win", "linux": "Super" }, + { "key": "rightcmd", "darwin": "⌘", "windows": "Win", "linux": "Super" }, + { "key": "rightsuper", "darwin": "⌘", "windows": "Win", "linux": "Super" }, + { "key": "leftwin", "darwin": "⌘", "windows": "Win", "linux": "Super" }, + { "key": "rightcontrol", "darwin": "⌃", "windows": "Ctrl", "linux": "Ctrl" }, + { "key": "leftctrl", "darwin": "⌃", "windows": "Ctrl", "linux": "Ctrl" }, + { "key": "function", "darwin": "fn", "windows": "Fn", "linux": "Fn" }, + { "key": "arrowleft", "darwin": "←", "windows": "←", "linux": "←" }, + { "key": "right", "darwin": "→", "windows": "→", "linux": "→" }, + { "key": "down", "darwin": "↓", "windows": "↓", "linux": "↓" }, + { "key": "arrowup", "darwin": "↑", "windows": "↑", "linux": "↑" }, + { "key": " Escape ", "darwin": "Esc", "windows": "Esc", "linux": "Esc" }, + { "key": "pageup", "darwin": "PAGEUP", "windows": "PAGEUP", "linux": "PAGEUP" }, + { "key": "rightfoo", "darwin": "RIGHTFOO", "windows": "RIGHTFOO", "linux": "RIGHTFOO" } ]