Skip to content

refactor: split key labels in layout files (@fehmer) #6527

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 3, 2025
Merged
64 changes: 58 additions & 6 deletions docs/LAYOUTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,74 @@ The contents of the file should be as follows:
"keymapShowTopRow": false,
"type": "ansi",
"keys": {
"row1": ["`~", "1!", "2@", "3#", "4$", "5%", "6^", "7&", "8*", "9(", "0)", "-_", "=+"],
"row2": ["qQ", "wW", "eE", "rR", "tT", "yY", "uU", "iI", "oO", "pP", "[{", "]}", "\\|"],
"row3": ["aA", "sS", "dD", "fF", "gG", "hH", "jJ", "kK", "lL", ";:", "'\""],
"row4": ["zZ", "xX", "cC", "vV", "bB", "nN", "mM", ",<", ".>", "/?"],
"row5": [" "]
"row1": [
["`", "~"],
["1", "!"],
["2", "@"],
["3", "#"],
["4", "$"],
["5", "%"],
["6", "^"],
["7", "&"],
["8", "*"],
["9", "("],
["0", ")"],
["-", "_"],
["=", "+"]
],
"row2": [
["q", "Q"],
["w", "W"],
["e", "E"],
["r", "R"],
["t", "T"],
["y", "Y"],
["u", "U"],
["i", "I"],
["o", "O"],
["p", "P"],
["[", "{"],
["]", "}"],
["\\", "|"]
],
"row3": [
["a", "A"],
["s", "S"],
["d", "D"],
["f", "F"],
["g", "G"],
["h", "H"],
["j", "J"],
["k", "K"],
["l", "L"],
[";", ":"],
["'", "\""]
],
"row4": [
["z", "Z"],
["x", "X"],
["c", "C"],
["v", "V"],
["b", "B"],
["n", "N"],
["m", "M"],
[",", "<"],
[".", ">"],
["/", "?"]
],
"row5": [[" "]]
}
}


```

It is recommended that you familiarize yourselves with JSON before adding a layout.

`keymapShowTopRow` indicates whether to always show the first row of the layout.
`type` can be `ansi` or `iso`.

In `keys` you need to specify `row1` to `row5`. Add the keys within the row as string. The string can have up to four character. The character define unshifted, shifted, alt-gr and shifted alt-gr character in this order. For example `eE€` defines `e` on regular key press, `E` if `shift` is held and `€` if `alt-gr` is held.
In `keys` you need to specify `row1` to `row5`. Add the keys within the row as string-array. The string-array can have up to four character. The character define unshifted, shifted, alt-gr and shifted alt-gr character in this order. For example `["e","E","€"]` defines `e` on regular key press, `E` if `shift` is held and `€` if `alt-gr` is held.

**Note:** Quote and backslash characters need to be escaped: `\"` and `\\`.

Expand Down
45 changes: 45 additions & 0 deletions frontend/__tests__/utils/key-converter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { readFileSync } from "fs";
import { layoutKeyToKeycode } from "../../src/ts/utils/key-converter";

const isoDvorak = JSON.parse(
readFileSync(
import.meta.dirname + "/../../static/layouts/swedish_dvorak.json",
"utf-8"
)
);
const dvorak = JSON.parse(
readFileSync(
import.meta.dirname + "/../../static/layouts/dvorak.json",
"utf-8"
)
);

describe("key-converter", () => {
describe("layoutKeyToKeycode", () => {
it("handles unknown key", () => {
const keycode = layoutKeyToKeycode("🤷", isoDvorak);

expect(keycode).toBeUndefined();
});
it("handles iso backslash", () => {
const keycode = layoutKeyToKeycode("*", isoDvorak);

expect(keycode).toEqual("Backslash");
});
it("handles iso IntlBackslash", () => {
const keycode = layoutKeyToKeycode("<", isoDvorak);

expect(keycode).toEqual("IntlBackslash");
});
it("handles iso row4", () => {
const keycode = layoutKeyToKeycode("q", isoDvorak);

expect(keycode).toEqual("KeyX");
});
it("handles ansi", () => {
const keycode = layoutKeyToKeycode("q", dvorak);

expect(keycode).toEqual("KeyX");
});
});
});
32 changes: 22 additions & 10 deletions frontend/scripts/json-validation.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ function validateOthers() {
return reject(new Error(challengesValidator.errors[0].message));
}

