Skip to content

fix(client): show keybind labels using the user's actual keyboard layout#3993

Open
vansszh wants to merge 3 commits into
openfrontio:mainfrom
vansszh:fix/1071-keybind-layout-display
Open

fix(client): show keybind labels using the user's actual keyboard layout#3993
vansszh wants to merge 3 commits into
openfrontio:mainfrom
vansszh:fix/1071-keybind-layout-display

Conversation

@vansszh
Copy link
Copy Markdown
Contributor

@vansszh vansszh commented May 23, 2026

Resolves #1071

Description:

On AZERTY, Dvorak, Russian and other non-QWERTY layouts the game currently shows the QWERTY letter for default keybinds even though detection already uses
e.code (physical position). A French user's moveUp keybind correctly fires on the top-left letter key, but the settings UI, help modal, and build-menu HUD
all label it W instead of Z (the character actually printed on that key).

This PR wires every keybind display through the Keyboard Layout Map API so labels match the
user's physical keyboard. On browsers without the API (Firefox, Safari) the existing QWERTY-style fallback runs, so behavior is unchanged for them.

What changed

  • New utility src/client/utilities/KeyboardLayout.ts wraps navigator.keyboard.getLayoutMap(), caches the result, listens for the layoutchange event for
    OS-level layout switches, and exposes loadKeyboardLayout(), getKeyForCode(code), and subscribeToLayoutChange(cb).
  • formatKeyForDisplay in Utils.ts consults the layout map first and falls through to the existing code-stripping when the API is unavailable or the map is
    still loading.
  • HelpModal.getKeyLabel uses the layout map for letter and digit codes; the existing special-label table (Shift, arrows, Return, etc.) takes precedence.
  • UnitDisplay (build-menu HUD) gets a small hotkeyLabel helper that prefers the user's saved character when they have rebound the action and otherwise
    translates the default code through the current layout.
  • SettingKeybind subscribes to layoutchange so the in-settings keybind labels stay in sync if the user switches layouts mid-session.
  • Main.ts preloads the layout map at bootstrap so the first paint is already layout-aware in supported browsers.
  • A monotonic generation counter prevents an older in-flight load from writing stale data to the cache after a newer layoutchange has started a fresh load.

What I tested

  • npm test - full suite at 1281 + 65 = 1346 passing, including 35 new tests for the layout-aware behavior and the overlap race.
  • npm run build-prod - succeeds (tsc + vite).
  • npx eslint - clean.
  • npx prettier --check on the touched files - clean.

Compatibility notes

  • Chromium browsers (Chrome, Edge, Opera, Brave): full benefit; codes are translated through the user's actual layout.
  • Firefox / Safari: navigator.keyboard is undefined; getKeyForCode returns null and every caller falls through to the existing QWERTY-style code
    stripping. Zero behavior change for those users.
  • The layoutchange event has been removed from some Chromium versions for fingerprinting reasons; we listen defensively (try/catch) and degrade gracefully.

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

@vansszh

On AZERTY, Dvorak, Russian and other non-QWERTY layouts the game
currently shows the QWERTY letter for default keybinds even though
detection already uses `e.code` (physical position). For a French
user the moveUp keybind correctly fires on the top-left letter key
but the settings UI, help modal, and build-menu HUD all label it
"W" instead of "Z" (the character actually printed on that key).

Wire all keybind displays through the new Keyboard Layout Map API:

- New utility `src/client/utilities/KeyboardLayout.ts` — wraps
  `navigator.keyboard.getLayoutMap()`, caches the result, listens
  for the `layoutchange` event for OS-level layout switches, and
  exposes a small synchronous reader plus a subscribe helper.

- `formatKeyForDisplay` in `Utils.ts` now consults the layout
  map first and falls through to the existing QWERTY-style code
  stripping when the API is unavailable (Firefox, Safari) or while
  the map is still loading.

- `HelpModal.getKeyLabel` follows the same pattern, preserving
  the existing UI-specific labels for special keys (Shift, arrows,
  etc.) and only consulting the layout map for letter and digit
  codes.

- `UnitDisplay` (build-menu HUD) gets a `hotkeyLabel` helper that
  prefers the user's saved character when they have rebound the
  action and otherwise translates the default code through the
  current layout. Subscribes to `layoutchange` so the HUD
  re-renders without a reload.

- `SettingKeybind` subscribes to layout changes too, so the
  in-settings keybind labels update in lockstep.

- `Main.ts` preloads the layout map at bootstrap so the first
  paint is already layout-aware in supported browsers.

Tests cover the new utility (load, dedupe, error handling,
subscribe/unsubscribe, layoutchange invalidation), the layout-aware
branch of `formatKeyForDisplay`, and `HelpModal.getKeyLabel`.
33 new tests, full suite at 1279 + 65 = 1344 passing.

