keyboard, and back off the size a quarter step

── keys ──

`rfb.ts` already had a `key()` that nothing called. It now has `keysymFor`,
mapping `KeyboardEvent.key` to an X11 keysym — `key` rather than `code`,
because it is already the character the user's own layout produced and `code`
would need that layout re-implemented here to get back to a symbol. Latin-1
passes straight through, above it uses X's 0x01000000 Unicode offset, and the
named keys come from keysymdef.h.

The canvas is focusable and takes focus on mousedown. Scoping keys to focus is
what stops the panel eating keys meant for the rest of the app — this is one
panel on a dashboard, so a window listener would be a global keyboard trap.
Modified keys and unmapped keys are left alone: cmd-W stays the browser's and
Tab keeps moving focus. preventDefault only for keys actually sent.

Keys reach wine without a window manager — verified by pressing F2 on the
display and watching the board reset. So the README's guess that wine needed a
WM to hand it focus was wrong; Auto Options ignores Escape because that VB6
form has no Cancel button, which is a different thing. Corrected there.

20 keysym cases checked against keysymdef.h, including the off-by-one I put in
my own test first: XK_F2 is 0xffbf, not 0xffc0.

── size ──

Half steps overshot: 2x to 2.5x is too small to too large with nothing in
between. Quarter steps from 2x up, which keeps cells whole for the same reason
halves did — a 16px grid at 2.25x is a 36px cell — and lets the picture track
the panel instead of lurching. Plus a 24px device-pixel margin, because
filling the panel edge to edge is most of what "too large" looks like.

Also: the scale was ignoring the panel's own CSS `zoom`. `devicePixelRatio`
knows nothing about it, so at 75% every cell boundary went back onto fractional
device pixels — the exact defect the snapping exists to prevent, reachable from
a button in the panel header. Effective ratio is now dpr x zoom, read as
getBoundingClientRect().width / offsetWidth.

