Skip to content

Expose the probed cell size (cell aspect) through the public API #154

Closed

Description

@Trippnology

Not a bug report — an API ask, grown out of the image support in #82.

The problem. Any app drawing proportional content onto a termdom-cellgrid canvas — album art, charts, QR codes, thumbnails — must size its grid against the terminal's real cell aspect (cell height ÷ width in pixels), or the rendering distorts. That aspect is not a constant: the classic 8×16 cell gives 2.0, JetBrains Mono's natural line box gives ~2.2, and line spacing / integer-pixel rounding push real terminals further. On my setup (Konsole + JetBrains Mono) the terminal answers the CSI 16 t query with CSI 6;25;11t — 11×25 px cells, aspect ≈ 2.27 — so a square image fitted at the customary 2:1 renders ~13% too tall, and users resort to hand-tuned magic constants per terminal.

TermDOM already measures this: the framebuffer starts from a guessed 8×16, then negotiateCellPixels() asks CSI 16 t at attach and adoptCellPixels() stores the answer — and intrinsic <img> sizing (the #82 feature) lays images out at round(px ÷ cell) through it. But the measurement is unreachable from app code:

  • the package exports map blocks @b9g/termdom/internal/cssom, where getImageCellSize() lives;
  • the TermDOM instance, document and window expose no property or event for it, and window.devicePixelRatio returns a hardcoded 1 (fair — CSS px are cells under the unit cell — but it leaves no public access at all);
  • the termdom-cellgrid context keeps its cellPixels() behind a private symbol.

The workaround we ship (simplified; the engine sizes a replaced <img> with the probed cell pixels, so a hidden probe leaks the measurement back through public DOM geometry):

// Hidden probe: a 640×200 fully transparent PNG (base64 data URL).
const probe = document.createElement("img");
probe.alt = "";
probe.src = PROBE_PNG_DATA_URL;
probe.setAttribute(
	"style",
	"position:fixed;visibility:hidden;width:auto;height:auto",
);
document.body.appendChild(probe);

// Read the cell aspect back — the box is round(640/cellW) × round(200/cellH).
const box = probe.getBoundingClientRect();
const cellW = 640 / box.width;
const cellH = 200 / box.height;
const cellAspect = cellH / cellW; // ≈ 2.27 once the probe answer lands,
// 2.0 while the 8×16 guess is still standing

Caveats that a real accessor would not have: it costs an image decode and a DOM node per app; precision is bound by the integer rounding of the laid-out box (±0.5 cell per axis — a larger probe narrows it); and it reads the guess until the CSI 16 t answer arrives, so consumers must read it lazily rather than once at startup.

The ask — any one of these would do, smallest first:

  1. A getter on the TermDOM instance or its window, e.g. term.cellPixels: {width, height} — the framebuffer's current value, guessed or probed.
  2. An accessor on the termdom-cellgrid context (ctx.cellPixels or ctx.cellAspect) — cellgrid consumers are exactly the apps that need it.
  3. Export getImageCellSize / getCellPixels from the package root (the exports map currently blocks the internal path).

Two bonus behaviors that would complete it:

  • A signal when the value changes — an event, or dispatching a window resize from adoptCellPixels even under the default cellSize: "unit" (today the terminalresize re-probe listener is only installed when measuresCell, i.e. cellSize: "auto").
  • Installing that re-probe unconditionally, so a font-size zoom mid-session refreshes image sizing under the default setting too.

Environment: @b9g/termdom 0.2.0, Node 24, primary dev terminal Konsole. All engine internals cited from the 0.2.0 bundle (GUESSED_CELL_PIXELS, negotiateCellPixels, getNaturalSize, getPixelCellSize, the exports map in package.json).

Activity

  1. brainkim commented on Oct 15, 2026

    @brainkim
    Member

    Thanks for the detailed write-up, and for the probe workaround, which showed exactly what was missing!

    0.2.1 adds term.measureCell() (#155). It asks the terminal for its cell (XTWINOPS 16) and resolves to {width, height} in the terminal's pixels, whatever the cellSize option says. So under the default unit cell you get the real 11×25, not 1×1:

    const cell = await term.measureCell();
    const aspect = cell ? cell.height / cell.width : 2;

    A few differences from what you proposed:

    • It's async, and never returns the 8×16 guess. The real value exists only once the terminal answers, so measureCell() waits for it. It resolves to null when there's no terminal to ask (before attach(), after dispose(), output not a TTY) or the terminal doesn't answer, so you pick the fallback yourself. Returning a guess for this would be counterproductive.

    • Changes come as an event. cellchange fires on the TermDOM with width and height when the cell is measured at a different size: when the first answer after attach() replaces the assumed size, and after a font zoom.

      term.addEventListener("cellchange", (event) => redraw(event.height / event.width));
    • No context accessor or internal exports. One method on the TermDOM serves the cellgrid and every other consumer, without making internals public API.

    Your second bonus is in too: TermDOM now asks for the cell again after every resize, not only under cellSize: "auto". A font zoom resizes images under the default setting as well. Before, they kept the size the old cell gave them until restart.

    Closing, but feel free to reopen if there are additional issues.

  2. Trippnology commented on Oct 16, 2026

    @Trippnology
    Author

    Thanks for sorting this so quickly, that worked perfectly. 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions