Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions app/src/main/frontend/src/__tests__/viewport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { ViewportReporter, viewportFromMetrics, type ViewportSize } from "../viewport.js";

describe("viewportFromMetrics", () => {
it("reports whole cells that fit", () => {
expect(viewportFromMetrics(800, 480, 10, 20)).toEqual({ cols: 80, rows: 24 });
});

it("floors partial cells rather than rounding up", () => {
// 806/10 = 80.6 — the 81st column would be clipped.
expect(viewportFromMetrics(806, 495, 10, 20)).toEqual({ cols: 80, rows: 24 });
});

it("clamps to the server's validated range", () => {
// ClientMessage.ViewportResize is @Min(20)@Max(500) / @Min(10)@Max(500).
// Sending outside that is a protocol error, so clamp instead.
expect(viewportFromMetrics(50, 40, 10, 20)).toEqual({ cols: 20, rows: 10 });
expect(viewportFromMetrics(99_999, 99_999, 10, 20)).toEqual({ cols: 500, rows: 500 });
});

it("reports nothing when the element has not been laid out", () => {
// A hidden or detached element measures zero. Reporting a bogus size
// is worse than reporting none.
expect(viewportFromMetrics(0, 0, 10, 20)).toBeNull();
expect(viewportFromMetrics(800, 480, 0, 0)).toBeNull();
expect(viewportFromMetrics(NaN, 480, 10, 20)).toBeNull();
});
});