In a 1300x900 device-pixel panel: 2.5x becomes 2.25x, 1260 to 1134 px wide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
This commit is contained in:
2026-08-23 22:32:10 +00:00
co-authored by Claude Opus 5
parent e4247cd760
commit ef74f0c2dd
3 changed files with 133 additions and 20 deletions
+18 -6
View File
@@ -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 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. 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, **Keys go to the canvas, and only while it has focus.** It takes focus on mousedown — the gesture you use
so Escape does not reach the game and a Custom board cannot be typed in. Adding keys to the RFB client is to play — and `keysymFor` in `rfb.ts` maps `KeyboardEvent.key` to an X11 keysym. Anything with a modifier,
the smaller half of this; wine also expects a window manager to hand it focus. 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 ## 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 **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" 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 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 panel snaps the scale, in DEVICE pixels, to whole numbers below 2x and QUARTER steps above it. Quarters are
because the unit that has to stay regular is the CELL: at 3.5x a cell is exactly 56 screen pixels, as even 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
as at 3x, and only sub-cell pixels alternate. 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 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 is the same width whether the stage is 512 or 560. Trimming the surround is worth nothing on its own — it
+56 -14
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react'; import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import type { RfbError } from './rfb'; import type { RfbError } from './rfb';
import { RfbClient } from './rfb'; import { RfbClient, keysymFor } from './rfb';
type SessionStatus = { type SessionStatus = {
installed: boolean; installed: boolean;
@@ -24,6 +24,9 @@ const PLACEHOLDER = { width: 512, height: 352 };
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 }; const BUTTON_MASK: Record<number, number> = { 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` // Plugin panels have no localisation mechanism yet — `officerdev`'s `useTranslation`/`panelCatalogs`
// (merged by `I18nBridge`) is platform-internal and not exported from the barrel, and // (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 // `scripts/i18n/scan-hardcoded.ts` explicitly does not scan `plugins/`. So these stay plain English
@@ -73,9 +76,18 @@ export const MinesweeperPanel = () => {
useEffect(() => { useEffect(() => {
const node = boxRef.current; const node = boxRef.current;
if (!node) return; if (!node) return;
const observer = new ResizeObserver(([entry]) => { const observer = new ResizeObserver(() => {
const rect = entry?.contentRect; // `offsetWidth` is in the element's OWN pixels; `getBoundingClientRect` is in visual ones, so their
if (rect) setBox({ width: rect.width, height: rect.height, dpr: window.devicePixelRatio || 1 }); // 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); observer.observe(node);
return () => observer.disconnect(); 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 * 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. * 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 * QUARTER steps are allowed from 2x up, and that is not a compromise on the above. The unit that has
* stay regular is the CELL, not the pixel: at 3.5x a cell is exactly 56 screen pixels, so the grid is * to stay regular is the CELL, not the pixel: the board is a 16px grid, so at 2.25x a cell is exactly
* as even as it is at 3x or 4x, and only sub-cell pixels alternate between three and four. Below 2x * 36 screen pixels — as even as at 2x or 3x — and only sub-cell pixels alternate. Below 2x that
* that alternation is a third of the pixel rather than a seventh, so it stays whole-number there. * 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 * Quarters rather than halves because the steps are what make the size QUANTISED, and coarse steps
* 512 or 560 wide. Shrinking the stage is only worth anything when it crosses a step, which is why * overshoot: with halves this went 2x to 2.5x, from too small to too large with nothing in between.
* these two changes landed together. * Finer steps let the picture track the panel rather than lurch between two sizes.
*/ */
const stage = useMemo(() => { const stage = useMemo(() => {
if (!frame || !box || box.width <= 0 || box.height <= 0) return null; 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 // 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. // 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 }; return { width: (frame.width * scale) / box.dpr, height: (frame.height * scale) / box.dpr };
}, [frame, box]); }, [frame, box]);
@@ -199,10 +215,31 @@ export const MinesweeperPanel = () => {
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY }; 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<HTMLCanvasElement>, 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<HTMLCanvasElement>, kind: 'down' | 'up' | 'move') => { const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: 'down' | 'up' | 'move') => {
const rfb = rfbRef.current; const rfb = rfbRef.current;
if (!rfb) return; if (!rfb) return;
ev.preventDefault(); ev.preventDefault();
if (kind === 'down') ev.currentTarget.focus();
if (kind !== 'move') { if (kind !== 'move') {
const bit = BUTTON_MASK[ev.button] ?? 0; const bit = BUTTON_MASK[ev.button] ?? 0;
@@ -253,9 +290,12 @@ export const MinesweeperPanel = () => {
ref={canvasRef} ref={canvasRef}
width={PLACEHOLDER.width} width={PLACEHOLDER.width}
height={PLACEHOLDER.height} height={PLACEHOLDER.height}
tabIndex={0}
onMouseDown={(ev) => onPointer(ev, 'down')} onMouseDown={(ev) => onPointer(ev, 'down')}
onMouseUp={(ev) => onPointer(ev, 'up')} onMouseUp={(ev) => onPointer(ev, 'up')}
onMouseMove={(ev) => onPointer(ev, 'move')} onMouseMove={(ev) => onPointer(ev, 'move')}
onKeyDown={(ev) => onKey(ev, true)}
onKeyUp={(ev) => onKey(ev, false)}
onContextMenu={(ev) => ev.preventDefault()} onContextMenu={(ev) => ev.preventDefault()}
// Nearest-neighbour: smoothing a 1995 bitmap looks like a photograph of a screen rather than // 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. // 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 // 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 // explicit size governs and cannot exceed the box, so capping one axis would only squash the
// other. // 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' }} style={{ ...(stage ?? {}), cursor: connected ? 'default' : 'progress' }}
/> />
</div> </div>
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{connected ? 'connected' : 'connecting…'}</span> <span>{connected ? 'connected' : 'connecting…'}</span>
{/* Focus decides where keys go, and a canvas gives no hint that it can hold any. */}
<span>click the board to send it keys</span>
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground"> <button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
<RefreshCw className="size-3" /> refresh <RefreshCw className="size-3" /> refresh
</button> </button>
+59
View File
@@ -336,6 +336,12 @@ export class RfbClient {
this.send(message); 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 { key(keysym: number, down: boolean): void {
const message = new Uint8Array(8); const message = new Uint8Array(8);
const view = new DataView(message.buffer); const view = new DataView(message.buffer);
@@ -345,3 +351,56 @@ export class RfbClient {
this.send(message); 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<string, number> = {
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;
}