diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index acba57635f..ac4de595c6 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -8,6 +8,11 @@ import "./components/Difficulties"; import { modalHeader } from "./components/ui/ModalHeader"; import { Platform } from "./Platform"; import { TroubleshootingModal } from "./TroubleshootingModal"; +import { + getKeyForCode, + loadKeyboardLayout, + subscribeToLayoutChange, +} from "./utilities/KeyboardLayout"; @customElement("help-modal") export class HelpModal extends BaseModal { @@ -16,6 +21,24 @@ export class HelpModal extends BaseModal { @state() private keybinds: Record = this.getKeybinds(); @query("#tutorial-video-iframe") private videoIframe?: HTMLIFrameElement; + private unsubscribeLayout: (() => void) | null = null; + + connectedCallback() { + super.connectedCallback(); + this.unsubscribeLayout = subscribeToLayoutChange(() => { + // Re-render so getKeyLabel picks up the new layout-mapped characters. + this.requestUpdate(); + }); + // Kick off the load if it hasn't happened yet (idempotent). + void loadKeyboardLayout(); + } + + disconnectedCallback() { + this.unsubscribeLayout?.(); + this.unsubscribeLayout = null; + super.disconnectedCallback(); + } + private getKeybinds(): Record { return new UserSettings().keybinds(Platform.isMac); } @@ -44,6 +67,12 @@ export class HelpModal extends BaseModal { }; if (specialLabels[code]) return specialLabels[code]; + + // Use the user's actual keyboard layout when available (Chromium browsers). + // E.g. on AZERTY, "KeyW" → "Z" instead of the QWERTY-encoded "W". + const layoutChar = getKeyForCode(code); + if (layoutChar) return layoutChar.toUpperCase(); + if (code.startsWith("Key") && code.length === 4) return code.slice(3); if (code.startsWith("Digit")) return code.slice(5); if (code.startsWith("Numpad")) return `Num ${code.slice(6)}`; diff --git a/src/client/Main.ts b/src/client/Main.ts index bd73b5f46c..04cebc29d5 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -67,6 +67,7 @@ import { translateText, } from "./Utils"; import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom"; +import { loadKeyboardLayout } from "./utilities/KeyboardLayout"; import "./components/DesktopNavBar"; import "./components/Footer"; @@ -1073,6 +1074,13 @@ const bootstrap = () => { // on iOS and can softlock the HUD. See issue #2330. installSafariPinchZoomBlocker(); + // Pre-load the keyboard layout map so keybind labels (settings, help + // modal, build-menu HUD) show the user's actual keyboard characters + // (e.g. "Z" on AZERTY instead of the QWERTY-encoded "W"). Fire-and-forget; + // any not-yet-loaded display falls back to QWERTY and re-renders when the + // map resolves. See issue #1071. + void loadKeyboardLayout(); + initLayout(); new Client().initialize(); initNavigation(); diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 3ecd016af2..851c18c6d4 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -12,6 +12,7 @@ import { import { GameConfig } from "../core/Schemas"; import type { LangSelector } from "./LangSelector"; import { Platform } from "./Platform"; +import { getKeyForCode } from "./utilities/KeyboardLayout"; export const TUTORIAL_VIDEO_URL = "https://www.youtube.com/embed/EN2oOog3pSs"; @@ -310,6 +311,15 @@ export function formatPercentage(value: number): string { * Formats a keyboard key code for user-friendly display. * Handles empty values, spaces, and normalizes key codes like "Digit1" and "KeyA". * + * When the [Keyboard Layout Map API][1] is available (Chromium browsers + * after {@link loadKeyboardLayout} resolves), letter and digit codes are + * resolved to the character produced by the user's actual keyboard layout + * — e.g. `"KeyW"` returns `"Z"` on AZERTY, `","` on Dvorak. Other browsers + * (Firefox, Safari) and not-yet-loaded states fall back to the QWERTY + * letter encoded in the code itself. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/API/Keyboard + * * @param value - The key code to format (e.g., "Digit1", "KeyA", "Space") * @returns The formatted key for display (e.g., "1", "A", "Space") * @@ -333,6 +343,14 @@ export function formatKeyForDisplay(value: string): string { // Handle space character or "Space" key if (value === " " || value === "Space") return "Space"; + // Prefer the user's actual keyboard layout (e.g. "KeyW" -> "z" on AZERTY). + // Returns null on Firefox/Safari or while the map is still loading; we + // fall through to the QWERTY-style normalization below in that case. + const layoutChar = getKeyForCode(value); + if (layoutChar) { + return layoutChar.toUpperCase(); + } + // Handle DigitN pattern (e.g., "Digit1" -> "1") if (/^Digit\d$/.test(value)) { return value.replace("Digit", ""); diff --git a/src/client/components/baseComponents/setting/SettingKeybind.ts b/src/client/components/baseComponents/setting/SettingKeybind.ts index 9725db81ff..66a9760c13 100644 --- a/src/client/components/baseComponents/setting/SettingKeybind.ts +++ b/src/client/components/baseComponents/setting/SettingKeybind.ts @@ -1,6 +1,10 @@ import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { formatKeyForDisplay, translateText } from "../../../../client/Utils"; +import { + loadKeyboardLayout, + subscribeToLayoutChange, +} from "../../../../client/utilities/KeyboardLayout"; @customElement("setting-keybind") export class SettingKeybind extends LitElement { @@ -12,10 +16,29 @@ export class SettingKeybind extends LitElement { @property({ type: String }) display = ""; @property({ type: Boolean }) easter = false; + private unsubscribeLayout: (() => void) | null = null; + createRenderRoot() { return this; } + connectedCallback() { + super.connectedCallback(); + // Re-render when the keyboard layout map loads, or the user switches + // layouts at the OS level, so the displayed character matches the + // user's actual keyboard. + this.unsubscribeLayout = subscribeToLayoutChange(() => { + this.requestUpdate(); + }); + void loadKeyboardLayout(); + } + + disconnectedCallback() { + this.unsubscribeLayout?.(); + this.unsubscribeLayout = null; + super.disconnectedCallback(); + } + private listening = false; render() { diff --git a/src/client/hud/layers/UnitDisplay.ts b/src/client/hud/layers/UnitDisplay.ts index 8714d51b65..7e92ac0661 100644 --- a/src/client/hud/layers/UnitDisplay.ts +++ b/src/client/hud/layers/UnitDisplay.ts @@ -14,7 +14,11 @@ import { UserSettings } from "../../../core/game/UserSettings"; import { Controller } from "../../Controller"; import { ToggleStructureEvent } from "../../InputHandler"; import { UIState } from "../../UIState"; -import { renderNumber, translateText } from "../../Utils"; +import { formatKeyForDisplay, renderNumber, translateText } from "../../Utils"; +import { + loadKeyboardLayout, + subscribeToLayoutChange, +} from "../../utilities/KeyboardLayout"; const warshipIcon = assetUrl("images/BattleshipIconWhite.svg"); const cityIcon = assetUrl("images/CityIconWhite.svg"); const factoryIcon = assetUrl("images/FactoryIconWhite.svg"); @@ -34,6 +38,7 @@ export class UnitDisplay extends LitElement implements Controller { public uiState: UIState; private playerBuildables: BuildableUnit[] | null = null; private keybinds: Record = {}; + private unsubscribeLayout: (() => void) | null = null; private _cities = 0; private _warships = 0; private _factories = 0; @@ -55,9 +60,35 @@ export class UnitDisplay extends LitElement implements Controller { this.keybinds = userSettings.parsedUserKeybinds(); this.allDisabled = BuildMenus.types.every((u) => config.isUnitDisabled(u)); + // Re-render when the keyboard layout map loads or the user switches + // layouts, so the hotkey hints (e.g. "1"/"2"/.../"W") match what the + // user has printed on their physical keys. + this.unsubscribeLayout?.(); + this.unsubscribeLayout = subscribeToLayoutChange(() => { + this.requestUpdate(); + }); + void loadKeyboardLayout(); this.requestUpdate(); } + disconnectedCallback() { + this.unsubscribeLayout?.(); + this.unsubscribeLayout = null; + super.disconnectedCallback(); + } + + /** + * Returns the character to display next to a build-menu icon. + * Prefers the user's own saved character when they have rebound the + * action; otherwise translates the default code (e.g. "Digit1") through + * the current keyboard layout via {@link formatKeyForDisplay}. + */ + private hotkeyLabel(action: string, defaultCode: string): string { + const userKey = this.keybinds[action]?.key; + if (userKey) return userKey.toUpperCase(); + return formatKeyForDisplay(defaultCode); + } + private cost(item: UnitType): Gold { for (const bu of this.playerBuildables ?? []) { if (bu.type === item) { @@ -128,70 +159,70 @@ export class UnitDisplay extends LitElement implements Controller { this._cities, UnitType.City, "city", - this.keybinds["buildCity"]?.key ?? "1", + this.hotkeyLabel("buildCity", "Digit1"), )} ${this.renderUnitItem( factoryIcon, this._factories, UnitType.Factory, "factory", - this.keybinds["buildFactory"]?.key ?? "2", + this.hotkeyLabel("buildFactory", "Digit2"), )} ${this.renderUnitItem( portIcon, this._port, UnitType.Port, "port", - this.keybinds["buildPort"]?.key ?? "3", + this.hotkeyLabel("buildPort", "Digit3"), )} ${this.renderUnitItem( defensePostIcon, this._defensePost, UnitType.DefensePost, "defense_post", - this.keybinds["buildDefensePost"]?.key ?? "4", + this.hotkeyLabel("buildDefensePost", "Digit4"), )} ${this.renderUnitItem( missileSiloIcon, this._missileSilo, UnitType.MissileSilo, "missile_silo", - this.keybinds["buildMissileSilo"]?.key ?? "5", + this.hotkeyLabel("buildMissileSilo", "Digit5"), )} ${this.renderUnitItem( samLauncherIcon, this._samLauncher, UnitType.SAMLauncher, "sam_launcher", - this.keybinds["buildSamLauncher"]?.key ?? "6", + this.hotkeyLabel("buildSamLauncher", "Digit6"), )} ${this.renderUnitItem( warshipIcon, this._warships, UnitType.Warship, "warship", - this.keybinds["buildWarship"]?.key ?? "7", + this.hotkeyLabel("buildWarship", "Digit7"), )} ${this.renderUnitItem( atomBombIcon, null, UnitType.AtomBomb, "atom_bomb", - this.keybinds["buildAtomBomb"]?.key ?? "8", + this.hotkeyLabel("buildAtomBomb", "Digit8"), )} ${this.renderUnitItem( hydrogenBombIcon, null, UnitType.HydrogenBomb, "hydrogen_bomb", - this.keybinds["buildHydrogenBomb"]?.key ?? "9", + this.hotkeyLabel("buildHydrogenBomb", "Digit9"), )} ${this.renderUnitItem( mirvIcon, null, UnitType.MIRV, "mirv", - this.keybinds["buildMIRV"]?.key ?? "0", + this.hotkeyLabel("buildMIRV", "Digit0"), )} @@ -210,10 +241,9 @@ export class UnitDisplay extends LitElement implements Controller { } const selected = this.uiState.ghostStructure === unitType; const hovered = this._hoveredUnit === unitType; - const displayHotkey = hotkey - .replace("Digit", "") - .replace("Key", "") - .toUpperCase(); + // hotkey already comes from hotkeyLabel() pre-formatted via the layout + // map; uppercase it here as a final-line normalization. + const displayHotkey = hotkey.toUpperCase(); return html`
>; +} + +let cachedLayout: ReadonlyMap | null = null; +let pendingLoad: Promise | null = null; +let layoutChangeBound = false; +// Monotonic counter incremented on every `layoutchange` so an older, +// still-in-flight `performLoad` that resolves *after* a newer load has +// started cannot overwrite `cachedLayout` with stale data. +let loadGeneration = 0; +const subscribers = new Set<() => void>(); + +function getKeyboardApi(): KeyboardApi | null { + if (typeof navigator === "undefined") return null; + const k = (navigator as unknown as { keyboard?: KeyboardApi }).keyboard; + if (!k || typeof k.getLayoutMap !== "function") return null; + return k; +} + +function notifySubscribers(): void { + // Iterate over a snapshot so a subscriber that unsubscribes during + // notification doesn't skip its successors. + for (const cb of [...subscribers]) { + try { + cb(); + } catch (e) { + console.error("KeyboardLayout subscriber threw:", e); + } + } +} + +function bindLayoutChange(api: KeyboardApi): void { + if (layoutChangeBound) return; + try { + api.addEventListener("layoutchange", onLayoutChange); + layoutChangeBound = true; + } catch { + // Some browsers expose getLayoutMap but not the event - that's + // fine, the cache just won't auto-invalidate. + } +} + +function onLayoutChange(): void { + // Bump the generation *before* clearing state so any in-flight + // `performLoad` that resolves after this point sees a mismatched + // generation and skips its commit. + loadGeneration += 1; + cachedLayout = null; + pendingLoad = null; + // Notify subscribers immediately so components fall back to the QWERTY + // path while the new layout is being fetched, then kick off a fresh load + // — `loadKeyboardLayout` will notify subscribers again from its `.finally` + // hook so labels update once the new map resolves. + notifySubscribers(); + void loadKeyboardLayout(); +} + +async function performLoad(): Promise { + // Capture the generation at the start of this load. If `onLayoutChange` + // bumps the counter while we await `getLayoutMap`, the generation we + // captured will no longer match and we must drop our result rather + // than overwrite a fresher load's cache. + const startGeneration = loadGeneration; + const api = getKeyboardApi(); + if (!api) return; + bindLayoutChange(api); + try { + const map = await api.getLayoutMap(); + if (startGeneration !== loadGeneration) return; + cachedLayout = map; + } catch (e) { + if (startGeneration !== loadGeneration) return; + console.warn("Failed to load keyboard layout map:", e); + cachedLayout = null; + } +} + +/** + * Loads the keyboard layout map. Idempotent — concurrent calls + * share the same in-flight Promise, and subsequent calls after a + * successful load resolve immediately. + * + * Awaiting this is optional. Callers that can render synchronously + * with a fallback (e.g. {@link getKeyForCode} returning `null` → + * fall back to the QWERTY character) need only call this once at + * bootstrap and let the eventual update flow through subscribers. + */ +export function loadKeyboardLayout(): Promise { + if (cachedLayout !== null) return Promise.resolve(); + pendingLoad ??= performLoad().finally(() => { + // Notify even on failure — subscribers should re-render with the + // fallback path rather than waiting forever. + notifySubscribers(); + }); + return pendingLoad; +} + +/** + * Returns the character produced by the user's keyboard for the + * given physical key code, or `null` if: + * - the browser does not implement the Keyboard Layout Map API, + * - {@link loadKeyboardLayout} has not yet resolved, or + * - the layout map has no entry for this code (rare — usually + * means the code refers to a non-printable key). + * + * Callers should treat `null` as "fall back to the QWERTY default + * encoded in the code". + * + * Synchronous; does **not** trigger a load. Pair with + * {@link subscribeToLayoutChange} to re-render when the load + * resolves. + * + * @param code - A `KeyboardEvent.code` value such as `"KeyW"`, + * `"Digit1"`, `"Period"`, `"Slash"`. + */ +export function getKeyForCode(code: string): string | null { + if (!cachedLayout) return null; + const ch = cachedLayout.get(code); + return typeof ch === "string" && ch.length > 0 ? ch : null; +} + +/** + * Subscribe to layout-availability changes. The callback fires: + * 1. Once after {@link loadKeyboardLayout} resolves (success + * _or_ failure — the cache state is now final). + * 2. Each time the OS emits a `layoutchange` event on the + * Keyboard API (where supported). + * + * The callback receives no arguments; query the new state via + * {@link getKeyForCode}. + * + * @returns A disposer that removes the subscription. + */ +export function subscribeToLayoutChange(callback: () => void): () => void { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; +} + +/** + * @internal Test-only: reset module-level state between test cases. + * Not part of the public API. + */ +export function _resetForTesting(): void { + const api = getKeyboardApi(); + if (api && layoutChangeBound) { + try { + api.removeEventListener("layoutchange", onLayoutChange); + } catch { + // best-effort + } + } + cachedLayout = null; + pendingLoad = null; + layoutChangeBound = false; + loadGeneration = 0; + subscribers.clear(); +} diff --git a/tests/client/HelpModal.getKeyLabel.test.ts b/tests/client/HelpModal.getKeyLabel.test.ts new file mode 100644 index 0000000000..f7c3081009 --- /dev/null +++ b/tests/client/HelpModal.getKeyLabel.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { HelpModal } from "../../src/client/HelpModal"; +import { + _resetForTesting, + loadKeyboardLayout, +} from "../../src/client/utilities/KeyboardLayout"; + +interface FakeKeyboard { + getLayoutMap(): Promise>; + addEventListener(): void; + removeEventListener(): void; +} + +function installLayout(layout: Record): void { + const fake: FakeKeyboard = { + getLayoutMap: () => Promise.resolve(new Map(Object.entries(layout))), + addEventListener: () => {}, + removeEventListener: () => {}, + }; + Object.defineProperty(navigator, "keyboard", { + configurable: true, + value: fake, + }); +} + +function removeLayout(): void { + Object.defineProperty(navigator, "keyboard", { + configurable: true, + value: undefined, + }); + delete (navigator as Partial<{ keyboard: unknown }>).keyboard; +} + +function makeModal(): HelpModal { + const modal = new HelpModal(); + // Bypass connectedCallback / DOM upgrade — we only exercise getKeyLabel. + return modal; +} + +describe("HelpModal.getKeyLabel", () => { + beforeEach(() => { + _resetForTesting(); + }); + + afterEach(() => { + _resetForTesting(); + removeLayout(); + }); + + describe("special labels (UI-specific)", () => { + it("returns the styled Shift label for ShiftLeft / ShiftRight", () => { + const m = makeModal(); + expect( + (m as unknown as { getKeyLabel(c: string): string }).getKeyLabel( + "ShiftLeft", + ), + ).toBe("⇧ Shift"); + expect( + (m as unknown as { getKeyLabel(c: string): string }).getKeyLabel( + "ShiftRight", + ), + ).toBe("⇧ Shift"); + }); + + it("returns arrows for arrow codes", () => { + const m = makeModal(); + const get = (code: string) => + (m as unknown as { getKeyLabel(c: string): string }).getKeyLabel(code); + expect(get("ArrowUp")).toBe("↑"); + expect(get("ArrowDown")).toBe("↓"); + expect(get("ArrowLeft")).toBe("←"); + expect(get("ArrowRight")).toBe("→"); + }); + + it("returns Esc / Enter / Space symbols", () => { + const m = makeModal(); + const get = (code: string) => + (m as unknown as { getKeyLabel(c: string): string }).getKeyLabel(code); + expect(get("Escape")).toBe("Esc"); + expect(get("Enter")).toBe("↵ Return"); + expect(get("Space")).toBe("Space"); + }); + }); + + describe("fallback path (no layout map)", () => { + it("strips the Key prefix for letter codes", () => { + const m = makeModal(); + const get = (code: string) => + (m as unknown as { getKeyLabel(c: string): string }).getKeyLabel(code); + expect(get("KeyA")).toBe("A"); + expect(get("KeyZ")).toBe("Z"); + }); + + it("strips the Digit prefix for digit codes", () => { + const m = makeModal(); + const get = (code: string) => + (m as unknown as { getKeyLabel(c: string): string }).getKeyLabel(code); + expect(get("Digit1")).toBe("1"); + expect(get("Digit0")).toBe("0"); + }); + + it("formats Numpad keys with a Num prefix", () => { + const m = makeModal(); + expect( + (m as unknown as { getKeyLabel(c: string): string }).getKeyLabel( + "Numpad7", + ), + ).toBe("Num 7"); + }); + + it("returns the empty string for empty input", () => { + const m = makeModal(); + expect( + (m as unknown as { getKeyLabel(c: string): string }).getKeyLabel(""), + ).toBe(""); + }); + }); + + describe("layout-aware path", () => { + it("returns the layout-mapped character when available", async () => { + installLayout({ KeyW: "z", KeyA: "q", Digit1: "&" }); + await loadKeyboardLayout(); + + const m = makeModal(); + const get = (code: string) => + (m as unknown as { getKeyLabel(c: string): string }).getKeyLabel(code); + // AZERTY positions: physical W key prints "Z", physical A key prints "Q". + expect(get("KeyW")).toBe("Z"); + expect(get("KeyA")).toBe("Q"); + expect(get("Digit1")).toBe("&"); + }); + + it("never overrides the special-label table even if the layout map has the code", async () => { + installLayout({ Space: "ignored", Escape: "ignored" }); + await loadKeyboardLayout(); + + const m = makeModal(); + const get = (code: string) => + (m as unknown as { getKeyLabel(c: string): string }).getKeyLabel(code); + expect(get("Space")).toBe("Space"); + expect(get("Escape")).toBe("Esc"); + }); + }); +}); diff --git a/tests/client/KeyboardLayout.test.ts b/tests/client/KeyboardLayout.test.ts new file mode 100644 index 0000000000..63249477fc --- /dev/null +++ b/tests/client/KeyboardLayout.test.ts @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + _resetForTesting, + getKeyForCode, + loadKeyboardLayout, + subscribeToLayoutChange, +} from "../../src/client/utilities/KeyboardLayout"; + +type LayoutChangeHandler = () => void; + +interface FakeKeyboard { + getLayoutMap: ReturnType; + addEventListener: ReturnType; + removeEventListener: ReturnType; + emitLayoutChange: () => void; +} + +function installFakeKeyboard(layout: Record): FakeKeyboard { + const handlers = new Set(); + const fake: FakeKeyboard = { + getLayoutMap: vi.fn().mockResolvedValue(new Map(Object.entries(layout))), + addEventListener: vi.fn((type: string, handler: LayoutChangeHandler) => { + if (type === "layoutchange") handlers.add(handler); + }), + removeEventListener: vi.fn((type: string, handler: LayoutChangeHandler) => { + if (type === "layoutchange") handlers.delete(handler); + }), + emitLayoutChange: () => { + for (const h of [...handlers]) h(); + }, + }; + Object.defineProperty(navigator, "keyboard", { + configurable: true, + value: fake, + }); + return fake; +} + +function removeFakeKeyboard(): void { + if ("keyboard" in navigator) { + Object.defineProperty(navigator, "keyboard", { + configurable: true, + value: undefined, + }); + delete (navigator as Partial<{ keyboard: unknown }>).keyboard; + } +} + +describe("KeyboardLayout", () => { + beforeEach(() => { + _resetForTesting(); + }); + + afterEach(() => { + _resetForTesting(); + removeFakeKeyboard(); + vi.restoreAllMocks(); + }); + + describe("getKeyForCode (before load)", () => { + it("returns null when the layout has not been loaded yet", () => { + installFakeKeyboard({ KeyW: "z" }); + expect(getKeyForCode("KeyW")).toBeNull(); + }); + + it("returns null when the API is unavailable", () => { + removeFakeKeyboard(); + expect(getKeyForCode("KeyW")).toBeNull(); + }); + }); + + describe("loadKeyboardLayout", () => { + it("populates the cache when the API resolves", async () => { + installFakeKeyboard({ KeyW: "z", KeyA: "q", Digit1: "&" }); + await loadKeyboardLayout(); + + expect(getKeyForCode("KeyW")).toBe("z"); + expect(getKeyForCode("KeyA")).toBe("q"); + expect(getKeyForCode("Digit1")).toBe("&"); + }); + + it("returns null for unknown codes after load", async () => { + installFakeKeyboard({ KeyW: "z" }); + await loadKeyboardLayout(); + + expect(getKeyForCode("Unmapped")).toBeNull(); + }); + + it("is a no-op when navigator.keyboard is unavailable", async () => { + removeFakeKeyboard(); + await expect(loadKeyboardLayout()).resolves.toBeUndefined(); + expect(getKeyForCode("KeyW")).toBeNull(); + }); + + it("dedupes concurrent calls into a single getLayoutMap invocation", async () => { + const fake = installFakeKeyboard({ KeyW: "z" }); + + const [a, b, c] = [ + loadKeyboardLayout(), + loadKeyboardLayout(), + loadKeyboardLayout(), + ]; + await Promise.all([a, b, c]); + + expect(fake.getLayoutMap).toHaveBeenCalledTimes(1); + }); + + it("does not re-invoke getLayoutMap once the cache is populated", async () => { + const fake = installFakeKeyboard({ KeyW: "z" }); + await loadKeyboardLayout(); + await loadKeyboardLayout(); + await loadKeyboardLayout(); + expect(fake.getLayoutMap).toHaveBeenCalledTimes(1); + }); + + it("treats getLayoutMap rejection as 'unavailable' without crashing", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const failing: FakeKeyboard = { + getLayoutMap: vi.fn().mockRejectedValue(new Error("boom")), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + emitLayoutChange: () => {}, + }; + Object.defineProperty(navigator, "keyboard", { + configurable: true, + value: failing, + }); + + await loadKeyboardLayout(); + + expect(getKeyForCode("KeyW")).toBeNull(); + expect(warn).toHaveBeenCalled(); + }); + }); + + describe("subscribeToLayoutChange", () => { + it("notifies subscribers exactly once after the initial load resolves", async () => { + installFakeKeyboard({ KeyW: "z" }); + const cb = vi.fn(); + subscribeToLayoutChange(cb); + + await loadKeyboardLayout(); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("notifies subscribers when a layoutchange event fires", async () => { + const fake = installFakeKeyboard({ KeyW: "z" }); + await loadKeyboardLayout(); + + const cb = vi.fn(); + subscribeToLayoutChange(cb); + + fake.emitLayoutChange(); + expect(cb).toHaveBeenCalledTimes(1); + + fake.emitLayoutChange(); + expect(cb).toHaveBeenCalledTimes(2); + }); + + it("invalidates the cache when layoutchange fires so labels fall back to the QWERTY path", async () => { + const fake = installFakeKeyboard({ KeyW: "z" }); + await loadKeyboardLayout(); + expect(getKeyForCode("KeyW")).toBe("z"); + + // Simulate the user switching to QWERTY mid-session. The next + // getLayoutMap() invocation (kicked off automatically by + // onLayoutChange) will return the new map. + fake.getLayoutMap.mockResolvedValueOnce(new Map([["KeyW", "w"]])); + fake.emitLayoutChange(); + + // Cache is invalidated immediately so callers see the QWERTY fallback + // until the auto-triggered reload finishes. + expect(getKeyForCode("KeyW")).toBeNull(); + }); + + it("auto-reloads the layout map after layoutchange so subscribers receive the new layout without a manual call", async () => { + const fake = installFakeKeyboard({ KeyW: "z" }); + await loadKeyboardLayout(); + expect(getKeyForCode("KeyW")).toBe("z"); + + fake.getLayoutMap.mockResolvedValueOnce(new Map([["KeyW", "w"]])); + + // Subscribe so we can await the second (post-auto-reload) notification. + let resolvePostReload: () => void; + const postReload = new Promise((r) => { + resolvePostReload = r; + }); + let count = 0; + subscribeToLayoutChange(() => { + count += 1; + if (count === 2) resolvePostReload(); + }); + + fake.emitLayoutChange(); + + // First notification is the synchronous "cache cleared" one. + expect(count).toBe(1); + expect(getKeyForCode("KeyW")).toBeNull(); + + // Auto-triggered reload completes and notifies again with the new + // layout map populated. + await postReload; + expect(count).toBe(2); + expect(getKeyForCode("KeyW")).toBe("w"); + // getLayoutMap was called twice: the initial load + the auto-reload. + expect(fake.getLayoutMap).toHaveBeenCalledTimes(2); + }); + + it("does not let an older in-flight load overwrite the cache after layoutchange starts a newer load", async () => { + const fake = installFakeKeyboard({}); + + // Stage two getLayoutMap calls, both deferred so we can resolve them + // in a controlled order. + let resolveFirst!: (value: Map) => void; + let resolveSecond!: (value: Map) => void; + fake.getLayoutMap.mockImplementationOnce( + () => + new Promise>((r) => { + resolveFirst = r; + }), + ); + fake.getLayoutMap.mockImplementationOnce( + () => + new Promise>((r) => { + resolveSecond = r; + }), + ); + + // Start load #1 — will await `resolveFirst`. + void loadKeyboardLayout(); + + // Layoutchange before #1 resolves: cache invalidates and the auto- + // reload starts load #2, awaiting `resolveSecond`. + fake.emitLayoutChange(); + + // Resolve load #1 with stale data. The generation guard must drop it. + resolveFirst(new Map([["KeyW", "stale"]])); + // Flush microtasks so the .finally on load #1 runs. + await Promise.resolve(); + await Promise.resolve(); + + // Cache must NOT contain the stale value. + expect(getKeyForCode("KeyW")).not.toBe("stale"); + expect(getKeyForCode("KeyW")).toBeNull(); + + // Resolve load #2 with the correct, current layout. + resolveSecond(new Map([["KeyW", "w"]])); + await Promise.resolve(); + await Promise.resolve(); + + expect(getKeyForCode("KeyW")).toBe("w"); + expect(fake.getLayoutMap).toHaveBeenCalledTimes(2); + }); + + it("returns a disposer that removes the subscription", async () => { + installFakeKeyboard({ KeyW: "z" }); + const cb = vi.fn(); + const unsubscribe = subscribeToLayoutChange(cb); + unsubscribe(); + + await loadKeyboardLayout(); + expect(cb).not.toHaveBeenCalled(); + }); + + it("isolates subscriber errors so one bad callback does not block others", async () => { + installFakeKeyboard({ KeyW: "z" }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const good = vi.fn(); + subscribeToLayoutChange(() => { + throw new Error("oops"); + }); + subscribeToLayoutChange(good); + + await loadKeyboardLayout(); + + expect(good).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/client/formatKeyForDisplay.test.ts b/tests/client/formatKeyForDisplay.test.ts new file mode 100644 index 0000000000..4f32dad987 --- /dev/null +++ b/tests/client/formatKeyForDisplay.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { formatKeyForDisplay } from "../../src/client/Utils"; +import { + _resetForTesting, + loadKeyboardLayout, +} from "../../src/client/utilities/KeyboardLayout"; + +interface FakeKeyboard { + getLayoutMap(): Promise>; + addEventListener(): void; + removeEventListener(): void; +} + +function installLayout(layout: Record): void { + const fake: FakeKeyboard = { + getLayoutMap: () => Promise.resolve(new Map(Object.entries(layout))), + addEventListener: () => {}, + removeEventListener: () => {}, + }; + Object.defineProperty(navigator, "keyboard", { + configurable: true, + value: fake, + }); +} + +function removeLayout(): void { + Object.defineProperty(navigator, "keyboard", { + configurable: true, + value: undefined, + }); + delete (navigator as Partial<{ keyboard: unknown }>).keyboard; +} + +describe("formatKeyForDisplay", () => { + beforeEach(() => { + _resetForTesting(); + }); + + afterEach(() => { + _resetForTesting(); + removeLayout(); + }); + + describe("fallback (no layout map)", () => { + it("returns empty string for empty input", () => { + expect(formatKeyForDisplay("")).toBe(""); + }); + + it("normalizes Space variants", () => { + expect(formatKeyForDisplay("Space")).toBe("Space"); + expect(formatKeyForDisplay(" ")).toBe("Space"); + }); + + it("strips the Key prefix from letter codes", () => { + expect(formatKeyForDisplay("KeyA")).toBe("A"); + expect(formatKeyForDisplay("KeyZ")).toBe("Z"); + }); + + it("strips the Digit prefix from digit codes", () => { + expect(formatKeyForDisplay("Digit1")).toBe("1"); + expect(formatKeyForDisplay("Digit0")).toBe("0"); + }); + + it("preserves Shift+ prefix and recurses into the suffix", () => { + expect(formatKeyForDisplay("Shift+KeyR")).toBe("Shift+R"); + expect(formatKeyForDisplay("Shift+Digit5")).toBe("Shift+5"); + }); + + it("capitalizes the first letter for unrecognized codes", () => { + expect(formatKeyForDisplay("ArrowUp")).toBe("ArrowUp"); + expect(formatKeyForDisplay("escape")).toBe("Escape"); + expect(formatKeyForDisplay("period")).toBe("Period"); + }); + }); + + describe("with the Keyboard Layout Map API available", () => { + it("returns the layout-mapped character for letter codes", async () => { + installLayout({ + KeyW: "z", + KeyA: "q", + KeyS: "s", + KeyD: "d", + }); + await loadKeyboardLayout(); + + // AZERTY: physical W-position prints "Z", physical A-position prints "Q". + expect(formatKeyForDisplay("KeyW")).toBe("Z"); + expect(formatKeyForDisplay("KeyA")).toBe("Q"); + expect(formatKeyForDisplay("KeyS")).toBe("S"); + expect(formatKeyForDisplay("KeyD")).toBe("D"); + }); + + it("returns the layout-mapped character for digit codes", async () => { + // AZERTY top row: digits require shift; unshifted is a symbol. + installLayout({ Digit1: "&", Digit2: "é" }); + await loadKeyboardLayout(); + + expect(formatKeyForDisplay("Digit1")).toBe("&"); + expect(formatKeyForDisplay("Digit2")).toBe("É"); + }); + + it("composes Shift+ prefix with the layout-mapped suffix", async () => { + installLayout({ KeyR: "r" }); + await loadKeyboardLayout(); + + expect(formatKeyForDisplay("Shift+KeyR")).toBe("Shift+R"); + }); + + it("falls back to QWERTY when the layout map has no entry for the code", async () => { + installLayout({ KeyW: "z" }); + await loadKeyboardLayout(); + + // Not in the map → QWERTY fallback path runs. + expect(formatKeyForDisplay("KeyA")).toBe("A"); + expect(formatKeyForDisplay("Digit1")).toBe("1"); + }); + + it("does not consult the layout map for the special Space code", async () => { + installLayout({ Space: "ignored" }); + await loadKeyboardLayout(); + + expect(formatKeyForDisplay("Space")).toBe("Space"); + }); + }); +});