Resolves openfrontio#1071
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 23, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 23, 2026

Review Change Stack

Walkthrough

Adds keyboard layout awareness for displayed key labels: a new KeyboardLayout utility loads/caches the browser Layout Map, components subscribe to layout changes and re-render, formatters prefer mapped characters, bootstrap preloads the map, and tests cover loader, subscriptions, and formatting.

Changes

Keyboard Layout Support for Display Labels

Layer / File(s) Summary
KeyboardLayout utility module and caching infrastructure
src/client/utilities/KeyboardLayout.ts
New module wraps browser Keyboard Layout Map API with module-level caching, concurrent load deduplication, layout-change subscriptions, and test reset utilities. Exposes loadKeyboardLayout(), getKeyForCode(), and subscribeToLayoutChange().
Layout-aware key label formatting
src/client/Utils.ts, src/client/HelpModal.ts
formatKeyForDisplay() and HelpModal.getKeyLabel() now prefer layout-mapped characters via getKeyForCode() (uppercased) before falling back to existing normalization and special-case labels.
Component lifecycle integration with layout subscriptions
src/client/HelpModal.ts, src/client/components/baseComponents/setting/SettingKeybind.ts, src/client/hud/layers/UnitDisplay.ts
Adds connectedCallback()/disconnectedCallback() wiring: components subscribe/unsubscribe to layout changes, call loadKeyboardLayout() on connect, and call requestUpdate() on layout change.
Application bootstrap and hotkey rendering updates
src/client/Main.ts, src/client/hud/layers/UnitDisplay.ts
Adds non-awaited loadKeyboardLayout() in bootstrap() to preload map. Refactors UnitDisplay to use a hotkeyLabel(action, defaultCode) helper and uppercases prepared hotkey strings in rendering.
Test coverage for layout-aware formatters and HelpModal
tests/client/HelpModal.getKeyLabel.test.ts, tests/client/formatKeyForDisplay.test.ts
Vitest suites verify fallback normalization, layout-aware outputs with a mocked navigator.keyboard, Shift composition, and special-label precedence.
Comprehensive test coverage for KeyboardLayout module
tests/client/KeyboardLayout.test.ts
Vitest suite covers loader idempotency, concurrency deduplication, cache behavior, layoutchange handling and auto-reload, generation-guard behavior, subscription lifecycle, disposers, and error isolation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

