| 1 | // Higher-level keypad UI: rasterize key "faces" (see {@link Icon} in icons.ts) |
| 2 | // to JPEG with sharp (cached by markup), and drive a {@link Keypad} through an |
| 3 | // imperative page system. Two layers, one file — the device talks HID, this |
| 4 | // talks pictures. |
| 5 | import sharp from "sharp"; |
| 6 | import type { Dispose } from "@clo/lib/ts.ts"; |
| 7 | import { Keypad, KEY_POSITIONS, PANEL_SIZE_PX } from "./Keypad.ts"; |
| 8 | import { blank, type Svg } from "./icons.ts"; |
| 9 | import { type Cleanup, effect, signal, type Signal } from "./signals.ts"; |
| 10 | |
| 11 | export { blank, Icon } from "./icons.ts"; |
| 12 | |
| 13 | /** The pixel size of a single key. */ |
| 14 | export const KEY_SIZE = 118; |
| 15 | |
| 16 | /** A settled face: an SVG source (e.g. an {@link Icon}), markup, or a JPEG buffer. */ |
| 17 | export type StaticFace = Svg | string | Uint8Array; |
| 18 | |
| 19 | /** |
| 20 | * Anything renderable to a key. A thunk `() => Face` is a *reactive* face: it is |
| 21 | * re-evaluated inside a tracking scope, so if it reads a {@link signal} the key |
| 22 | * re-renders itself whenever that signal changes. |
| 23 | */ |
| 24 | export type Face = StaticFace | (() => Face); |
| 25 | |
| 26 | /** Collapse a (possibly reactive) face to a settled one, running any thunks. */ |
| 27 | function unwrap(face: Face): StaticFace { |
| 28 | let settled: Face = face; |
| 29 | while (typeof settled === "function") settled = settled(); |
| 30 | return settled; |
| 31 | } |
| 32 | |
| 33 | /** Resolve a face to its SVG markup (string) or pass a pre-encoded buffer through. */ |
| 34 | function faceMarkup(face: Face): string | Uint8Array { |
| 35 | const settled = unwrap(face); |
| 36 | if (typeof settled === "string") return settled; |
| 37 | if (settled instanceof Uint8Array) return settled; |
| 38 | return settled.svg; |
| 39 | } |
| 40 | |
| 41 | // --------------------------------------------------------------------------- |
| 42 | // Encoding — SVG -> JPEG via sharp, cached by markup so each face encodes once. |
| 43 | // --------------------------------------------------------------------------- |
| 44 | |
| 45 | // The keypad screens decode JPEG, so that's fixed — but we control the knobs. |
| 46 | // 4:4:4 (full chroma, no subsampling) keeps thin icon strokes crisp; the default |
| 47 | // 4:2:0 is what fringes the edges. Baseline (not progressive) for the decoder. |
| 48 | const JPEG_OPTIONS = { quality: 100, chromaSubsampling: "4:4:4" } as const; |
| 49 | |
| 50 | const jpegCache = new Map<string, Uint8Array>(); |
| 51 | |
| 52 | /** Encode a Face to a key-sized JPEG. Cached by markup (returns the same buffer). */ |
| 53 | export async function toJpeg(face: Face): Promise<Uint8Array> { |
| 54 | const markup = faceMarkup(face); |
| 55 | if (typeof markup !== "string") return markup; |
| 56 | |
| 57 | const cached = jpegCache.get(markup); |
| 58 | if (cached) return cached; |
| 59 | |
| 60 | const jpeg = await sharp(Buffer.from(markup)) |
| 61 | .resize(KEY_SIZE, KEY_SIZE) |
| 62 | .flatten({ background: "#000000" }) |
| 63 | .jpeg(JPEG_OPTIONS) |
| 64 | .toBuffer(); |
| 65 | jpegCache.set(markup, jpeg); |
| 66 | return jpeg; |
| 67 | } |
| 68 | |
| 69 | const tileCache = new Map<string, Buffer>(); |
| 70 | |
| 71 | /** Rasterize a face to a key-sized RGB tile (cached by markup). */ |
| 72 | async function rasterTile(face: Face): Promise<Buffer> { |
| 73 | const markup = faceMarkup(face); |
| 74 | const cacheable = typeof markup === "string"; |
| 75 | if (cacheable) { |
| 76 | const hit = tileCache.get(markup); |
| 77 | if (hit) return hit; |
| 78 | } |
| 79 | const tile = await sharp(typeof markup === "string" ? Buffer.from(markup) : markup) |
| 80 | .resize(KEY_SIZE, KEY_SIZE) |
| 81 | .flatten({ background: "#000000" }) |
| 82 | .raw() |
| 83 | .toBuffer(); |
| 84 | if (cacheable) tileCache.set(markup, tile); |
| 85 | return tile; |
| 86 | } |
| 87 | |
| 88 | const panelCache = new Map<string, Uint8Array>(); |
| 89 | |
| 90 | /** |
| 91 | * Compose the 9 grid faces into a single 480x480 panel JPEG, each at its key |
| 92 | * position. Cached by the exact set of faces. Hand the result to |
| 93 | * `Keypad.setPanel` for an atomic, cascade-free refresh of the whole grid. |
| 94 | */ |
| 95 | export async function composePanel(faces: Face[]): Promise<Uint8Array> { |
| 96 | const markups = faces.map(faceMarkup); |
| 97 | const cacheKey = markups.every((markup) => typeof markup === "string") |
| 98 | ? (markups as string[]).join(" ") |
| 99 | : null; |
| 100 | if (cacheKey) { |
| 101 | const hit = panelCache.get(cacheKey); |
| 102 | if (hit) return hit; |
| 103 | } |
| 104 | |
| 105 | const tiles = await Promise.all( |
| 106 | faces.map(async (face, index) => ({ |
| 107 | input: await rasterTile(face), |
| 108 | raw: { width: KEY_SIZE, height: KEY_SIZE, channels: 3 as const }, |
| 109 | left: KEY_POSITIONS[index].x, |
| 110 | top: KEY_POSITIONS[index].y, |
| 111 | })), |
| 112 | ); |
| 113 | |
| 114 | const jpeg = await sharp({ |
| 115 | create: { |
| 116 | width: PANEL_SIZE_PX, |
| 117 | height: PANEL_SIZE_PX, |
| 118 | channels: 3, |
| 119 | background: { r: 0, g: 0, b: 0 }, |
| 120 | }, |
| 121 | }) |
| 122 | .composite(tiles) |
| 123 | .jpeg(JPEG_OPTIONS) |
| 124 | .toBuffer(); |
| 125 | |
| 126 | if (cacheKey) panelCache.set(cacheKey, jpeg); |
| 127 | return jpeg; |
| 128 | } |
| 129 | |
| 130 | // --------------------------------------------------------------------------- |
| 131 | // Page system — imperative bindings, opaque menus, transparent overlays. |
| 132 | // --------------------------------------------------------------------------- |
| 133 | |
| 134 | /** A single key binding: what it shows and what it does. */ |
| 135 | export interface Cell { |
| 136 | face?: Face; |
| 137 | onPress?: () => void; |
| 138 | } |
| 139 | |
| 140 | /** Builder callback used by `menu()` / `overlay()`. */ |
| 141 | export type BuildPage = (page: Page) => void; |
| 142 | |
| 143 | /** |
| 144 | * A page of key bindings, built imperatively via `.key(name, face, action)`. |
| 145 | * `menu` pages are opaque (keys they don't define show blank); `overlay` pages |
| 146 | * are transparent (undefined keys fall through). Toggle with `page.active`. |
| 147 | */ |
| 148 | export class Page { |
| 149 | readonly cells: Partial<Record<Keypad.Key, Cell>> = {}; |
| 150 | readonly opaque: boolean; |
| 151 | #surface: KeypadSurface; |
| 152 | |
| 153 | constructor(surface: KeypadSurface, opaque: boolean) { |
| 154 | this.#surface = surface; |
| 155 | this.opaque = opaque; |
| 156 | } |
| 157 | |
| 158 | /** Bind a key to a face and an action. */ |
| 159 | key(name: Keypad.Key, face: Face, onPress?: () => void): this { |
| 160 | this.cells[name] = { face, onPress }; |
| 161 | this.#surface.refresh(); |
| 162 | return this; |
| 163 | } |
| 164 | |
| 165 | get active(): boolean { |
| 166 | return this.#surface.isOpen(this); |
| 167 | } |
| 168 | |
| 169 | set active(value: boolean) { |
| 170 | if (value) this.#surface.open(this); |
| 171 | else this.#surface.close(this); |
| 172 | } |
| 173 | |
| 174 | open() { |
| 175 | this.#surface.open(this); |
| 176 | } |
| 177 | |
| 178 | close() { |
| 179 | this.#surface.close(this); |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * A per-app view onto the shared {@link Keypad}: the root page, a stack of |
| 185 | * sub-pages/overlays, and press routing. The active surface owns the device. |
| 186 | * This is what app configs interact with. |
| 187 | */ |
| 188 | export class KeypadSurface { |
| 189 | #keypad: Keypad; |
| 190 | #root: Page; |
| 191 | #stack: Page[]; |
| 192 | #active = false; |
| 193 | #disposers: Dispose[] = []; |
| 194 | // Structural changes (bindings, stack, reconnect) bump this signal; the render |
| 195 | // effect depends on it, so it re-runs alongside any face-signal change. |
| 196 | #revision: Signal<number> = signal(0); |
| 197 | #renderDispose: Cleanup | null = null; |
| 198 | #renderToken = 0; |
| 199 | #pendingFaces: StaticFace[] | null = null; |
| 200 | #encodeScheduled = false; |
| 201 | |
| 202 | constructor(keypad: Keypad) { |
| 203 | this.#keypad = keypad; |
| 204 | this.#root = new Page(this, true); |
| 205 | this.#stack = [this.#root]; |
| 206 | } |
| 207 | |
| 208 | /** Bind a key on the root layout. Unlisted keys show `blank` and do nothing. */ |
| 209 | key(name: Keypad.Key, face: Face, onPress?: () => void): this { |
| 210 | this.#root.key(name, face, onPress); |
| 211 | return this; |
| 212 | } |
| 213 | |
| 214 | /** Build an opaque sub-page (a sub-menu). Open it with `page.open()`. */ |
| 215 | menu(build: BuildPage): Page { |
| 216 | const page = new Page(this, true); |
| 217 | build(page); |
| 218 | return page; |
| 219 | } |
| 220 | |
| 221 | /** Build a transparent overlay (only its keys change; the rest stay). */ |
| 222 | overlay(build: BuildPage): Page { |
| 223 | const page = new Page(this, false); |
| 224 | build(page); |
| 225 | return page; |
| 226 | } |
| 227 | |
| 228 | open(page: Page) { |
| 229 | if (page === this.#root || this.#stack.includes(page)) return; |
| 230 | this.#stack.push(page); |
| 231 | this.refresh(); |
| 232 | } |
| 233 | |
| 234 | close(page: Page) { |
| 235 | const index = this.#stack.indexOf(page); |
| 236 | if (index > 0) { |
| 237 | this.#stack.splice(index, 1); |
| 238 | this.refresh(); |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | /** Pop the top sub-page (what the `back` button does by default). */ |
| 243 | back() { |
| 244 | if (this.#stack.length > 1) { |
| 245 | this.#stack.pop(); |
| 246 | this.refresh(); |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | isOpen(page: Page): boolean { |
| 251 | return this.#stack.includes(page); |
| 252 | } |
| 253 | |
| 254 | /** Trigger a re-render (called when bindings or the stack change). */ |
| 255 | refresh() { |
| 256 | this.#revision.update((n) => n + 1); |
| 257 | } |
| 258 | |
| 259 | setActive(active: boolean) { |
| 260 | if (this.#active === active) return; |
| 261 | this.#active = active; |
| 262 | if (active) { |
| 263 | this.#disposers.push( |
| 264 | this.#keypad.on("keypress", (name) => this.#dispatch(name)), |
| 265 | this.#keypad.on("connect", () => this.refresh()), |
| 266 | ); |
| 267 | // One reactive scope drives all rendering: it reads #revision (structural |
| 268 | // changes) and, by evaluating each face thunk, any signals those faces |
| 269 | // read. Any of them changing re-runs this and re-encodes the panel. |
| 270 | this.#renderDispose = effect(() => { |
| 271 | this.#revision(); |
| 272 | const faces = Keypad.lcdKeys.map((key) => |
| 273 | unwrap(this.#visible(key)?.face ?? blank) |
| 274 | ); |
| 275 | this.#scheduleEncode(faces); |
| 276 | }); |
| 277 | } else { |
| 278 | this.#renderDispose?.(); |
| 279 | this.#renderDispose = null; |
| 280 | for (const dispose of this.#disposers) dispose(); |
| 281 | this.#disposers = []; |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | #visible(key: Keypad.Key): Cell | undefined { |
| 286 | for (let index = this.#stack.length - 1; index >= 0; index -= 1) { |
| 287 | const page = this.#stack[index]; |
| 288 | const cell = page.cells[key]; |
| 289 | if (cell) return cell; |
| 290 | if (page.opaque) return undefined; |
| 291 | } |
| 292 | return undefined; |
| 293 | } |
| 294 | |
| 295 | // Coalesce a burst of synchronous re-runs (e.g. several signals set in one |
| 296 | // event handler) into a single async encode on the next microtask. |
| 297 | #scheduleEncode(faces: StaticFace[]) { |
| 298 | this.#pendingFaces = faces; |
| 299 | if (this.#encodeScheduled) return; |
| 300 | this.#encodeScheduled = true; |
| 301 | queueMicrotask(() => { |
| 302 | this.#encodeScheduled = false; |
| 303 | const pending = this.#pendingFaces; |
| 304 | this.#pendingFaces = null; |
| 305 | if (pending) void this.#encode(pending); |
| 306 | }); |
| 307 | } |
| 308 | |
| 309 | async #encode(faces: StaticFace[]) { |
| 310 | if (!this.#active) return; |
| 311 | const token = ++this.#renderToken; |
| 312 | const panel = await composePanel(faces); |
| 313 | // Bail if a newer render started (or we were deactivated) while encoding. |
| 314 | if (token !== this.#renderToken || !this.#active) return; |
| 315 | this.#keypad.setPanel(panel); |
| 316 | } |
| 317 | |
| 318 | #dispatch(key: Keypad.Key) { |
| 319 | const cell = this.#visible(key); |
| 320 | if (cell?.onPress) { |
| 321 | cell.onPress(); |
| 322 | return; |
| 323 | } |
| 324 | if (key === "back" && this.#stack.length > 1) { |
| 325 | this.back(); |
| 326 | } |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | /** Build a key binding (for use outside the imperative `.key()` form). */ |
| 331 | export function key(face: Face, onPress?: () => void): Cell { |
| 332 | return { face, onPress }; |
| 333 | } |