From 32d93a36532b9149545774ac4d304bc2b9a3518d Mon Sep 17 00:00:00 2001 From: Mosmain Date: Tue, 2 Jun 2026 01:29:41 +0300 Subject: [PATCH 01/53] fix(overlay/hotkeys): wave 1 UX bugfixes - #7 numpad hotkeys: emit canonical W3C codes (NumpadSubtract etc.) the global-hotkey crate accepts; old short tokens (numsub/nummult/ numdiv/numdec/NumpadEnter->Enter) failed to register and silently reverted. Right-Alt: detect held modifiers by event.code so AltGr (key AltGraph) no longer reads as a bad main key. - #6 re-applying an identical hotkey: idempotent register + a re-apply window event so a same-value re-record re-claims the binding; recorder shows an applied confirmation. - #17 close ConfirmDialog when the overlay locks. - #20 dismiss the RMB transparency menu on lock and on window blur. - #22 Enter key saves the Tarkov paths form. - #23 clamp the overlay onto a visible monitor at startup when the window-state plugin restored off-screen coords (2K -> FHD). Adds hotkey.spec.ts (18 cases) cross-checking emitted tokens against the global-hotkey 0.7.0 parser table. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/client/src/App.vue | 10 ++ apps/client/src/__tests__/hotkey.spec.ts | 159 ++++++++++++++++++ .../hotkeys/components/HotkeyRecorder.vue | 25 ++- .../hotkeys/composables/useGlobalShortcut.ts | 13 ++ .../client/src/features/hotkeys/lib/hotkey.ts | 90 +++++++--- .../overlay/components/MapQuickMenu.vue | 6 + .../settings/sections/PathsSection.vue | 2 + apps/desktop/src-tauri/src/lib.rs | 54 ++++++ 8 files changed, 331 insertions(+), 28 deletions(-) create mode 100644 apps/client/src/__tests__/hotkey.spec.ts diff --git a/apps/client/src/App.vue b/apps/client/src/App.vue index 5366711..07e69b5 100644 --- a/apps/client/src/App.vue +++ b/apps/client/src/App.vue @@ -11,6 +11,7 @@ import { useTrayIcon } from '@/features/overlay/composables/useTrayIcon'; import { useOverlayBootstrap } from '@/features/overlay/composables/useOverlayBootstrap'; import { useAutoMapSwitch } from '@/features/map/composables/useAutoMapSwitch'; import { eventsUrl } from '@/shared/config'; +import { useConfirm } from 'primevue/useconfirm'; const { clickThrough: overlayClickThrough } = storeToRefs(useOverlayStore()); const { lockHotkey } = storeToRefs(useHotkeysStore()); @@ -35,8 +36,17 @@ useGlobalShortcut(isTauri, lockHotkey, () => { useOverlayBootstrap(overlayClickThrough); useTrayIcon(isTauri, overlayClickThrough); +const confirm = useConfirm(); const quickMenu = ref | null>(null); +// Close transient UI when the overlay locks so click-through can't strand them. +watch(overlayClickThrough, (locked) => { + if (locked) { + confirm.close(); + quickMenu.value?.close(); + } +}); + // Right-click anywhere over the Leaflet canvas opens the transparency panel. // Lives at the app root so the same gesture works regardless of which route // is mounted underneath. diff --git a/apps/client/src/__tests__/hotkey.spec.ts b/apps/client/src/__tests__/hotkey.spec.ts new file mode 100644 index 0000000..06a614b --- /dev/null +++ b/apps/client/src/__tests__/hotkey.spec.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import { captureHotkey, formatHotkeyParts } from '@/features/hotkeys/lib/hotkey'; + +// Mirror of the keys accepted by global-hotkey 0.7.0's `parse_key` (uppercased +// before matching). Our captured main-key tokens MUST land in this set or +// Tauri's register() throws and the binding silently reverts (the Num- bug). +const CRATE_ACCEPTED = new Set([ + ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), + ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map((l) => `KEY${l}`), + ...'0123456789'.split(''), + ...'0123456789'.split('').map((d) => `DIGIT${d}`), + 'BACKQUOTE', '`', 'BACKSLASH', '\\', 'BRACKETLEFT', '[', 'BRACKETRIGHT', ']', + 'COMMA', ',', 'EQUAL', '=', 'MINUS', '-', 'PERIOD', '.', 'QUOTE', "'", + 'SEMICOLON', ';', 'SLASH', '/', + 'SPACE', 'TAB', 'ENTER', 'BACKSPACE', 'DELETE', 'END', 'HOME', 'INSERT', + 'PAGEDOWN', 'PAGEUP', + 'ARROWDOWN', 'DOWN', 'ARROWLEFT', 'LEFT', 'ARROWRIGHT', 'RIGHT', 'ARROWUP', 'UP', + 'NUMPAD0', 'NUM0', 'NUMPAD1', 'NUM1', 'NUMPAD2', 'NUM2', 'NUMPAD3', 'NUM3', + 'NUMPAD4', 'NUM4', 'NUMPAD5', 'NUM5', 'NUMPAD6', 'NUM6', 'NUMPAD7', 'NUM7', + 'NUMPAD8', 'NUM8', 'NUMPAD9', 'NUM9', + 'NUMPADADD', 'NUMADD', 'NUMPADPLUS', 'NUMPLUS', + 'NUMPADSUBTRACT', 'NUMSUBTRACT', + 'NUMPADMULTIPLY', 'NUMMULTIPLY', + 'NUMPADDIVIDE', 'NUMDIVIDE', + 'NUMPADDECIMAL', 'NUMDECIMAL', + 'NUMPADENTER', 'NUMENTER', 'NUMPADEQUAL', 'NUMEQUAL', + ...Array.from({ length: 24 }, (_, i) => `F${i + 1}`), +]); + +interface Mods { + ctrl?: boolean; + alt?: boolean; + shift?: boolean; + meta?: boolean; +} + +function ev(code: string, mods: Mods = {}, key = ''): KeyboardEvent { + return new KeyboardEvent('keydown', { + code, + key, + ctrlKey: !!mods.ctrl, + altKey: !!mods.alt, + shiftKey: !!mods.shift, + metaKey: !!mods.meta, + }); +} + +/** Last token of an accelerator string (the main key). */ +function mainKey(combo: string): string { + const parts = combo.split('+'); + return parts[parts.length - 1] ?? ''; +} + +describe('captureHotkey — main keys', () => { + it('letters map KeyX → X', () => { + expect(captureHotkey(ev('KeyL', { ctrl: true, alt: true })).combo).toBe('CommandOrControl+Alt+L'); + }); + + it('digits map DigitN → N', () => { + expect(captureHotkey(ev('Digit5', { ctrl: true })).combo).toBe('CommandOrControl+5'); + }); + + it('function keys pass through', () => { + expect(captureHotkey(ev('F7', { alt: true })).combo).toBe('Alt+F7'); + }); + + it('main-row minus is "-"', () => { + expect(captureHotkey(ev('Minus', { ctrl: true })).combo).toBe('CommandOrControl+-'); + }); + + it('meta counts as CommandOrControl', () => { + expect(captureHotkey(ev('KeyK', { meta: true })).combo).toBe('CommandOrControl+K'); + }); +}); + +describe('captureHotkey — numpad (regression for the Num- bug)', () => { + const NUMPAD_CODES = [ + 'Numpad0', 'Numpad1', 'Numpad2', 'Numpad3', 'Numpad4', + 'Numpad5', 'Numpad6', 'Numpad7', 'Numpad8', 'Numpad9', + 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', + 'NumpadEnter', + ]; + + it('NumpadSubtract no longer collapses to "-"', () => { + const combo = captureHotkey(ev('NumpadSubtract', { ctrl: true })).combo; + expect(combo).toBe('CommandOrControl+NumpadSubtract'); + expect(mainKey(combo!)).not.toBe('-'); + }); + + it('NumpadAdd keeps working', () => { + expect(captureHotkey(ev('NumpadAdd', { ctrl: true })).combo).toBe('CommandOrControl+NumpadAdd'); + }); + + it('every numpad token is accepted by the global-hotkey parser', () => { + for (const code of NUMPAD_CODES) { + const combo = captureHotkey(ev(code, { ctrl: true })).combo; + expect(combo, `no combo for ${code}`).toBeTruthy(); + const token = mainKey(combo!).toUpperCase(); + expect(CRATE_ACCEPTED.has(token), `${code} → "${token}" rejected by crate`).toBe(true); + } + }); +}); + +describe('captureHotkey — validation', () => { + it('requires a modifier', () => { + const r = captureHotkey(ev('KeyL')); + expect(r.combo).toBeNull(); + expect(r.error).toBe('no-modifier'); + }); + + it('Escape cancels recording', () => { + const r = captureHotkey(ev('Escape', { ctrl: true }, 'Escape')); + expect(r.cancelled).toBe(true); + }); + + it('holding only modifiers waits for a main key', () => { + const r = captureHotkey(ev('ControlLeft', { ctrl: true }, 'Control')); + expect(r.combo).toBeNull(); + expect(r.error).toBeNull(); + }); + + it('right Alt as AltGraph waits, not "invalid"', () => { + // AltGr layouts report key="AltGraph" (not in MODIFIER_KEYS) — the code match saves it. + const r = captureHotkey(ev('AltRight', { ctrl: true, alt: true }, 'AltGraph')); + expect(r.combo).toBeNull(); + expect(r.error).toBeNull(); + }); + + it('right Alt as plain Alt waits', () => { + const r = captureHotkey(ev('AltRight', { alt: true }, 'Alt')); + expect(r.combo).toBeNull(); + expect(r.error).toBeNull(); + }); + + it('right-modifier codes are never main keys', () => { + for (const code of ['ShiftRight', 'ControlRight', 'MetaRight']) { + expect(captureHotkey(ev(code, { shift: true })).error).toBeNull(); + } + }); + + it('unmapped keys are rejected', () => { + const r = captureHotkey(ev('MediaSelect', { ctrl: true })); + expect(r.error).toBe('bad-main-key'); + }); +}); + +describe('formatHotkeyParts', () => { + it('renders canonical numpad tokens', () => { + expect(formatHotkeyParts('CommandOrControl+NumpadSubtract')).toEqual(['Ctrl', 'Num -']); + }); + + it('still renders legacy persisted tokens', () => { + expect(formatHotkeyParts('CommandOrControl+numadd')).toEqual(['Ctrl', 'Num +']); + }); + + it('uppercases unknown main keys', () => { + expect(formatHotkeyParts('Alt+F7')).toEqual(['Alt', 'F7']); + }); +}); diff --git a/apps/client/src/features/hotkeys/components/HotkeyRecorder.vue b/apps/client/src/features/hotkeys/components/HotkeyRecorder.vue index e6af8a1..05ea518 100644 --- a/apps/client/src/features/hotkeys/components/HotkeyRecorder.vue +++ b/apps/client/src/features/hotkeys/components/HotkeyRecorder.vue @@ -1,5 +1,5 @@ diff --git a/apps/client/src/features/settings/sections/PathsSection.vue b/apps/client/src/features/settings/sections/PathsSection.vue index 594efa8..058f64f 100644 --- a/apps/client/src/features/settings/sections/PathsSection.vue +++ b/apps/client/src/features/settings/sections/PathsSection.vue @@ -64,6 +64,7 @@ const { :readonly="!canEditPaths" size="small" fluid + @keyup.enter="canSavePaths && savePaths()" /> @@ -82,6 +83,7 @@ const { :readonly="!canEditPaths" size="small" fluid + @keyup.enter="canSavePaths && savePaths()" /> diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9b56125..f2dcc87 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -54,8 +54,62 @@ pub fn run() { app_handle: app_handle.clone(), }); + // Clamp restored window onto a visible monitor (handles 2K→FHD moves). + if let Some(win) = app.get_webview_window("main") { + clamp_window_to_monitor(&win); + } + Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +/// Reposition the window if the window-state plugin restored coordinates that +/// land off every current monitor (monitor layout changed between sessions). +fn clamp_window_to_monitor(window: &tauri::WebviewWindow) { + const MIN_OVERLAP: i32 = 48; + + let Ok(pos) = window.outer_position() else { return }; + let Ok(size) = window.outer_size() else { return }; + let Ok(monitors) = window.available_monitors() else { return }; + + if monitors.is_empty() { + return; + } + + let wx1 = pos.x; + let wy1 = pos.y; + let wx2 = wx1 + size.width as i32; + let wy2 = wy1 + size.height as i32; + + let on_screen = monitors.iter().any(|m| { + let mx1 = m.position().x; + let my1 = m.position().y; + let mx2 = mx1 + m.size().width as i32; + let my2 = my1 + m.size().height as i32; + + let ix = (wx2.min(mx2) - wx1.max(mx1)).max(0); + let iy = (wy2.min(my2) - wy1.max(my1)).max(0); + ix >= MIN_OVERLAP && iy >= MIN_OVERLAP + }); + + if on_screen { + return; + } + + // Pick primary → first available → current. + let target = window + .primary_monitor() + .ok() + .flatten() + .or_else(|| monitors.into_iter().next()) + .or_else(|| window.current_monitor().ok().flatten()); + + if let Some(m) = target { + const MARGIN: i32 = 40; + let nx = m.position().x + MARGIN; + let ny = m.position().y + MARGIN; + let _ = window.set_position(tauri::PhysicalPosition::new(nx, ny)); + } +} From ade1862559bb80d5af8c031585e8562057822bfd Mon Sep 17 00:00:00 2001 From: Mosmain Date: Tue, 2 Jun 2026 01:40:02 +0300 Subject: [PATCH 02/53] fix(hotkeys): suspend global shortcuts while recording The OS keeps every registered shortcut live during capture, so pressing an already-bound combo fired its action and the OS swallowed the keystroke before the webview saw it. That made three things impossible: binding a combo that is already taken (e.g. the lock combo), re-recording the identical combo (the keypress never reached the recorder, so nothing saved), and capturing right-Alt combos that collide with a default Ctrl+Alt binding on AltGr layouts. The recorder now brackets capture with suspend/resume window events: suspend unregisters every live shortcut, resume re-claims them all (which also re-applies an unchanged re-record). Replaces the earlier reapply- event hack, which never ran because capture itself was being blocked. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hotkeys/components/HotkeyRecorder.vue | 18 +++-- .../hotkeys/composables/useGlobalShortcut.ts | 69 +++++++++++-------- .../client/src/features/hotkeys/lib/hotkey.ts | 9 ++- 3 files changed, 58 insertions(+), 38 deletions(-) diff --git a/apps/client/src/features/hotkeys/components/HotkeyRecorder.vue b/apps/client/src/features/hotkeys/components/HotkeyRecorder.vue index 05ea518..82ac9fd 100644 --- a/apps/client/src/features/hotkeys/components/HotkeyRecorder.vue +++ b/apps/client/src/features/hotkeys/components/HotkeyRecorder.vue @@ -1,5 +1,10 @@ + + diff --git a/apps/client/src/features/overlay/components/OverlayHeader.vue b/apps/client/src/features/overlay/components/OverlayHeader.vue index 11246f1..b4f7aeb 100644 --- a/apps/client/src/features/overlay/components/OverlayHeader.vue +++ b/apps/client/src/features/overlay/components/OverlayHeader.vue @@ -12,6 +12,8 @@ interface Props { const props = defineProps(); defineEmits<{ close: [] }>(); +const { t } = useI18n(); + const statusIconClass = computed(() => { switch (props.status) { case 'open': @@ -36,6 +38,18 @@ async function startDrag(event: MouseEvent): Promise { diff --git a/apps/client/src/features/settings/sections/OverlaySection.vue b/apps/client/src/features/settings/sections/OverlaySection.vue index d3ca210..580d710 100644 --- a/apps/client/src/features/settings/sections/OverlaySection.vue +++ b/apps/client/src/features/settings/sections/OverlaySection.vue @@ -67,7 +67,7 @@ const overlayZoomOptions = computed(() => [ option-value="value" :allow-empty="false" size="small" - class="w-full" + fluid /> diff --git a/apps/client/src/features/settings/sections/PlayerSection.vue b/apps/client/src/features/settings/sections/PlayerSection.vue index 8663221..d726658 100644 --- a/apps/client/src/features/settings/sections/PlayerSection.vue +++ b/apps/client/src/features/settings/sections/PlayerSection.vue @@ -22,7 +22,7 @@ const playerFollowOptions = computed(() => [ option-value="value" :allow-empty="false" size="small" - class="w-full" + fluid />

{{ t('playerFollowHint') }}

diff --git a/apps/client/src/pages/index.vue b/apps/client/src/pages/index.vue index b9f510e..a9c5d6a 100644 --- a/apps/client/src/pages/index.vue +++ b/apps/client/src/pages/index.vue @@ -1,6 +1,7 @@