describe("ViewportReporter", () => {
let sent: ViewportSize[];
let size: ViewportSize | null;

beforeEach(() => {
vi.useFakeTimers();
sent = [];
size = { cols: 80, rows: 24 };
});

afterEach(() => {
vi.useRealTimers();
});

function reporter(debounceMs = 150): ViewportReporter {
return new ViewportReporter(() => size, (s) => { sent.push(s); }, debounceMs);
}

it("reports once on start so the server is not stuck on the default", () => {
const r = reporter();
r.start();

expect(sent).toEqual([{ cols: 80, rows: 24 }]);
r.stop();
});

it("does not report a size that has not changed", () => {
const r = reporter();
r.start();
sent.length = 0;

window.dispatchEvent(new Event("resize"));
vi.advanceTimersByTime(200);

// Pixels changed; cells did not. Every report costs a server repaint.
expect(sent).toEqual([]);
r.stop();
});

it("coalesces a burst of resizes into one report", () => {
const r = reporter();
r.start();
sent.length = 0;

size = { cols: 64, rows: 20 };
for (let i = 0; i < 20; i++) window.dispatchEvent(new Event("resize"));
vi.advanceTimersByTime(200);

expect(sent).toEqual([{ cols: 64, rows: 20 }]);
r.stop();
});

it("stops reporting once stopped", () => {
const r = reporter();
r.start();
sent.length = 0;
r.stop();

size = { cols: 100, rows: 40 };
window.dispatchEvent(new Event("resize"));
vi.advanceTimersByTime(200);

expect(sent).toEqual([]);
});

it("re-reports an unchanged size after the baseline is reset", () => {
// Reconnect: the server may be a fresh session back at 80x24, so the
// client's "already sent that" memory has to be cleared.
const r = reporter();
r.start();
sent.length = 0;

r.resetBaseline();
r.reportNow();

expect(sent).toEqual([{ cols: 80, rows: 24 }]);
r.stop();
});
});
19 changes: 19 additions & 0 deletions app/src/main/frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { configureThemes, effects } from "./effects.js";
import { envelope } from "./envelope.js";
import { getRegions } from "./layout.js";
import { ViewportReporter, measureViewport } from "./viewport.js";
import { InputController } from "./input.js";
import { readIntentFromUrl } from "./intent.js";
import { RegionRenderer } from "./region.js";
Expand Down Expand Up @@ -79,6 +80,14 @@ async function main(): Promise<void> {
const regions = getRegions();
const renderer = new RegionRenderer(regions);

// Report the canvas size in character cells so the server can render for
// the actual viewport instead of assuming 80x24 (SPEC §6.7). Measured on
// the main region — that's the content area screens size against.
const viewport = new ViewportReporter(
() => measureViewport(regions.main.el),
(size) => sendRaw({ type: "viewport.resize", ...size }),
);

const statusInput = document.getElementById("status-input") as HTMLInputElement;
const statusText = document.getElementById("status-text") as HTMLElement;
const cursor = document.getElementById("cursor") as HTMLElement;
Expand Down Expand Up @@ -188,6 +197,11 @@ async function main(): Promise<void> {
const tokenFromAuth = (env.payload as { token?: string }).token;
if (tokenFromAuth) sessionStore.set(tokenFromAuth);
if (p.user) document.title = `VOIDcore — ${p.user.handle}`;
// The server session starts at the 80x24 default, and anything we
// sent before the socket opened was dropped. Re-report now that
// there is definitely a session to receive it.
viewport.resetBaseline();
viewport.reportNow();
break;
}
case "auth.err": {
Expand All @@ -198,6 +212,10 @@ async function main(): Promise<void> {
case "resume.ok": {
const p = env.payload as ResumeOkPayload;
if (!p.sync && p.frames) for (const f of p.frames) dispatch(f);
// Same reasoning as auth.ok — and a reconnect may have landed on a
// session that never learned this client's size.
viewport.resetBaseline();
viewport.reportNow();
break;
}
case "resume.err": {
Expand Down Expand Up @@ -228,6 +246,7 @@ async function main(): Promise<void> {
}

registerInstance(ws);
viewport.start();
ws.start();
}

Expand Down
133 changes: 133 additions & 0 deletions app/src/main/frontend/src/viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Viewport reporting — `viewport.resize` per SPEC §4.3 / §6.7.
*
* The server renders in character cells, so it needs the canvas size in
* cells, not pixels. We measure one cell from a probe rendered in the
* region's own font, divide, and report. The server holds the result on
* the session and lets the active screen reflow.
*
* Bounds mirror the server's validation on `ClientMessage.ViewportResize`
* (cols 20..500, rows 10..500). Clamping here rather than sending an
* out-of-range value keeps a very small or very large window from
* tripping a protocol error.
*/

export interface ViewportSize {
cols: number;
rows: number;
}

const MIN_COLS = 20;
const MAX_COLS = 500;
const MIN_ROWS = 10;
const MAX_ROWS = 500;

/** Characters in the probe string. Wide enough that sub-pixel advance
* widths average out instead of compounding. */
const PROBE_CHARS = 50;

const DEFAULT_DEBOUNCE_MS = 150;

function clamp(n: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, n));
}

/**
* Cells that fit in a box, given a cell's size. Pure — the arithmetic is
* here so it can be tested without a layout engine.
*
* Returns null for non-finite or non-positive inputs: a hidden or
* not-yet-laid-out element measures zero, and reporting a bogus size is
* worse than reporting none.
*/
export function viewportFromMetrics(
boxWidth: number, boxHeight: number,
cellWidth: number, cellHeight: number,
): ViewportSize | null {
const finite = [boxWidth, boxHeight, cellWidth, cellHeight].every(
(n) => Number.isFinite(n) && n > 0);
if (!finite) return null;
return {
cols: clamp(Math.floor(boxWidth / cellWidth), MIN_COLS, MAX_COLS),
rows: clamp(Math.floor(boxHeight / cellHeight), MIN_ROWS, MAX_ROWS),
};
}

/**
* Measure one character cell inside `el`, in the element's own font.
* The probe is inserted, measured, and removed synchronously, so it never
* paints.
*/
export function measureCell(el: HTMLElement): { width: number; height: number } {
const probe = document.createElement("span");
probe.textContent = "0".repeat(PROBE_CHARS);
probe.style.position = "absolute";
probe.style.visibility = "hidden";
probe.style.whiteSpace = "pre";
el.appendChild(probe);
const rect = probe.getBoundingClientRect();
probe.remove();
return { width: rect.width / PROBE_CHARS, height: rect.height };
}

/** Measure `el`'s usable size in character cells. */
export function measureViewport(el: HTMLElement): ViewportSize | null {
const cell = measureCell(el);
const box = el.getBoundingClientRect();
return viewportFromMetrics(box.width, box.height, cell.width, cell.height);
}

/**
* Watches for size changes and reports them, debounced and de-duplicated.
*
* De-duplication matters more than it looks: a drag-resize fires a torrent
* of pixel-level events, but the cell count only changes occasionally, and
* every report costs a server-side repaint.
*/
export class ViewportReporter {
private last: ViewportSize | null = null;
private timer: number | null = null;

constructor(
private readonly measure: () => ViewportSize | null,
private readonly send: (size: ViewportSize) => void,
private readonly debounceMs: number = DEFAULT_DEBOUNCE_MS,
) {}

start(): void {
window.addEventListener("resize", this.onResize);
// Report once up front so the server isn't stuck on the 80x24 default
// for a client that never resizes.
this.reportNow();
}

stop(): void {
window.removeEventListener("resize", this.onResize);
if (this.timer != null) {
clearTimeout(this.timer);
this.timer = null;
}
}

/** Force the next report through even if the size is unchanged — used
* on reconnect, where the server may be a fresh session at 80x24. */
resetBaseline(): void {
this.last = null;
}

reportNow(): void {
const size = this.measure();
if (!size) return;
if (this.last && this.last.cols === size.cols && this.last.rows === size.rows) return;
this.last = size;
this.send(size);
}

private onResize = (): void => {
if (this.timer != null) clearTimeout(this.timer);
this.timer = window.setTimeout(() => {
this.timer = null;
this.reportNow();
}, this.debounceMs);
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ public void dispatch(Envelope inbound, VoidCoreSession session) {
case ClientMessage.LineSubmit m -> screen.onLineSubmit(session, m);
case ClientMessage.LineCancel m -> screen.onLineCancel(session);
case ClientMessage.ScrollRequest m -> notImplemented(session, m);
case ClientMessage.ViewportResize m -> notImplemented(session, m);
case ClientMessage.ViewportResize m -> screen.onViewportResize(session, m);
case ClientMessage.EditorCommit m -> screen.onAppEvent(session,
new AppEvent.EditorCommit(m.widget_id(), m.content(), m.action()));
case ClientMessage.EditorCancel m -> screen.onAppEvent(session,
Expand All @@ -159,7 +159,7 @@ private static String routerMethodFor(ClientMessage m) {
case ClientMessage.LineSubmit x -> "onLineSubmit(text-len=" + (x.text() == null ? 0 : x.text().length()) + ")";
case ClientMessage.LineCancel x -> "onLineCancel";
case ClientMessage.ScrollRequest x -> "scrollRequest [not impl]";
case ClientMessage.ViewportResize x -> "viewportResize [not impl]";
case ClientMessage.ViewportResize x -> "viewportResize";
case ClientMessage.EditorCommit x -> "onAppEvent(EditorCommit widget=" + x.widget_id() + ")";
case ClientMessage.EditorCancel x -> "onAppEvent(EditorCancel widget=" + x.widget_id() + ")";
case ClientMessage.EditorSnapshot x -> "onAppEvent(EditorSnapshot widget=" + x.widget_id() + ")";
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/io/aeyer/voidcore/ws/VoidCoreSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ public interface VoidCoreSession {
String currentChatRoomSlug();
void setCurrentChatRoomSlug(String slug);

/**
* Client canvas size in character cells, as last reported by
* {@code viewport.resize} (SPEC §4.3 / §6.7). Defaults to the classic
* 80x24 until the client reports otherwise, so a client that never
* sends one behaves exactly as before.
*/
int viewportCols();
int viewportRows();
void setViewport(int cols, int rows);

String currentDoorId();
void setCurrentDoorId(String doorId);

Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/io/aeyer/voidcore/ws/flow/ScreenRouter.java
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,21 @@ public void onKeystroke(VoidCoreSession session, ClientMessage.Keystroke req) {
* a plain {@link Screen} (not a ScreenApp), the event is logged and
* dropped — these messages can only arrive while a ScreenApp is on top.
*/
/**
* Client canvas resized (SPEC §4.3). Records the new size on the
* session, then gives the active screen a chance to reflow.
*
* <p>Deliberately not a re-enter: {@link Screen#onViewportResize} is a
* no-op by default, and screens that reflow override it to repaint. A
* resize must not reset mid-flow state.
*/
public void onViewportResize(VoidCoreSession session, ClientMessage.ViewportResize req) {
session.setViewport(req.cols(), req.rows());
Screen screen = activeScreen(session);
if (screen == null) return;
screen.onViewportResize(makeContext(session));
}

public void onAppEvent(VoidCoreSession session, AppEvent ev) {
Frame top = navState.peekFrame(session);
Phase ph = top == null ? null : top.phase();
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/io/aeyer/voidcore/ws/flow/screen/BbsContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ public BbsContext(VoidCoreSession session,

public VoidCoreSession session() { return session; }
public UserRow user() { return user; }

/**
* Client canvas width in character cells, as last reported by
* {@code viewport.resize}. 80 until the client says otherwise, which
* is also what {@link io.aeyer.voidcore.ws.flow.layout.Layout.Flow}
* assumes by default — so a screen that ignores this behaves exactly
* as it did before.
*/
public int viewportCols() { return session.viewportCols(); }
public int viewportRows() { return session.viewportRows(); }
public BbsServices services() { return services; }

/** Transitional accessor for the still-private helpers on ScreenRouter. */
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/io/aeyer/voidcore/ws/flow/screen/Screen.java
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,19 @@ default List<String> topics(BbsContext ctx) {
default Transition onEvent(BbsContext ctx, String topic) {
return onEnter(ctx);
}

/**
* The client's canvas changed size — browser resize, mobile rotation,
* on-screen keyboard opening. The new size is already on the session
* ({@link BbsContext#viewportCols()}) before this is called.
*
* <p>Default is deliberately <strong>no-op</strong>, not
* {@code onEnter(ctx)}. Re-entering resets screen-local state — a
* wizard would jump back to step 0 — and a resize is not a navigation
* event. Screens that reflow override this and repaint; screens whose
* layout the client handles need do nothing.
*/
default void onViewportResize(BbsContext ctx) {
// no-op
}
}
Loading
Loading