const charDefinitionSchema = {
type: "array",
minItems: 1,
maxItems: 4,
items: { type: "string", minLength: 1, maxLength: 1 },
};
const charDefinitionSchemaRow5 = {
type: "array",
minItems: 1,
maxItems: 2,
items: { type: "string", minLength: 1, maxLength: 1 },
};
//layouts
const layoutsSchema = {
ansi: {
Expand All @@ -145,31 +157,31 @@ function validateOthers() {
properties: {
row1: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 4 },
items: charDefinitionSchema,
minItems: 13,
maxItems: 13,
},
row2: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 4 },
items: charDefinitionSchema,
minItems: 13,
maxItems: 13,
},
row3: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 4 },
items: charDefinitionSchema,
minItems: 11,
maxItems: 11,
},
row4: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 4 },
items: charDefinitionSchema,
minItems: 10,
maxItems: 10,
},
row5: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 2 },
items: charDefinitionSchemaRow5,
minItems: 1,
maxItems: 2,
},
Expand All @@ -189,31 +201,31 @@ function validateOthers() {
properties: {
row1: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 4 },
items: charDefinitionSchema,
minItems: 13,
maxItems: 13,
},
row2: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 4 },
items: charDefinitionSchema,
minItems: 12,
maxItems: 12,
},
row3: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 4 },
items: charDefinitionSchema,
minItems: 12,
maxItems: 12,
},
row4: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 4 },
items: charDefinitionSchema,
minItems: 11,
maxItems: 11,
},
row5: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 2 },
items: charDefinitionSchemaRow5,
minItems: 1,
maxItems: 2,
},
Expand Down
82 changes: 61 additions & 21 deletions frontend/src/ts/elements/keymap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,45 @@ import * as ShiftTracker from "../test/shift-tracker";
import * as AltTracker from "../test/alt-tracker";
import * as KeyConverter from "../utils/key-converter";
import { getActiveFunboxNames } from "../test/funbox/list";
import { areSortedArraysEqual } from "../utils/arrays";

export const keyDataDelimiter = "~~";

