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.
5import sharp from "sharp";
6import type { Dispose } from "@clo/lib/ts.ts";
7import { Keypad, KEY_POSITIONS, PANEL_SIZE_PX } from "./Keypad.ts";
8import { blank, type Svg } from "./icons.ts";
9import { type Cleanup, effect, signal, type Signal } from "./signals.ts";
10
11export { blank, Icon } from "./icons.ts";
12
13/** The pixel size of a single key. */
14export const KEY_SIZE = 118;
15
16/** A settled face: an SVG source (e.g. an {@link Icon}), markup, or a JPEG buffer. */
17export 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 */
24export type Face = StaticFace | (() => Face);
25
26/** Collapse a (possibly reactive) face to a settled one, running any thunks. */
27function 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. */
34function 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.
48const JPEG_OPTIONS = { quality: 100, chromaSubsampling: "4:4:4" } as const;
49
50const jpegCache = new Map<string, Uint8Array>();
51
52/** Encode a Face to a key-sized JPEG. Cached by markup (returns the same buffer). */
53export 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
69const tileCache = new Map<string, Buffer>();
70
71/** Rasterize a face to a key-sized RGB tile (cached by markup). */
72async 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
88const 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 */
95export 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. */
135export interface Cell {
136 face?: Face;
137 onPress?: () => void;
138}
139
140/** Builder callback used by `menu()` / `overlay()`. */
141export 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 */
148export 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 */
188export 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). */
331export function key(face: Face, onPress?: () => void): Cell {
332 return { face, onPress };
333}