diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts b/apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts index f3ba672b9ce..bac3bbb1da0 100644 --- a/apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts +++ b/apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts @@ -1,6 +1,7 @@ import { HOTKEYS, type HotkeyId } from "../../registry"; import { useHotkeyOverridesStore } from "../../stores/hotkeyOverridesStore"; import { useKeyboardLayoutStore } from "../../stores/keyboardLayoutStore"; +import { useKeyboardPreferencesStore } from "../../stores/keyboardPreferencesStore"; import type { ShortcutBinding } from "../../types"; import { bindingToDispatchChord } from "../../utils/binding"; @@ -33,8 +34,7 @@ export function getBinding(id: HotkeyId): ShortcutBinding | null { * the bound handler on non-US layouts. */ export function getDispatchChord(id: HotkeyId): string | null { - return bindingToDispatchChord( - getBinding(id), - useKeyboardLayoutStore.getState().map, - ); + const adaptive = useKeyboardPreferencesStore.getState().adaptiveLayoutEnabled; + const layoutMap = adaptive ? useKeyboardLayoutStore.getState().map : null; + return bindingToDispatchChord(getBinding(id), layoutMap); } diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts b/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts index 9df55b5c205..22add732053 100644 --- a/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts +++ b/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts @@ -2,13 +2,22 @@ import { useMemo } from "react"; import { formatHotkeyDisplay } from "../../display"; import { PLATFORM } from "../../registry"; import { useKeyboardLayoutStore } from "../../stores/keyboardLayoutStore"; +import { useKeyboardPreferencesStore } from "../../stores/keyboardPreferencesStore"; import type { HotkeyDisplay, ShortcutBinding } from "../../types"; import { bindingToDispatchChord } from "../../utils/binding"; import { useBinding } from "../useBinding"; +/** Effective layout map for display: null when the user has disabled + * adaptive layout mapping, so glyphs match the authored chord. */ +function useActiveLayoutMap(): ReadonlyMap | null { + const layoutMap = useKeyboardLayoutStore((s) => s.map); + const adaptive = useKeyboardPreferencesStore((s) => s.adaptiveLayoutEnabled); + return adaptive ? layoutMap : null; +} + export function useHotkeyDisplay(id: string): HotkeyDisplay { const binding = useBinding(id as Parameters[0]); - const layoutMap = useKeyboardLayoutStore((s) => s.map); + const layoutMap = useActiveLayoutMap(); const chord = bindingToDispatchChord(binding, layoutMap); return useMemo( () => formatHotkeyDisplay(chord, PLATFORM, layoutMap), @@ -25,7 +34,7 @@ export function useHotkeyDisplay(id: string): HotkeyDisplay { export function useFormatBinding( binding: ShortcutBinding | null, ): HotkeyDisplay { - const layoutMap = useKeyboardLayoutStore((s) => s.map); + const layoutMap = useActiveLayoutMap(); const chord = bindingToDispatchChord(binding, layoutMap); return useMemo( () => formatHotkeyDisplay(chord, PLATFORM, layoutMap), diff --git a/apps/desktop/src/renderer/hotkeys/index.ts b/apps/desktop/src/renderer/hotkeys/index.ts index 0cdc8a0ef37..647dc89d3d5 100644 --- a/apps/desktop/src/renderer/hotkeys/index.ts +++ b/apps/desktop/src/renderer/hotkeys/index.ts @@ -10,7 +10,10 @@ export { useRecordHotkeys, } from "./hooks"; export { HOTKEYS, type HotkeyId, PLATFORM } from "./registry"; -export { useHotkeyOverridesStore } from "./stores"; +export { + useHotkeyOverridesStore, + useKeyboardPreferencesStore, +} from "./stores"; export type { BindingMode, HotkeyCategory, diff --git a/apps/desktop/src/renderer/hotkeys/stores/index.ts b/apps/desktop/src/renderer/hotkeys/stores/index.ts index 8c5b63a8a45..478d066be0e 100644 --- a/apps/desktop/src/renderer/hotkeys/stores/index.ts +++ b/apps/desktop/src/renderer/hotkeys/stores/index.ts @@ -1 +1,2 @@ export { useHotkeyOverridesStore } from "./hotkeyOverridesStore"; +export { useKeyboardPreferencesStore } from "./keyboardPreferencesStore"; diff --git a/apps/desktop/src/renderer/hotkeys/stores/keyboardPreferencesStore.ts b/apps/desktop/src/renderer/hotkeys/stores/keyboardPreferencesStore.ts new file mode 100644 index 00000000000..abe0aecf6d8 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/stores/keyboardPreferencesStore.ts @@ -0,0 +1,28 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +interface KeyboardPreferencesState { + /** Opt-in: when true, logical bindings are translated through the OS + * keyboard layout — e.g. `⌘Z` dispatches to physical KeyY on QWERTZ. + * Defaults to false so bindings dispatch and display as if on US-ANSI + * regardless of the current input source. */ + adaptiveLayoutEnabled: boolean; + setAdaptiveLayoutEnabled: (enabled: boolean) => void; +} + +export const useKeyboardPreferencesStore = create()( + persist( + (set) => ({ + adaptiveLayoutEnabled: false, + setAdaptiveLayoutEnabled: (enabled) => + set({ adaptiveLayoutEnabled: enabled }), + }), + { + name: "keyboard-preferences", + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + adaptiveLayoutEnabled: state.adaptiveLayoutEnabled, + }), + }, + ), +); diff --git a/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts b/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts index 42a4585426c..e5a6c44912d 100644 --- a/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts +++ b/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts @@ -1,9 +1,19 @@ import { HOTKEYS, type HotkeyId } from "../registry"; import { useHotkeyOverridesStore } from "../stores/hotkeyOverridesStore"; import { useKeyboardLayoutStore } from "../stores/keyboardLayoutStore"; +import { useKeyboardPreferencesStore } from "../stores/keyboardPreferencesStore"; import type { ShortcutBinding } from "../types"; import { bindingToDispatchChord } from "./binding"; +/** Layout map for the resolver — null when the user has disabled adaptive + * layout mapping, so logical bindings keep their authored chord. */ +function activeLayoutMap(): ReadonlyMap | null { + if (!useKeyboardPreferencesStore.getState().adaptiveLayoutEnabled) { + return null; + } + return useKeyboardLayoutStore.getState().map; +} + /** * KeyboardEvent → registered {@link HotkeyId}, or `null` if unbound. Uses the * same `event.code` normalization as react-hotkeys-hook so the reverse index @@ -134,21 +144,28 @@ function buildRegisteredAppChords( return map; } -// Reassigned on each override OR layout change; `let` is required so the -// subscribe callbacks can replace the reference the resolver reads. +// Reassigned on each override, layout, OR adaptive-layout-toggle change; +// `let` is required so the subscribe callbacks can replace the reference +// the resolver reads. let registeredAppChords = buildRegisteredAppChords( useHotkeyOverridesStore.getState().overrides, - useKeyboardLayoutStore.getState().map, + activeLayoutMap(), ); useHotkeyOverridesStore.subscribe((state) => { registeredAppChords = buildRegisteredAppChords( state.overrides, - useKeyboardLayoutStore.getState().map, + activeLayoutMap(), + ); +}); +useKeyboardLayoutStore.subscribe(() => { + registeredAppChords = buildRegisteredAppChords( + useHotkeyOverridesStore.getState().overrides, + activeLayoutMap(), ); }); -useKeyboardLayoutStore.subscribe((state) => { +useKeyboardPreferencesStore.subscribe(() => { registeredAppChords = buildRegisteredAppChords( useHotkeyOverridesStore.getState().overrides, - state.map, + activeLayoutMap(), ); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx index debcaa1c331..992d9a59b55 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx @@ -9,7 +9,9 @@ import { import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; import { Kbd, KbdGroup } from "@superset/ui/kbd"; +import { Label } from "@superset/ui/label"; import { toast } from "@superset/ui/sonner"; +import { Switch } from "@superset/ui/switch"; import { cn } from "@superset/ui/utils"; import { createFileRoute } from "@tanstack/react-router"; import { useMemo, useState } from "react"; @@ -22,6 +24,7 @@ import { useFormatBinding, useHotkeyDisplay, useHotkeyOverridesStore, + useKeyboardPreferencesStore, useRecordHotkeys, } from "renderer/hotkeys"; @@ -137,6 +140,13 @@ function KeyboardShortcutsPage() { const resetAll = useHotkeyOverridesStore((s) => s.resetAll); const setOverride = useHotkeyOverridesStore((s) => s.setOverride); + const adaptiveLayoutEnabled = useKeyboardPreferencesStore( + (s) => s.adaptiveLayoutEnabled, + ); + const setAdaptiveLayoutEnabled = useKeyboardPreferencesStore( + (s) => s.setAdaptiveLayoutEnabled, + ); + useRecordHotkeys(recordingId, { // New printable bindings follow the printed character (matches what the // user sees on their keyboard). F-keys / named keys are forced to @@ -215,6 +225,25 @@ function KeyboardShortcutsPage() { + {/* Preferences */} +
+
+ +

+ Remap shortcuts to your current keyboard layout (e.g. ⌘Z dispatches + to physical KeyY on QWERTZ). When off, shortcuts always match the + keys you bound. +

+
+ +
+ {/* Search */}