diff --git a/README.md b/README.md index 3089403..ca1465a 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,14 @@ Worth knowing before you conclude something is broken. Neither is hard to fix; n its own OK button is fine — the run-time error box is — but Extras → Auto Options has neither an OK button nor a Cancel, and its only exit was the title bar. Restarting the sidecar clears it. -**The panel sends no keyboard events at all.** `MinesweeperPanel` forwards pointer events and nothing else, -so Escape does not reach the game and a Custom board cannot be typed in. Adding keys to the RFB client is -the smaller half of this; wine also expects a window manager to hand it focus. +**Keys go to the canvas, and only while it has focus.** It takes focus on mousedown — the gesture you use +to play — and `keysymFor` in `rfb.ts` maps `KeyboardEvent.key` to an X11 keysym. Anything with a modifier, +and anything unmapped, is left to the browser rather than swallowed: this is one panel on a dashboard, not +a full-screen viewer, so a global key trap would be the wrong shape. + +Keys reach wine perfectly well without a window manager — verified by pressing F2 on the display and +watching the board reset. The Auto Options dialog ignoring Escape is that VB6 form having no Cancel button, +which is a different thing from keys not arriving. ## Two things worth knowing before you change anything @@ -85,9 +90,16 @@ resizing the panel, and the surround is teal (`-solid`) rather than the root's b **The stage is small because the panel scales it by whole numbers.** Pixel art at a fractional scale gets uneven cells — some three screen pixels wide, their neighbours two — which reads as "not quite right" rather than as blur, and `image-rendering: pixelated` does not help because unevenness is not blur. So the -panel snaps the scale, in DEVICE pixels, to whole numbers below 2x and half steps above it. Halves are safe -because the unit that has to stay regular is the CELL: at 3.5x a cell is exactly 56 screen pixels, as even -as at 3x, and only sub-cell pixels alternate. +panel snaps the scale, in DEVICE pixels, to whole numbers below 2x and QUARTER steps above it. Quarters are +safe because the unit that has to stay regular is the CELL: the board is a 16px grid, so at 2.25x a cell is +exactly 36 screen pixels, as even as at 2x, and only sub-cell pixels alternate. They are quarters rather +than halves because coarse steps overshoot — with halves this jumped 2x to 2.5x, too small to too large +with nothing in between. + +Two things the scale has to know about that are easy to miss. `devicePixelRatio` is not the whole story: +the panel header has its own CSS `zoom`, so the effective ratio is `devicePixelRatio × zoom`, read as +`getBoundingClientRect().width / offsetWidth`. And the stage is fitted into the box MINUS a margin, because +filling the panel edge to edge is most of what "too large" looks like. That makes the size QUANTISED, which is the thing to understand before changing the stage: at 3x the game is the same width whether the stage is 512 or 560. Trimming the surround is worth nothing on its own — it diff --git a/web/MinesweeperPanel.tsx b/web/MinesweeperPanel.tsx index 349e721..fac1962 100644 --- a/web/MinesweeperPanel.tsx +++ b/web/MinesweeperPanel.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import type { RfbError } from './rfb'; -import { RfbClient } from './rfb'; +import { RfbClient, keysymFor } from './rfb'; type SessionStatus = { installed: boolean; @@ -24,6 +24,9 @@ const PLACEHOLDER = { width: 512, height: 352 }; const BUTTON_MASK: Record = { 0: 1, 1: 2, 2: 4 }; +/** Breathing room around the stage, in device pixels. Small — it is a frame, not a margin. */ +const MARGIN_DEVICE_PX = 24; + // Plugin panels have no localisation mechanism yet — `officerdev`'s `useTranslation`/`panelCatalogs` // (merged by `I18nBridge`) is platform-internal and not exported from the barrel, and // `scripts/i18n/scan-hardcoded.ts` explicitly does not scan `plugins/`. So these stay plain English @@ -73,9 +76,18 @@ export const MinesweeperPanel = () => { useEffect(() => { const node = boxRef.current; if (!node) return; - const observer = new ResizeObserver(([entry]) => { - const rect = entry?.contentRect; - if (rect) setBox({ width: rect.width, height: rect.height, dpr: window.devicePixelRatio || 1 }); + const observer = new ResizeObserver(() => { + // `offsetWidth` is in the element's OWN pixels; `getBoundingClientRect` is in visual ones, so their + // ratio is the panel's CSS `zoom`. That matters because the panel header has a zoom control and + // `devicePixelRatio` knows nothing about it — at 75% the scale below would be off by a quarter and + // put the cell grid back on fractional boundaries, which is the exact thing it exists to prevent. + const rect = node.getBoundingClientRect(); + const zoom = node.offsetWidth > 0 ? rect.width / node.offsetWidth : 1; + setBox({ + width: node.offsetWidth, + height: node.offsetHeight, + dpr: (window.devicePixelRatio || 1) * zoom, + }); }); observer.observe(node); return () => observer.disconnect(); @@ -94,21 +106,25 @@ export const MinesweeperPanel = () => { * it removes the blur, not the unevenness. A fractional devicePixelRatio produces this at what looks * like 1:1, which is the case that is easiest to miss. * - * HALF steps are allowed from 2x up, and that is not a compromise on the above. The unit that has to - * stay regular is the CELL, not the pixel: at 3.5x a cell is exactly 56 screen pixels, so the grid is - * as even as it is at 3x or 4x, and only sub-cell pixels alternate between three and four. Below 2x - * that alternation is a third of the pixel rather than a seventh, so it stays whole-number there. + * QUARTER steps are allowed from 2x up, and that is not a compromise on the above. The unit that has + * to stay regular is the CELL, not the pixel: the board is a 16px grid, so at 2.25x a cell is exactly + * 36 screen pixels — as even as at 2x or 3x — and only sub-cell pixels alternate. Below 2x that + * alternation is too large a share of the pixel, so it stays whole-number there. * - * The steps are what make the size quantised — at 3x the game is the same width whether the stage is - * 512 or 560 wide. Shrinking the stage is only worth anything when it crosses a step, which is why - * these two changes landed together. + * Quarters rather than halves because the steps are what make the size QUANTISED, and coarse steps + * overshoot: with halves this went 2x to 2.5x, from too small to too large with nothing in between. + * Finer steps let the picture track the panel rather than lurch between two sizes. */ const stage = useMemo(() => { if (!frame || !box || box.width <= 0 || box.height <= 0) return null; - const fit = Math.min((box.width * box.dpr) / frame.width, (box.height * box.dpr) / frame.height); + // Air around the stage. Filling the panel edge to edge is most of what "too large" looks like, and it + // leaves the game with no frame at all. + const usableW = Math.max(1, box.width * box.dpr - MARGIN_DEVICE_PX * 2); + const usableH = Math.max(1, box.height * box.dpr - MARGIN_DEVICE_PX * 2); + const fit = Math.min(usableW / frame.width, usableH / frame.height); // Below 1 there is nothing to floor to, so take a unit fraction: the rows that survive are then // evenly spaced instead of dropped in clumps. - const scale = fit < 1 ? 1 / Math.ceil(1 / fit) : fit < 2 ? 1 : Math.floor(fit * 2) / 2; + const scale = fit < 1 ? 1 / Math.ceil(1 / fit) : fit < 2 ? 1 : Math.floor(fit * 4) / 4; return { width: (frame.width * scale) / box.dpr, height: (frame.height * scale) / box.dpr }; }, [frame, box]); @@ -199,10 +215,31 @@ export const MinesweeperPanel = () => { return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY }; }; + /** + * Keys, forwarded only while the canvas has focus. + * + * The canvas is focusable and takes focus on mousedown, which is the gesture you use to play anyway. + * Scoping it to focus is what keeps the panel from eating keys meant for the rest of the app — this is + * one panel among several on a dashboard, and a window-level listener would be a global keyboard trap. + * + * A key with a modifier is left alone: cmd-W and ctrl-R belong to the browser and the game wants none + * of them. So is anything `keysymFor` has no mapping for, which keeps Tab moving focus rather than + * disappearing into a game of Minesweeper. `preventDefault` happens only for keys actually sent. + */ + const onKey = (ev: React.KeyboardEvent, down: boolean) => { + const rfb = rfbRef.current; + if (!rfb || ev.metaKey || ev.ctrlKey || ev.altKey) return; + const keysym = keysymFor(ev.key); + if (keysym === null) return; + ev.preventDefault(); + rfb.key(keysym, down); + }; + const onPointer = (ev: React.MouseEvent, kind: 'down' | 'up' | 'move') => { const rfb = rfbRef.current; if (!rfb) return; ev.preventDefault(); + if (kind === 'down') ev.currentTarget.focus(); if (kind !== 'move') { const bit = BUTTON_MASK[ev.button] ?? 0; @@ -253,9 +290,12 @@ export const MinesweeperPanel = () => { ref={canvasRef} width={PLACEHOLDER.width} height={PLACEHOLDER.height} + tabIndex={0} onMouseDown={(ev) => onPointer(ev, 'down')} onMouseUp={(ev) => onPointer(ev, 'up')} onMouseMove={(ev) => onPointer(ev, 'move')} + onKeyDown={(ev) => onKey(ev, true)} + onKeyUp={(ev) => onKey(ev, false)} onContextMenu={(ev) => ev.preventDefault()} // Nearest-neighbour: smoothing a 1995 bitmap looks like a photograph of a screen rather than // the screen. It is half of what keeps this sharp; `stage` above is the other half. @@ -263,12 +303,14 @@ export const MinesweeperPanel = () => { // The max-* pair is only the fallback for the frame before `stage` is known — once it is, the // explicit size governs and cannot exceed the box, so capping one axis would only squash the // other. - className={`rounded-sm shadow-lg [image-rendering:pixelated] ${stage ? '' : 'max-h-full max-w-full'}`} + className={`rounded-sm shadow-lg outline-none ring-primary/50 [image-rendering:pixelated] focus:ring-2 ${stage ? '' : 'max-h-full max-w-full'}`} style={{ ...(stage ?? {}), cursor: connected ? 'default' : 'progress' }} />
{connected ? 'connected' : 'connecting…'} + {/* Focus decides where keys go, and a canvas gives no hint that it can hold any. */} + click the board to send it keys diff --git a/web/rfb.ts b/web/rfb.ts index 77af7fa..52bd01a 100644 --- a/web/rfb.ts +++ b/web/rfb.ts @@ -336,6 +336,12 @@ export class RfbClient { this.send(message); } + /** + * KeyEvent. The keysym is X11's, not a browser keycode — see `keysymFor`. + * + * Modifiers are not sent alongside: x11vnc resolves a keysym to a keycode and presses Shift itself if + * the layout needs it, so sending the symbol for `A` produces `A` without us tracking Shift. + */ key(keysym: number, down: boolean): void { const message = new Uint8Array(8); const view = new DataView(message.buffer); @@ -345,3 +351,56 @@ export class RfbClient { this.send(message); } } + +/** + * A browser `KeyboardEvent.key` as an X11 keysym, or null for keys the game has no use for. + * + * `key` rather than `code`, because it is already the CHARACTER the layout produced — the shifted one, + * the accented one, the one from the user's own keyboard map. `code` is a physical position and would + * need the layout re-implemented here to get back to a symbol. + * + * Latin-1 maps straight through: for a single printable character the keysym IS the code point. Above + * that, X uses a 0x01000000 offset for Unicode, which is what the last branch does. Everything else is + * a named key from `keysymdef.h`. + * + * Returning null is how a key stays the BROWSER's: unmapped keys are not swallowed by the panel. + */ +const NAMED_KEYSYMS: Record = { + Backspace: 0xff08, + Tab: 0xff09, + Enter: 0xff0d, + Escape: 0xff1b, + Delete: 0xffff, + Home: 0xff50, + End: 0xff57, + PageUp: 0xff55, + PageDown: 0xff56, + ArrowLeft: 0xff51, + ArrowUp: 0xff52, + ArrowRight: 0xff53, + ArrowDown: 0xff54, + Insert: 0xff63, + Shift: 0xffe1, + Control: 0xffe3, + Alt: 0xffe9, + Meta: 0xffeb, + CapsLock: 0xffe5, +}; + +export function keysymFor(key: string): number | null { + const named = NAMED_KEYSYMS[key]; + if (named !== undefined) return named; + + // F1–F24 are contiguous from 0xffbe. + const fn = /^F(\d{1,2})$/.exec(key); + if (fn) { + const n = Number(fn[1]); + return n >= 1 && n <= 24 ? 0xffbd + n : null; + } + + // Anything longer than one character here is a key we have no mapping for — leave it to the browser. + if ([...key].length !== 1) return null; + const code = key.codePointAt(0); + if (code === undefined) return null; + return code <= 0xff ? code : 0x01000000 + code; +}