Skip to content
Open
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
1 change: 0 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,6 @@
"lucide-react": "^0.563.0",
"mastracode": "0.15.0-alpha.3",
"nanoid": "^5.1.6",
"native-keymap": "^3.3.9",
"node-addon-api": "^7.1.0",
"node-pty": "1.1.0",
"os-locale": "^6.0.2",
Expand Down
6 changes: 0 additions & 6 deletions apps/desktop/runtime-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,6 @@ const externalizedRuntimeModules: ExternalizedRuntimeModule[] = [
packagedCopies: [copyWholeModule("node-pty")],
asarUnpackGlobs: ["**/node_modules/node-pty/**/*"],
},
{
specifier: "native-keymap",
materialize: ["native-keymap"],
packagedCopies: [copyWholeModule("native-keymap")],
asarUnpackGlobs: ["**/node_modules/native-keymap/**/*"],
},
{
specifier: "@superset/macos-process-metrics",
materialize: ["@superset/macos-process-metrics"],
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { createDeviceRouter } from "./device";
import { createExternalRouter } from "./external";
import { createFilesystemRouter } from "./filesystem";
import { createHostServiceCoordinatorRouter } from "./host-service-coordinator";
import { createKeyboardLayoutRouter } from "./keyboardLayout";
import { createMenuRouter } from "./menu";
import { createMigrationRouter } from "./migration";
import { createNotificationsRouter } from "./notifications";
Expand Down Expand Up @@ -59,7 +58,6 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
uiState: createUiStateRouter(),
ringtone: createRingtoneRouter(getWindow),
hostServiceCoordinator: createHostServiceCoordinatorRouter(),
keyboardLayout: createKeyboardLayoutRouter(),
migration: createMigrationRouter(),
});
};
Expand Down
25 changes: 0 additions & 25 deletions apps/desktop/src/lib/trpc/routers/keyboardLayout.ts

This file was deleted.

101 changes: 0 additions & 101 deletions apps/desktop/src/main/lib/keyboardLayout.ts

This file was deleted.

113 changes: 7 additions & 106 deletions apps/desktop/src/renderer/hotkeys/display.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { formatHotkeyDisplay, glyphForCode } from "./display";
import { formatHotkeyDisplay } from "./display";

describe("formatHotkeyDisplay", () => {
it("formats a mac chord with modifier glyphs and no separator", () => {
Expand Down Expand Up @@ -42,111 +42,12 @@ describe("formatHotkeyDisplay", () => {
text: "Unassigned",
});
});
});

describe("glyphForCode", () => {
const usMap = new Map<string, string>([
["KeyA", "a"],
["KeyZ", "z"],
["Slash", "/"],
["Quote", "'"],
["Digit5", "5"],
]);
const qwertzMap = new Map<string, string>([
["KeyA", "a"],
["KeyZ", "y"], // QWERTZ — Y/Z swapped
["Slash", "-"],
["Quote", "ä"],
]);

it("returns null when no layout map provided", () => {
expect(glyphForCode("z", null)).toBeNull();
});

it("returns the printed glyph for a letter on the current layout", () => {
expect(glyphForCode("a", usMap)).toBe("A");
expect(glyphForCode("z", usMap)).toBe("Z");
expect(glyphForCode("z", qwertzMap)).toBe("Y");
});

it("returns the printed glyph for digits", () => {
expect(glyphForCode("5", usMap)).toBe("5");
});

it("returns the printed glyph for punctuation tokens", () => {
expect(glyphForCode("slash", usMap)).toBe("/");
expect(glyphForCode("slash", qwertzMap)).toBe("-");
});

it("returns null for special keys that don't have a printable glyph", () => {
expect(glyphForCode("enter", usMap)).toBeNull();
expect(glyphForCode("arrowup", usMap)).toBeNull();
expect(glyphForCode("escape", usMap)).toBeNull();
expect(glyphForCode("f5", usMap)).toBeNull();
});

it("returns null when the layout map has no entry for the code", () => {
expect(glyphForCode("z", new Map())).toBeNull();
});

it("returns null for multi-character (composing) glyphs", () => {
const composing = new Map<string, string>([["KeyA", "ʼa"]]);
expect(glyphForCode("a", composing)).toBeNull();
});

it("preserves non-ASCII glyphs that would expand on uppercase (ß, ı)", () => {
// "ß".toUpperCase() === "SS" in JS — would break single-keycap display
const german = new Map<string, string>([["KeyS", "ß"]]);
expect(glyphForCode("s", german)).toBe("ß");
const turkish = new Map<string, string>([["KeyI", "ı"]]);
expect(glyphForCode("i", turkish)).toBe("ı");
});
});

describe("formatHotkeyDisplay — layout-aware", () => {
const usMap = new Map<string, string>([
["KeyZ", "z"],
["Slash", "/"],
["BracketLeft", "["],
]);
const qwertzMap = new Map<string, string>([
["KeyZ", "y"],
["Slash", "-"],
]);

it("uses the layout glyph for printable keys when a map is provided", () => {
expect(formatHotkeyDisplay("meta+z", "mac", qwertzMap).text).toBe("⌘Y");
expect(formatHotkeyDisplay("ctrl+slash", "linux", qwertzMap).text).toBe(
"Ctrl+-",
);
});

it("falls back to KEY_DISPLAY when layoutMap is null (regression — current behavior)", () => {
expect(formatHotkeyDisplay("meta+z", "mac", null).text).toBe("⌘Z");
expect(formatHotkeyDisplay("ctrl+slash", "linux", null).text).toBe(
"Ctrl+/",
);
});

it("matches today's output for a US-equivalent map (no visible change)", () => {
expect(formatHotkeyDisplay("meta+z", "mac").text).toBe(
formatHotkeyDisplay("meta+z", "mac", usMap).text,
);
expect(formatHotkeyDisplay("ctrl+slash", "linux").text).toBe(
formatHotkeyDisplay("ctrl+slash", "linux", usMap).text,
);
});

it("special keys ignore layoutMap and keep their symbol", () => {
// Even if a malicious map tried to remap "Enter", we ignore it for
// special keys since glyphForCode returns null for them.
const weird = new Map<string, string>([["Enter", "X"]]);
expect(formatHotkeyDisplay("meta+enter", "mac", weird).text).toBe("⌘↵");
});

it("falls back to KEY_DISPLAY when layoutMap is missing the code (e.g. Numpad)", () => {
expect(formatHotkeyDisplay("meta+bracketleft", "mac", qwertzMap).text).toBe(
"⌘[",
);
it("renders the chord text as authored, regardless of OS layout", () => {
// On Dvorak the labeled-Z key produces ';' (and is at physical Slash);
// the chord still displays as ⌘Z because the binding text says 'z'.
// Users on alternate layouts who want a different label can rebind.
expect(formatHotkeyDisplay("meta+z", "mac").text).toBe("⌘Z");
expect(formatHotkeyDisplay("ctrl+slash", "linux").text).toBe("Ctrl+/");
});
});
61 changes: 5 additions & 56 deletions apps/desktop/src/renderer/hotkeys/display.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Display formatting for hotkey bindings.
* Converts key strings like "meta+shift+n" into platform-specific symbols.
* Converts chord strings like "meta+shift+n" into platform-specific symbols.
*/

import type { HotkeyDisplay, Platform } from "./types";
Expand Down Expand Up @@ -39,47 +39,6 @@ const KEY_DISPLAY: Record<string, string> = {
bracketright: "]",
};

// canonical token (e.g. "z", "slash") → event.code (e.g. "KeyZ", "Slash")
// for keymap lookup against the layout data sourced from native-keymap.
// Only includes printable keys whose glyph varies by layout. Special keys
// (Enter, arrows, etc.) deliberately stay on KEY_DISPLAY — their
// event.code isn't a printable character.
const PRINTABLE_TO_SCAN_CODE: Record<string, string> = {
slash: "Slash",
backslash: "Backslash",
comma: "Comma",
period: "Period",
semicolon: "Semicolon",
quote: "Quote",
backquote: "Backquote",
minus: "Minus",
equal: "Equal",
bracketleft: "BracketLeft",
bracketright: "BracketRight",
};

function canonicalToScanCode(canonical: string): string | null {
if (/^[a-z]$/.test(canonical)) return `Key${canonical.toUpperCase()}`;
if (/^[0-9]$/.test(canonical)) return `Digit${canonical}`;
return PRINTABLE_TO_SCAN_CODE[canonical] ?? null;
}

/** Glyph printed at this physical key on the user's current layout, or null. */
export function glyphForCode(
canonical: string,
layoutMap: ReadonlyMap<string, string> | null,
): string | null {
if (!layoutMap) return null;
const scan = canonicalToScanCode(canonical);
if (!scan) return null;
const v = layoutMap.get(scan);
if (!v || v.length !== 1) return null;
// Uppercase only ASCII letters. Some layout glyphs expand to multiple
// characters when uppercased (`ß` → `SS`, Turkish `ı` → `I`/`İ`) which
// would break single-glyph keycap rendering — keep those as-is.
return /^[a-z]$/.test(v) ? v.toUpperCase() : v;
}

const MODIFIER_ORDER = ["meta", "ctrl", "alt", "shift"] as const;
type Modifier = (typeof MODIFIER_ORDER)[number];

Expand All @@ -88,19 +47,14 @@ const isModifier = (p: string): p is Modifier =>

/**
* Format a chord string into display symbols.
* e.g. `"meta+shift+n"` on mac → `{ keys: ["⌘", "⇧", "N"], text: "⌘⇧N" }`
* e.g. `"meta+shift+n"` on mac → `{ keys: ["⌘", "⇧", "N"], text: "⌘⇧N" }`.
*
* `layoutMap` (optional) is `Map<event.code, unshifted glyph>` derived from
* the OS keyboard layout (sourced from native-keymap via the main process).
* When provided, printable keys (letters/digits/punctuation) are looked up
* so the displayed glyph matches what the user sees on their physical key
* — e.g. `meta+z` shows `⌘Y` on a German QWERTZ keyboard. When null, falls
* back to the US-ANSI glyph table.
* Renders the chord text as written. Users on non-US layouts whose physical
* keys don't match the chord's letter can rebind via Settings → Keyboard.
*/
export function formatHotkeyDisplay(
keys: string | null,
platform: Platform,
layoutMap: ReadonlyMap<string, string> | null = null,
): HotkeyDisplay {
if (!keys) return { keys: ["Unassigned"], text: "Unassigned" };

Expand All @@ -117,12 +71,7 @@ export function formatHotkeyDisplay(
const modSymbols = MODIFIER_ORDER.filter((m) => modifiers.includes(m)).map(
(m) => MODIFIER_DISPLAY[platform][m],
);
// Order matters: layoutMap wins for printable keys (so QWERTZ shows the
// user's printed glyph for `KeyZ`), KEY_DISPLAY wins for special keys
// (Enter, arrows, etc. — glyphForCode returns null for these because
// PRINTABLE_TO_SCAN_CODE doesn't include them).
const keyDisplay =
glyphForCode(key, layoutMap) ?? KEY_DISPLAY[key] ?? key.toUpperCase();
const keyDisplay = KEY_DISPLAY[key] ?? key.toUpperCase();
const displayKeys = [...modSymbols, keyDisplay];
const separator = platform === "mac" ? "" : "+";
return { keys: displayKeys, text: displayKeys.join(separator) };
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/hotkeys/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { getBinding, getDispatchChord, useBinding } from "./useBinding";
export { getBinding, useBinding } from "./useBinding";
export { useHotkey } from "./useHotkey";
export { useFormatBinding, useHotkeyDisplay } from "./useHotkeyDisplay";
export { useRecordHotkeys } from "./useRecordHotkeys";
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { getBinding, getDispatchChord, useBinding } from "./useBinding";
export { getBinding, useBinding } from "./useBinding";
Loading
Loading