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 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.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 1e0d1be..caaecf5 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'; @@ -132,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'); @@ -255,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'); @@ -603,62 +610,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); @@ -821,10 +774,12 @@ function App() {
-
- {formatKeyDisplay(currentHotkey)} - to record -
+ {hotkeyLabelsReady && ( +
+ {formatKeyDisplay(currentHotkey)} + to record +
+ )}
@@ -947,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')}> @@ -1363,6 +1320,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..058d93d --- /dev/null +++ b/internal/hotkey/testdata/short_labels.json @@ -0,0 +1,50 @@ +[ + { "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": "" }, + { "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" } +] 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)