const stenoKeys: JSONData.Layout = {
keymapShowTopRow: true,
type: "matrix",
keys: {
row1: [],
row2: ["sS", "tT", "pP", "hH", "**", "fF", "pP", "lL", "tT", "dD"],
row3: ["sS", "kK", "wW", "rR", "**", "rR", "bB", "gG", "sS", "zZ"],
row4: ["aA", "oO", "eE", "uU"],
row2: [
["s", "S"],
["t", "T"],
["p", "P"],
["h", "H"],
["*", "*"],
["f", "F"],
["p", "P"],
["l", "L"],
["t", "T"],
["d", "D"],
],
row3: [
["s", "S"],
["k", "K"],
["w", "W"],
["r", "R"],
["*", "*"],
["r", "R"],
["b", "B"],
["g", "G"],
["s", "S"],
["z", "Z"],
],
row4: [
["a", "A"],
["o", "O"],
["e", "E"],
["u", "U"],
],
row5: [],
},
};
Expand Down Expand Up @@ -115,7 +145,7 @@ export function show(): void {
function buildRow(options: {
layoutData: JSONData.Layout;
rowId: string;
rowKeys: string[];
rowKeys: string[][];
layoutNameDisplayString: string;
showTopRow: boolean;
isMatrix: boolean;
Expand Down Expand Up @@ -189,30 +219,37 @@ function buildRow(options: {
* It is just created for simplicity in the for loop below.
* */
// If only one space, add another
if (rowKeys.length === 1 && rowKeys[0] === " ") {
rowKeys[1] = rowKeys[0];
const isRowEmpty = (row: string[] | undefined): boolean =>
areSortedArraysEqual(row ?? [], [" "]);

if (rowKeys.length === 1 && isRowEmpty(rowKeys[0])) {
rowKeys[1] = rowKeys[0] ?? [];
}
// If only one alpha, add one space and place it on the left
if (rowKeys.length === 1 && rowKeys[0] !== " ") {
rowKeys[1] = " ";
if (rowKeys.length === 1 && !isRowEmpty(rowKeys[0])) {
rowKeys[1] = [" "];
rowKeys.reverse();
}
// If two alphas equal, replace one with a space on the left
if (rowKeys.length > 1 && rowKeys[0] !== " " && rowKeys[0] === rowKeys[1]) {
rowKeys[0] = " ";
if (
rowKeys.length > 1 &&
!isRowEmpty(rowKeys[0]) &&
areSortedArraysEqual(rowKeys[0] as string[], rowKeys[1] as string[])
) {
rowKeys[0] = [" "];
}
const alphas = (v: string): boolean => v !== " ";
const alphas = (v: string[]): boolean => v.some((key) => key !== " ");
hasAlphas = rowKeys.some(alphas);

keysHtml += "<div></div>";

for (let keyId = 0; keyId < rowKeys.length; keyId++) {
const key = rowKeys[keyId] as string;
const key = rowKeys[keyId] as string[];
let keyDisplay = key[0] as string;
if (Config.keymapLegendStyle === "uppercase") {
keyDisplay = keyDisplay.toUpperCase();
}
const keyVisualValue = key.replace('"', "&quot;");
const keyVisualValue = key.map((it) => it.replace('"', "&quot;"));
// these are used to keep grid layout but magically hide keys using opacity:
let side = keyId < 1 ? "left" : "right";
// we won't use this trick for alternate layouts, unless Alice (for rotation):
Expand All @@ -221,7 +258,7 @@ function buildRow(options: {
keysHtml += `<div class="keymapSplitSpacer"></div>`;
r5Grid += "-";
}
if (keyVisualValue === " ") {
if (isRowEmpty(keyVisualValue)) {
keysHtml += `<div class="keymapKey keySpace layoutIndicator ${side}">
<div class="letter" ${letterStyle}>${layoutDisplay}</div>
</div>`;
Expand Down Expand Up @@ -256,7 +293,7 @@ function buildRow(options: {
continue;
}

const key = rowKeys[keyId] as string;
const key = rowKeys[keyId] as string[];
const bump = rowId === "row3" && (keyId === 3 || keyId === 6);
let keyDisplay = key[0] as string;
let letterStyle = "";
Expand All @@ -277,10 +314,11 @@ function buildRow(options: {
hide = ` invisible`;
}

const keyElement = `<div class="keymapKey${hide}" data-key="${key.replace(
'"',
"&quot;"
)}"><span class="letter" ${letterStyle}>${keyDisplay}</span>${
const keyElement = `<div class="keymapKey${hide}" data-key="${key
.map((it) => it.replace('"', "&quot;"))
.join(
keyDataDelimiter
)}"><span class="letter" ${letterStyle}>${keyDisplay}</span>${
bump ? "<div class='bump'></div>" : ""
}</div>`;

Expand Down Expand Up @@ -486,7 +524,9 @@ async function updateLegends(): Promise<void> {
}
) as HTMLElement[];

const layoutKeys = keymapKeys.map((el) => el.dataset["key"]);
const layoutKeys = keymapKeys.map((el) =>
el.dataset["key"]?.split(keyDataDelimiter)
);
if (layoutKeys.includes(undefined)) return;

const keys = keymapKeys.map((el) => el.childNodes[0]);
Expand All @@ -508,7 +548,7 @@ async function updateLegends(): Promise<void> {
}

for (let i = 0; i < layoutKeys.length; i++) {
const layoutKey = layoutKeys[i] as string;
const layoutKey = layoutKeys[i] as string[];
const key = keys[i];
const lowerCaseCharacter = layoutKey[0];
const upperCaseCharacter = layoutKey[1];
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/ts/modals/word-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { Language } from "@monkeytype/contracts/schemas/languages";

type FilterPreset = {
display: string;
getIncludeString: (layout: JSONData.Layout) => string[];
getExcludeString: (layout: JSONData.Layout) => string[];
getIncludeString: (layout: JSONData.Layout) => string[][];
getExcludeString: (layout: JSONData.Layout) => string[][];
};

const presets: Record<string, FilterPreset> = {
Expand Down
Loading