diff --git a/.gitignore b/.gitignore index d73afad..71cbc8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .envrc .direnv -node_modules result out.* diff --git a/flake.nix b/flake.nix index 94c4a00..3ac1563 100644 --- a/flake.nix +++ b/flake.nix @@ -7,12 +7,13 @@ (system: let pkgs = nixpkgs.legacyPackages.${system}; - swoop = pkgs.callPackage ./swoop.nix { }; + layout-lens = pkgs.callPackage ./layout-lens/default.nix { }; in rec { - packages.swoop = swoop; - defaultPackage = packages.swoop; - devShell = pkgs.callPackage ./shell.nix { }; + packages.layout-lens = layout-lens; + defaultPackage = packages.layout-lens; + devShells.layout-lens = pkgs.callPackage ./layout-lens/shell.nix { }; + devShells.qmk = pkgs.callPackage ./keyboards/qmk/shell.nix { }; } ); } diff --git a/keyboards/qmk/ferris-sweep/lens.json b/keyboards/qmk/ferris-sweep/lens.json new file mode 100644 index 0000000..e51c902 --- /dev/null +++ b/keyboards/qmk/ferris-sweep/lens.json @@ -0,0 +1,49 @@ +{ + "colorscheme": { + "keyFill": "#ffffff", + "keyStroke": "#000000", + "mainLayerColor": "black", + "tlLayerColor": "blue", + "trLayerColor": "red", + "blLayerColor": "purple" + }, + "keys": [ + ["Q", "!", "1", "f1"], + ["A", "<", "6", "f6"], + ["X", ">", "", "f11"], + ["W", "@", "2", "f2"], + ["R", "(", "7", "f7"], + ["C", "]", "", "f12"], + ["F", "#", "3", "f3"], + ["S", "[", "8", "f8"], + ["D", "]"], + ["P", "$", "4", "f4"], + ["T", "{", "9", "f9"], + ["V", "}"], + ["B", "%", "5", "f5"], + ["G", "-", "0", "f10"], + ["Z", "—"], + ["TR", "", ""], + ["␣", "", ""], + ["⇧", "", ""], + ["TL", "", ""], + ["J", "^", "🏠"], + ["M", "?", "◄", "😱"], + ["K", "", "", "🎮"], + ["L", "&", "⏬", "🔊"], + ["N", "_", "▼", "🔉"], + ["H", "|", "", "🔇"], + ["U", "*", "⏫", "🔆"], + ["E", "/", "▲", "🔅"], + [",", "\\", ""], + ["Y", "~", "end", "⏪"], + ["I", "=", "►", "⏯️"], + [".", "+", "", "⏩"], + [":", "`", "del", "copy"], + ["O", ";", "", "paste"], + ["'", "\"", "", "cut"] + ], + "imagePadding": 20, + "keySize": 50, + "layout": "split_3x5_2" +} diff --git a/keyboards/qmk/shell.nix b/keyboards/qmk/shell.nix new file mode 100644 index 0000000..6dc1c19 --- /dev/null +++ b/keyboards/qmk/shell.nix @@ -0,0 +1,5 @@ +{ pkgs ? import { } }: +with pkgs; +mkShell { + buildInputs = with pkgs; [ qmk ]; +} diff --git a/layout-lens/.gitignore b/layout-lens/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/layout-lens/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/swoop.nix b/layout-lens/default.nix similarity index 100% rename from swoop.nix rename to layout-lens/default.nix diff --git a/package-lock.json b/layout-lens/package-lock.json similarity index 100% rename from package-lock.json rename to layout-lens/package-lock.json diff --git a/package.json b/layout-lens/package.json similarity index 100% rename from package.json rename to layout-lens/package.json diff --git a/shell.nix b/layout-lens/shell.nix similarity index 100% rename from shell.nix rename to layout-lens/shell.nix diff --git a/layout-lens/src/config.ts b/layout-lens/src/config.ts new file mode 100644 index 0000000..e1590a6 --- /dev/null +++ b/layout-lens/src/config.ts @@ -0,0 +1,62 @@ +import { + Arguments, + Config, + KeyboardKey, + KeySymbol, + Layout, + PredefinedLayout, + PredefinedLayoutName, + SpecialSymbols, +} from "./types"; +import split_3x5_2 from "./layouts/split_3x5_2"; + +export function parseConfig(input: string): Config { + const parsed = JSON.parse(input); + + const layout = PredefinedLayoutName[ + parsed.layout as string + ] as PredefinedLayoutName; + + if (layout === undefined) { + throw `Cannot find layout ${parsed.layout}`; + } + + return { + keys: (parsed.keys as string[][]).map((k) => + k.map((s) => { + const special = SpecialSymbols[s]; + if (special === undefined) return s; + return special as SpecialSymbols; + }), + ), + colorscheme: parsed.colorscheme, + imagePadding: parsed.imagePadding, + keySize: parsed.keySize, + layout, + }; +} + +function key( + main: KeySymbol, + tlLayer: KeySymbol = "", + trLayer: KeySymbol = "", + blLayer: KeySymbol = "", +): KeyboardKey { + return { main, tlLayer, trLayer, blLayer }; +} + +const layouts: Record = { + [PredefinedLayoutName.split_3x5_2]: split_3x5_2, +}; + +export function makeLayout(config: Config): Layout { + const predefined = layouts[config.layout](config.keySize); + return { + keys: config.keys.map((k) => key(...(k as Arguments))), + colorscheme: config.colorscheme, + imagePadding: config.imagePadding, + keySize: config.keySize, + visual: predefined.visual, + size: predefined.size, + }; +} diff --git a/layout-lens/src/index.ts b/layout-lens/src/index.ts new file mode 100644 index 0000000..1f265e4 --- /dev/null +++ b/layout-lens/src/index.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import * as fs from "fs"; +import { renderLayout } from "./render"; +import { makeLayout, parseConfig } from "./config"; + +const inPath = process.argv[2]; +const outPath = process.argv[3]; + +const input = fs.readFileSync(inPath, "utf8"); +const layout = makeLayout(parseConfig(input)); + +fs.writeFileSync(outPath, renderLayout(layout)); diff --git a/layout-lens/src/layout.ts b/layout-lens/src/layout.ts new file mode 100644 index 0000000..c2f1b8e --- /dev/null +++ b/layout-lens/src/layout.ts @@ -0,0 +1,56 @@ +import { VisualKey, VisualLayout } from "./types"; +import * as V from "./vec2"; + +function visualKey(at: V.Vec2, keySize: number, angle: number = 0): VisualKey { + return { position: at, size: [keySize, keySize], angle }; +} + +function col(at: V.Vec2, keySize: number): VisualLayout { + return [ + visualKey(at, keySize), + visualKey(V.add(at, [0, keySize]), keySize), + visualKey(V.add(at, [0, 2 * keySize]), keySize), + ]; +} + +function radians(deg: number): number { + return (deg / 180) * Math.PI; +} + +export function thumbs( + at: V.Vec2, + reverse: boolean, + keySize: number, + thumbRotation = reverse ? -15 : 15, +): VisualLayout { + // Distance between thumb key centers + const factor = keySize; + + const offset: V.Vec2 = [ + Math.cos(radians(thumbRotation)) * factor, + Math.sin(radians(thumbRotation)) * factor, + ]; + + const result = [ + visualKey(at, keySize, thumbRotation), + visualKey( + V.add(at, reverse ? V.neg(offset) : offset), + keySize, + thumbRotation, + ), + ]; + + if (reverse) result.reverse(); + + return result; +} + +export function cols( + at: V.Vec2, + cols: V.Vec2[], + keySize: number, +): VisualLayout { + return cols.flatMap((self, index) => + col(V.add(at, V.add(self, [index * keySize, 0])), keySize), + ); +} diff --git a/layout-lens/src/layouts/split_3x5_2.ts b/layout-lens/src/layouts/split_3x5_2.ts new file mode 100644 index 0000000..5f88871 --- /dev/null +++ b/layout-lens/src/layouts/split_3x5_2.ts @@ -0,0 +1,26 @@ +import * as L from "../layout"; +import type { PredefinedLayout } from "../types"; +import type { Vec2 } from "../vec2"; + +const layout: PredefinedLayout = (keySize) => { + // 3x5 block + const block: Vec2[] = [ + [0, keySize], + [0, keySize / 2], + [0, 0], + [0, keySize / 2], + [0, keySize], + ]; + + return { + visual: [ + L.cols([0, 0], block, keySize), + L.thumbs([keySize * 3.5, keySize * 4.5], false, keySize), + L.thumbs([keySize * 7.5, keySize * 4.5], true, keySize), + L.cols([7 * keySize, 0], block, keySize), + ].flat(), + size: [keySize * 12, keySize * 6], + }; +}; + +export default layout; diff --git a/layout-lens/src/render.ts b/layout-lens/src/render.ts new file mode 100644 index 0000000..6b5f730 --- /dev/null +++ b/layout-lens/src/render.ts @@ -0,0 +1,124 @@ +import { tag, px } from "./svg"; +import { + KeyboardKey, + KeySymbol, + Layout, + LayoutColorscheme, + SpecialSymbols, + VisualKey, +} from "./types"; + +function textContents(input: KeySymbol): string { + if (input === SpecialSymbols.TL || input === SpecialSymbols.TR) return "■"; + + return input; +} + +function renderKey( + visual: VisualKey, + key: KeyboardKey, + colorscheme: LayoutColorscheme, + keySize: number, +) { + const centerX = visual.position[0] + visual.size[0] / 2; + const centerY = visual.position[1] + visual.size[1] / 2; + const textAttribs = { + "text-anchor": "middle", + "dominant-baseline": "middle", + "font-family": "Helvetica", + }; + + const textColor = (input: KeySymbol, _default: string): string => { + if (input === SpecialSymbols.TL) return colorscheme.tlLayerColor; + if (input === SpecialSymbols.TR) return colorscheme.trLayerColor; + return _default; + }; + + return tag( + "g", + { + transform: + visual.angle && visual.angle !== 0 + ? `rotate(${visual.angle}, ${centerX}, ${centerY})` + : undefined, + }, + [ + tag("rect", { + width: px(visual.size[0]), + height: px(visual.size[1]), + x: visual.position[0], + y: visual.position[1], + fill: colorscheme.keyFill, + stroke: colorscheme.keyStroke, + "stroke-width": px(2), + }), + tag( + "text", + { + x: centerX, + y: centerY, + textLength: px(keySize / 2), + fill: textColor(key.main, colorscheme.mainLayerColor), + ...textAttribs, + }, + textContents(key.main), + ), + tag( + "text", + { + x: visual.position[0] + visual.size[0] / 6, + y: visual.position[1] + visual.size[1] / 6, + fill: textColor(key.tlLayer, colorscheme.tlLayerColor), + "font-size": "66%", + ...textAttribs, + }, + textContents(key.tlLayer), + ), + tag( + "text", + { + x: visual.position[0] + (9 * visual.size[0]) / 10, + y: visual.position[1] + visual.size[1] / 6, + fill: textColor(key.trLayer, colorscheme.trLayerColor), + "font-size": "66%", + ...textAttribs, + "text-anchor": "end", + }, + textContents(key.trLayer), + ), + tag( + "text", + { + x: visual.position[0] + visual.size[0] / 10, + y: visual.position[1] + (5 * visual.size[1]) / 6, + fill: textColor(key.blLayer, colorscheme.blLayerColor), + "font-size": "66%", + ...textAttribs, + "text-anchor": "start", + }, + textContents(key.blLayer), + ), + ].join("\n"), + ); +} + +export function renderLayout(layout: Layout) { + return tag( + "svg", + { + viewBox: [ + -layout.imagePadding, + -layout.imagePadding, + 2 * layout.imagePadding + layout.size[0], + 2 * layout.imagePadding + layout.size[1], + ].join(" "), + xmlns: "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + }, + layout.visual + .map((key, index) => + renderKey(key, layout.keys[index], layout.colorscheme, layout.keySize), + ) + .join("\n"), + ); +} diff --git a/layout-lens/src/svg.ts b/layout-lens/src/svg.ts new file mode 100644 index 0000000..4a0eab5 --- /dev/null +++ b/layout-lens/src/svg.ts @@ -0,0 +1,30 @@ +function indent(amount: number, text: string) { + return text + .split("\n") + .map((l) => " ".repeat(amount) + l) + .join("\n"); +} + +export function tag( + name: string, + attributes: Record, + children: string = "", +) { + const attributeString = Object.entries(attributes) + .map(([k, v]) => `${k}="${v}"`) + .join(" "); + + const result = [ + `<${name}${attributeString === "" ? "" : ` ${attributeString}`}>`, + indent(2, children), + ``, + ] + .filter((l) => l.trim() !== "") + .join("\n"); + + return result; +} + +export function px(value: number) { + return `${value}px`; +} diff --git a/layout-lens/src/types.ts b/layout-lens/src/types.ts new file mode 100644 index 0000000..ab2e524 --- /dev/null +++ b/layout-lens/src/types.ts @@ -0,0 +1,65 @@ +import type { Vec2 } from "./vec2"; + +/** Returns the arguments of a given function type */ +export type Arguments any> = T extends ( + ...args: infer U +) => any + ? U + : never; + +export interface VisualKey { + position: Vec2; + size: Vec2; + angle?: number; +} + +export type VisualLayout = VisualKey[]; + +export enum SpecialSymbols { + TL, + TR, +} + +export type KeySymbol = SpecialSymbols | string; + +export interface KeyboardKey { + main: KeySymbol; + tlLayer: KeySymbol; + trLayer: KeySymbol; + blLayer: KeySymbol; +} + +export interface LayoutColorscheme { + keyFill: string; + keyStroke: string; + mainLayerColor: string; + tlLayerColor: string; + trLayerColor: string; + blLayerColor: string; +} + +export interface Layout { + visual: VisualLayout; + keys: KeyboardKey[]; + colorscheme: LayoutColorscheme; + imagePadding: number; + size: Vec2; + keySize: number; +} + +export enum PredefinedLayoutName { + split_3x5_2, +} + +export interface Config { + keys: KeySymbol[][]; + colorscheme: LayoutColorscheme; + imagePadding: number; + keySize: number; + layout: PredefinedLayoutName; +} + +export type PredefinedLayout = (keySize: number) => { + visual: VisualLayout; + size: Vec2; +}; diff --git a/layout-lens/src/vec2.ts b/layout-lens/src/vec2.ts new file mode 100644 index 0000000..fc99054 --- /dev/null +++ b/layout-lens/src/vec2.ts @@ -0,0 +1,9 @@ +export type Vec2 = [number, number]; + +export function add(x: Vec2, y: Vec2): Vec2 { + return [x[0] + y[0], x[1] + y[1]]; +} + +export function neg(v: Vec2): Vec2 { + return [-v[0], -v[1]]; +} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index e9339fb..0000000 --- a/src/index.ts +++ /dev/null @@ -1,309 +0,0 @@ -#!/usr/bin/env node -import * as fs from "fs"; - -type Vec2 = [number, number]; - -interface VisualKey { - position: Vec2; - size: Vec2; - angle?: number; -} - -type VisualLayout = VisualKey[]; - -interface KeyboardKey { - main: string; - tlLayer: string; - trLayer: string; - blLayer: string; -} - -interface LayoutColorscheme { - keyFill: string; - keyStroke: string; - mainLayerColor: string; - tlLayerColor: string; - trLayerColor: string; - blLayerColor: string; -} - -interface Layout { - visual: VisualLayout; - keys: KeyboardKey[]; - colorscheme: LayoutColorscheme; - padding: number; - size: Vec2; -} - -function indent(amount: number, text: string) { - return text - .split("\n") - .map((l) => " ".repeat(amount) + l) - .join("\n"); -} - -function tag( - name: string, - attributes: Record, - children: string = "", -) { - const attributeString = Object.entries(attributes) - .map(([k, v]) => `${k}="${v}"`) - .join(" "); - - const result = [ - `<${name}${attributeString === "" ? "" : ` ${attributeString}`}>`, - indent(2, children), - ``, - ] - .filter((l) => l.trim() !== "") - .join("\n"); - - return result; -} - -function px(value: number) { - return `${value}px`; -} - -function textContents(input: string): string { - if (input === "TR" || input === "TL") return "■"; - return input; -} - -function renderKey( - visual: VisualKey, - key: KeyboardKey, - colorscheme: LayoutColorscheme, -) { - const centerX = visual.position[0] + visual.size[0] / 2; - const centerY = visual.position[1] + visual.size[1] / 2; - const textAttribs = { - "text-anchor": "middle", - "dominant-baseline": "middle", - "font-family": "Helvetica", - }; - - const textColor = (input: string, _default: string): string => { - if (input === "TL") return colorscheme.tlLayerColor; - if (input === "TR") return colorscheme.trLayerColor; - return _default; - }; - - return tag( - "g", - { - transform: - visual.angle && visual.angle !== 0 - ? `rotate(${visual.angle}, ${centerX}, ${centerY})` - : undefined, - }, - [ - tag("rect", { - width: px(visual.size[0]), - height: px(visual.size[1]), - x: visual.position[0], - y: visual.position[1], - fill: colorscheme.keyFill, - stroke: colorscheme.keyStroke, - "stroke-width": px(2), - }), - tag( - "text", - { - x: centerX, - y: centerY, - textLength: px(keySize / 2), - fill: textColor(key.main, colorscheme.mainLayerColor), - ...textAttribs, - }, - textContents(key.main), - ), - tag( - "text", - { - x: visual.position[0] + visual.size[0] / 6, - y: visual.position[1] + visual.size[1] / 6, - fill: textColor(key.tlLayer, colorscheme.tlLayerColor), - "font-size": "66%", - ...textAttribs, - }, - textContents(key.tlLayer), - ), - tag( - "text", - { - x: visual.position[0] + (9 * visual.size[0]) / 10, - y: visual.position[1] + visual.size[1] / 6, - fill: textColor(key.trLayer, colorscheme.trLayerColor), - "font-size": "66%", - ...textAttribs, - "text-anchor": "end", - }, - textContents(key.trLayer), - ), - tag( - "text", - { - x: visual.position[0] + visual.size[0] / 10, - y: visual.position[1] + (5 * visual.size[1]) / 6, - fill: textColor(key.blLayer, colorscheme.blLayerColor), - "font-size": "66%", - ...textAttribs, - "text-anchor": "start", - }, - textContents(key.blLayer), - ), - ].join("\n"), - ); -} - -function renderLayout(layout: Layout) { - return tag( - "svg", - { - viewBox: [ - -layout.padding, - -layout.padding, - 2 * layout.padding + layout.size[0], - 2 * layout.padding + layout.size[1], - ].join(" "), - xmlns: "http://www.w3.org/2000/svg", - "xmlns:xlink": "http://www.w3.org/1999/xlink", - }, - layout.visual - .map((key, index) => - renderKey(key, layout.keys[index], layout.colorscheme), - ) - .join("\n"), - ); -} - -const outPath = process.argv[2]; - -// ========== Layout generation -const keySize = 50; -const keySizeVec: Vec2 = [50, 50]; -function visualKey(at: Vec2, angle: number = 0): VisualKey { - return { position: at, size: keySizeVec, angle }; -} - -function add(x: Vec2, y: Vec2): Vec2 { - return [x[0] + y[0], x[1] + y[1]]; -} - -function col(at: Vec2): VisualLayout { - return [ - visualKey(at), - visualKey(add(at, [0, keySize])), - visualKey(add(at, [0, 2 * keySize])), - ]; -} - -function radians(deg: number): number { - return (deg / 180) * Math.PI; -} - -function neg(v: Vec2): Vec2 { - return [-v[0], -v[1]]; -} - -function thumbs( - at: Vec2, - reverse: boolean, - thumbRotation = reverse ? -15 : 15, -): VisualLayout { - // Distance between thumb key centers - const factor = keySize; - const offset: Vec2 = [ - Math.cos(radians(thumbRotation)) * factor, - Math.sin(radians(thumbRotation)) * factor, - ]; - - const result = [ - visualKey(at, thumbRotation), - visualKey(add(at, reverse ? neg(offset) : offset), thumbRotation), - ]; - - if (reverse) result.reverse(); - - return result; -} - -function cols(at: Vec2, cols: Vec2[]): VisualLayout { - return cols - .map((self, index) => col(add(at, add(self, [index * keySize, 0])))) - .flat(); -} - -function key( - main: string, - tlLayer = "", - trLayer = "", - blLayer = "", -): KeyboardKey { - return { main, tlLayer, trLayer, blLayer }; -} - -const block: Vec2[] = [ - [0, keySize], - [0, keySize / 2], - [0, 0], - [0, keySize / 2], - [0, keySize], -]; - -const layout: Layout = { - colorscheme: { - keyFill: "#ffffff", - keyStroke: "#000000", - mainLayerColor: "black", - tlLayerColor: "blue", - trLayerColor: "red", - blLayerColor: "purple", - }, - visual: [ - cols([0, 0], block), - thumbs([keySize * 3.5, keySize * 4.5], false), - thumbs([keySize * 7.5, keySize * 4.5], true), - cols([7 * keySize, 0], block), - ].flat(), - keys: [ - [ - key("Q", "!", "1", "f1"), - key("A", "<", "6", "f6"), - key("X", ">", "", "f11"), - ], - [ - key("W", "@", "2", "f2"), - key("R", "(", "7", "f7"), - key("C", ")", "", "f12"), - ], - [key("F", "#", "3", "f3"), key("S", "[", "8", "f8"), key("D", "]")], - [key("P", "$", "4", "f4"), key("T", "{", "9", "f9"), key("V", "}")], - [key("B", "%", "5", "f5"), key("G", "-", "0", "f10"), key("Z", "—")], - [key("TR", "", ""), key("␣", "", "")], - [key("⇧", "", ""), key("TL", "", "")], - [key("J", "^", "🏠"), key("M", "?", "◄", "😱"), key("K", "", "", "🎮")], - [ - key("L", "&", "⏬", "🔊"), - key("N", "_", "▼", "🔉"), - key("H", "|", "", "🔇"), - ], - [key("U", "*", "⏫", "🔆"), key("E", "/", "▲", "🔅"), key(",", "\\", "")], - [ - key("Y", "~", "end", "⏪"), - key("I", "=", "►", "⏯️"), - key(".", "+", "", "⏩"), - ], - [ - key(":", "`", "del", "copy"), - key("O", ";", "", "paste"), - key("'", '"', "", "cut"), - ], - ].flat(), - padding: 20, - size: [keySize * 12, keySize * 6], -}; - -fs.writeFileSync(outPath, renderLayout(layout));