Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
45 changes: 41 additions & 4 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,20 @@ function appState(overrides: Record<string, unknown> = {}) {
};
}

function setup({ state = appState(), models = MODELS, download = IDLE_DOWNLOAD } = {}) {
function setup({
state = appState(), models = MODELS, download = IDLE_DOWNLOAD,
platform = 'linux' as string | Promise<string>, 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(<StrictMode><App /></StrictMode>);
}
Expand Down Expand Up @@ -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<string>((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$/);
});
});
85 changes: 22 additions & 63 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -132,6 +133,7 @@ function App() {
const [cancelHotkey, setCancelHotkey] = useState<string>('escape');
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
const [platform, setPlatform] = useState<string>('darwin');
const [hotkeyLabelsReady, setHotkeyLabelsReady] = useState<boolean>(false);
const [isCapturingHotkey, setIsCapturingHotkey] = useState<boolean>(false);
const [isCapturingCancelKey, setIsCapturingCancelKey] = useState<boolean>(false);
const [accessibilityRecovery, setAccessibilityRecovery] = useState<AccessibilityRecoveryState>('checking');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -821,10 +774,12 @@ function App() {
</nav>

<div className="sidebar-footer">
<div className="hotkey-hint">
<kbd>{formatKeyDisplay(currentHotkey)}</kbd>
<span>to record</span>
</div>
{hotkeyLabelsReady && (
<div className="hotkey-hint">
<kbd>{formatKeyDisplay(currentHotkey)}</kbd>
<span>to record</span>
</div>
)}
</div>
</div>

Expand Down Expand Up @@ -947,7 +902,9 @@ function App() {
{needsDownload ? 'Download selected model first' : 'Turn your voice to text with a single click'}
</span>
</div>
<kbd className="action-shortcut">{getHotkeyShortDisplay(currentHotkey)}</kbd>
{hotkeyLabelsReady && (
<kbd className="action-shortcut">{getHotkeyShortDisplay(currentHotkey)}</kbd>
)}
</div>

<div className="action-item" onClick={() => setCurrentPage('settings')}>
Expand Down Expand Up @@ -1363,6 +1320,8 @@ function App() {
isRecording={appState.state === 'recording'}
onStop={handleToggleRecording}
onCancel={handleCancelRecording}
stopKeyLabel={getHotkeyShortDisplay(currentHotkey)}
cancelKeyLabel={getHotkeyShortDisplay(cancelHotkey)}
/>
</>
);
Expand Down
76 changes: 76 additions & 0 deletions frontend/src/RecordingOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<RecordingOverlay
isRecording
onStop={onStop}
onCancel={onCancel}
stopKeyLabel={stopKeyLabel}
cancelKeyLabel={cancelKeyLabel}
/>,
);
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(
<RecordingOverlay isRecording={false} onStop={() => {}} onCancel={() => {}} stopKeyLabel="Alt" cancelKeyLabel="Esc" />,
);
expect(container).toBeEmptyDOMElement();
});
});
10 changes: 7 additions & 3 deletions frontend/src/RecordingOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLCanvasElement>(null);
const [waveformData, setWaveformData] = useState<number[]>([]);

Expand Down Expand Up @@ -106,11 +110,11 @@ export function RecordingOverlay({ isRecording, onStop, onCancel }: RecordingOve
<div className="recording-actions">
<button className="recording-btn stop-btn" onClick={onStop}>
Stop
<kbd>⌥</kbd>
{stopKeyLabel && <kbd>{stopKeyLabel}</kbd>}
</button>
<button className="recording-btn cancel-btn" onClick={onCancel}>
Cancel
<kbd>esc</kbd>
{cancelKeyLabel && <kbd>{cancelKeyLabel}</kbd>}
</button>
</div>
</div>
Expand Down
Loading
Loading