"A tiny map from code to key,
Loads in the background silently,
Components listen, labels change,
QWERTY bows to what you see,
Keyboard sings in your layout's key."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: showing keybind labels using the user's actual keyboard layout instead of assuming QWERTY.
Linked Issues check ✅ Passed The PR fully addresses issue #1071: detection continues using e.code (physical keys), UI labels now use the Keyboard Layout Map API for layout-specific characters, special-key labels are preserved, fallback behavior is maintained, and layoutchange events are handled.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing keyboard layout support: new KeyboardLayout utility, updates to formatKeyForDisplay, HelpModal, UnitDisplay, SettingKeybind, Main.ts bootstrap, and comprehensive tests. No unrelated modifications detected.
Description check ✅ Passed The description clearly explains the keyboard layout mapping feature, implementation details, testing approach, and browser compatibility considerations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/utilities/KeyboardLayout.ts`:
- Around line 64-68: onLayoutChange currently clears cachedLayout and
pendingLoad and notifies subscribers but does not start a new load, which can
leave getKeyForCode returning null; modify onLayoutChange to immediately kick
off a new load by calling the layout-loading function (e.g., loadLayout or
whatever async loader is present) after clearing cachedLayout/pendingLoad,
assign its Promise to pendingLoad and handle errors (log or set cachedLayout to
a safe value), then call notifySubscribers so subscribers get the refreshed map;
reference: onLayoutChange, cachedLayout, pendingLoad, getKeyForCode,
notifySubscribers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8dcaa47a-520f-4517-a346-e1bb2305b3ae

📥 Commits

Reviewing files that changed from the base of the PR and between a14cf0e and 30875b1.

📒 Files selected for processing (9)
  • src/client/HelpModal.ts
  • src/client/Main.ts
  • src/client/Utils.ts
  • src/client/components/baseComponents/setting/SettingKeybind.ts
  • src/client/hud/layers/UnitDisplay.ts
  • src/client/utilities/KeyboardLayout.ts
  • tests/client/HelpModal.getKeyLabel.test.ts
  • tests/client/KeyboardLayout.test.ts
  • tests/client/formatKeyForDisplay.test.ts

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management May 23, 2026
Per CodeRabbit feedback on openfrontio#3993 (and on reflection it's correct):
`onLayoutChange` cleared the cache and notified subscribers but did not
kick off a fresh load, so `getKeyForCode` would return null indefinitely
after an OS layout switch — components would re-render with the QWERTY
fallback and stay there until something else triggered a load.

Now `onLayoutChange` does both: it notifies subscribers immediately
(forcing a re-render with the fallback, so labels visibly update rather
than holding stale chars from the old layout), then calls
`loadKeyboardLayout` which fetches the new map and notifies subscribers
again from its `.finally` hook. End result: components flicker through
fallback for one paint, then settle on the new layout's characters.

Tests: replaced the cache-invalidation test (which relied on a manual
follow-up `loadKeyboardLayout` call) with one assertion for the
synchronous fallback state and a new test that awaits the auto-triggered
reload via a subscriber callback and verifies both the post-reload map
contents and the call count on the underlying API.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/client/KeyboardLayout.test.ts (1)

177-208: ⚡ Quick win

Add one overlap test for layoutchange during an in-flight initial load

This suite covers post-load behavior well, but it does not cover the overlap case where layoutchange fires before the first getLayoutMap() resolves. A deferred-first-call test would lock in correct behavior and catch stale-write races.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/client/KeyboardLayout.test.ts` around lines 177 - 208, Add a test that
simulates a layoutchange firing while the initial loadKeyboardLayout() is still
pending to ensure no stale-write race: use installFakeKeyboard to return a
deferred Promise from fake.getLayoutMap() for the first call, call
loadKeyboardLayout() (but do not resolve yet), then emitLayoutChange() to
trigger the "cache cleared" notification and assert subscribers see the cleared
state (getKeyForCode returns null), then resolve the deferred first
getLayoutMap, wait for the subsequent notification and verify the final layout
is the resolved value; also assert fake.getLayoutMap was called the expected
number of times and subscribeToLayoutChange is used to observe the two
notifications.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/utilities/KeyboardLayout.ts`:
- Around line 64-73: The current onLayoutChange -> loadKeyboardLayout flow can
allow an earlier performLoad() to overwrite cachedLayout/pendingLoad after a
newer load started; add a generation guard: introduce a
monotonically-incremented loadGeneration (module-scope), increment it in
onLayoutChange before calling loadKeyboardLayout(), capture the current
generation inside performLoad()/the promise chain (e.g., localGen =
loadGeneration) and only commit results to cachedLayout and clear pendingLoad
when localGen === loadGeneration; ensure loadKeyboardLayout, performLoad,
cachedLayout and pendingLoad are referenced so the check prevents stale writes
from older loads.

---

Nitpick comments:
In `@tests/client/KeyboardLayout.test.ts`:
- Around line 177-208: Add a test that simulates a layoutchange firing while the
initial loadKeyboardLayout() is still pending to ensure no stale-write race: use
installFakeKeyboard to return a deferred Promise from fake.getLayoutMap() for
the first call, call loadKeyboardLayout() (but do not resolve yet), then
emitLayoutChange() to trigger the "cache cleared" notification and assert
subscribers see the cleared state (getKeyForCode returns null), then resolve the
deferred first getLayoutMap, wait for the subsequent notification and verify the
final layout is the resolved value; also assert fake.getLayoutMap was called the
expected number of times and subscribeToLayoutChange is used to observe the two
notifications.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ac9eabd2-8bf9-45e2-9995-51efb1a2fab7

📥 Commits

Reviewing files that changed from the base of the PR and between 30875b1 and c929e78.

📒 Files selected for processing (2)
  • src/client/utilities/KeyboardLayout.ts
  • tests/client/KeyboardLayout.test.ts

Comment thread src/client/utilities/KeyboardLayout.ts
Per CodeRabbit's follow-up on openfrontio#3993: the previous version started a new
`loadKeyboardLayout` in `onLayoutChange` but did nothing to prevent an
earlier in-flight `performLoad` from later resolving and writing its
stale map back into `cachedLayout`, briefly corrupting the cache before
the newer load eventually overwrote it.

Add a module-scope `loadGeneration` counter that bumps on every
`layoutchange`. `performLoad` captures the generation at start and
skips committing its result (or its error-clear) if the counter has
advanced while it was awaiting `getLayoutMap`. Net effect: only the
newest load can ever publish to `cachedLayout`.

Tests: new case stages two deferred `getLayoutMap` calls, kicks off
load openfrontio#1, fires `layoutchange` (which starts load openfrontio#2), resolves load openfrontio#1
with a marker "stale" map, and asserts the cache rejects it. After
load openfrontio#2 resolves, the cache holds the correct fresh map. Sanity-checked
that removing the guard makes this test fail with `expected 'stale' not
to be 'stale'`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

keyboard shortcuts: use code for detection and key for display

2 participants