Hacktoberfest 2026: die Issues, die Maintainer für den Oktober markiert haben – offen und einsteigerfreundlich. Hacktoberfest-Issues durchsuchen

Cell width uses `Math.ceil` of the `M` advance, so float noise adds 1px per cell and text looks letter-spaced

Offen Anfängerfreundlich
#207 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

Dieses Issue hat noch niemand übernommen.

Bewertung

Schwierigkeit
2/5
Geschätzter Aufwand
1-3 Stunden
Anfängerfreundlichkeit
68/100
Issue-Typ
Bug
Klarheit
Klar beschrieben
Aktivitätsstatus
Aktiv
Tech-Stack
typescript
Bereich
frontend

Rechercherichtung

Beginne in lib/renderer.ts etwa bei den Zeilen 198–200, wo measureFont() den Vorlauf von M misst und die Zellbreite festlegt. Lies nach, wie diese Breite im Renderer verwendet wird, und führe dann bun test aus; im Issue steht, dass die vorhandenen Tests dieses Rundungsverhalten nicht abdecken. Fertig ist die Änderung, wenn die Zellbreite den zusätzlichen Abstand und die beschriebenen Lücken bei Box-Zeichen vermeidet und die Testsuite besteht.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Beschreibung

Summary

measureFont() sets the cell width to Math.ceil(ctx.measureText('M').width). Chromium reports monospace advances with a little float noise above the true value. JetBrains Mono at 15px has a 9px advance, but measureText returns 9.00006103515625. Math.ceil turns that into 10. Every cell is then 1px (11%) wider than the glyph advance. Text looks spaced out ("C l a u d e") and box-drawing lines (──) show a gap between every cell.

Fonts with a genuinely fractional advance are also affected. At 19px JetBrains Mono the advance is 11.4px and the cell becomes 12px.

Native Ghostty rounds the cell width instead (@round(face_width) in src/font/Metrics.zig, with a comment explaining why ceiling is the wrong choice). Changing Math.ceil to Math.round fixes it.

Environment

  • ghostty-web: main at 1858a59 (same code as npm 0.4.0 and 0.4.0-next.20.g1858a59)
  • Browser: Chromium 153.0.8010.52 (Arch Linux), headless and headed
  • Fonts: JetBrains Mono (loaded with @font-face), Liberation Mono, the generic monospace
  • devicePixelRatio: 1 and 2 (same result at both)

Minimal repro

<!doctype html>
<style>
  @font-face { font-family: 'JetBrains Mono'; src: url('JetBrainsMono-Regular.ttf'); }
</style>
<div id="t"></div>
<script type="module">
  import { init, Terminal } from './dist/ghostty-web.js';
  await document.fonts.load('15px "JetBrains Mono"');
  await init();

  const ctx = document.createElement('canvas').getContext('2d');
  ctx.font = '15px "JetBrains Mono"';
  console.log('advance', ctx.measureText('M').width); // 9.00006103515625

  const term = new Terminal({ fontFamily: '"JetBrains Mono"', fontSize: 15, cols: 40, rows: 3 });
  term.open(document.getElementById('t'));
  term.write('Claude Code  ╭────╮\r\n│abcd│');
  console.log('cell width', term.renderer.charWidth); // 10, expected 9
</script>

Expected vs actual

Measured in Chromium 153 with the repro page. "Before" is main. "After" is main with the one-line fix below. Results were identical at devicePixelRatio 1 and 2.

Font Size measureText('M').width Cell width, main (ceil) Cell width, fix (round)
JetBrains Mono 13px 7.8000030517578125 8 8
JetBrains Mono 14px 8.39996337890625 9 8
JetBrains Mono 15px 9.00006103515625 10 9
JetBrains Mono 16px 9.600021362304688 10 10
JetBrains Mono 19px 11.400039672851562 12 11
Liberation Mono 13px 7.80126953125 8 8
Liberation Mono 14px 8.4013671875 9 8
Liberation Mono 15px 9.00146484375 10 9
monospace 15px 9.00006103515625 10 9

At 15px the advance is 9px plus noise. Main makes the cell 10px, so each glyph gets an extra pixel to its right. In screenshots at 15px, main shows letter-spaced text and a dashed ──── line. With the fix the text is tight and the line is solid.

With ceil, the cell is up to 1px wider than the advance (0.6px at 14px and 19px). With round, it is never more than 0.5px off in either direction.

Root cause

lib/renderer.ts on main (1858a59), lines 198-200:

// Measure width using 'M' (typically widest character)
const widthMetrics = ctx.measureText('M');
const width = Math.ceil(widthMetrics.width);

https://github.com/coder/ghostty-web/blob/1858a5947767a3e1c9e98dbf53b2ff87fedb2aab/lib/renderer.ts#L198-L200

Each glyph is drawn at x * metrics.width (renderCellText, line 593). Its own advance does not matter. So any width added by rounding becomes visible space between letters. It also becomes a gap between box-drawing glyphs, since ─ is only as wide as the font's advance.

Suggested fix

--- a/lib/renderer.ts
+++ b/lib/renderer.ts
@@ -197,7 +197,10 @@ export class CanvasRenderer {
 
     // Measure width using 'M' (typically widest character)
     const widthMetrics = ctx.measureText('M');
-    const width = Math.ceil(widthMetrics.width);
+    // A monospace advance is the cell width. Canvas reports it with float
+    // noise (9.00006 for a 9px advance), so rounding up would add a whole
+    // pixel to every cell and space the text out. Native Ghostty rounds too.
+    const width = Math.round(widthMetrics.width);
 
     // Measure height using ascent + descent with padding for glyph overflow
     const ascent = widthMetrics.actualBoundingBoxAscent || this.fontSize * 0.8;

bun test passes with this change (372 pass, 0 fail). The tests don't cover it either way, because the happy-dom measureText mock returns whole numbers.

Why Math.round, and the alternatives

What the width feeds. metrics.width sets the canvas size in resize() (lines 236-249), the resize check in render() (lines 289-291), every cell's x position and background, the cursor, charWidth for mouse hit-testing in terminal.ts, and the column count in FitAddon (Math.floor(availableWidth / metrics.width)). terminal.ts (lines 249 and 699) also writes metrics.width * cols straight into canvas.width. All of this assumes a whole number of CSS pixels. Math.round keeps that assumption, so nothing else has to change.

Fractional cells. Using the font's exact advance (width = widthMetrics.width) would look best. It breaks the resize check, though. canvas.width is always an integer, so canvas.width !== cols * 9.00006 * dpr is true on every frame. The renderer would then resize and repaint every frame at any DPR, which is the bug #198 describes for fractional DPRs. Closed PR #164 removed the ceil and had to change the canvas sizing and terminal.ts to cope. That is a bigger change than this bug needs.

Ceil with an epsilon (Math.ceil(w - 0.01)). This fixes the 15px noise case. Fonts with a real fractional advance still get up to 1px of extra space per cell: 8.4px becomes 9px, and 11.4px becomes 12px. Spacing and box-drawing gaps stay wrong there. It also adds a magic number.

Round in device pixels (Math.round(w * dpr) / dpr). This matches native Ghostty, which rounds in physical pixels, and xterm.js, which floors the width in device pixels (device.char.width = Math.floor(charSize.width * dpr) in its WebGL renderer). At DPR 2, 14px would get an 8.5px cell instead of 8px, closer to the true 8.4px. But the CSS width becomes fractional at non-integer DPRs, so it depends on fixing the resize check from #198 first. It would be a good follow-up once #198 is fixed. Math.round is the safe change today, and at DPR 1 the two are identical.

Cost of rounding down. When the advance rounds down (8.4px to 8px), each glyph's advance is up to 0.5px wider than its cell, so neighbors can overlap by that much. Glyphs are placed per cell, so the error never accumulates. Most glyphs have side bearings, so their ink stays inside the cell. Box-drawing and block glyphs overlap by a fraction of a pixel instead of leaving a gap, which is what you want. Native Ghostty documents and accepts the same trade-off in Metrics.zig. The 14px screenshots after the fix look normal.

Relation to existing issues

  • #126 ("gap between unicode lines characters ──"). This is probably the main cause of the horizontal gaps reported there. At 15px JetBrains Mono, main draws ──── as dashes and the fix makes it solid. One commenter on #126 reported the gap with JetBrains Mono. A later comment on #126 fixes the row height and says it "does not touch the cell width (Math.ceil of the M advance)". Vertical gaps in │ columns come from the separate height calculation and are not fixed by this change.
  • #198 (resize every frame at fractional DPR, seams between cell backgrounds). That is a different bug, in the resize check and in fractional device-pixel edges. This fix neither causes nor fixes it. It is the reason not to switch to fractional cell widths before #198 is fixed.
  • #146 and #169 (open PRs) change the width to Math.ceil(width * dpr) / dpr. That keeps the ceil, so the 15px case stays wrong: ceil(9.00006) is still 10 at DPR 1, and ceil(18.00012) / 2 is 9.5 at DPR 2. Using Math.round in those formulas would fix both bugs together.
  • #164 (closed by its author, not merged) dropped the ceil as part of a large box-drawing PR. It named the same cause ("The previous Math.ceil was making cells wider than the font's advance"). It was never merged, and main still has the ceil.
Vorherrschende Sprache
TypeScript
Sterne
3k
Forks
193
Ø Merge
1 T. 40 Min.
Gemergte PRs (30 T.)
1

Entwicklungsumgebung

Dieses Projekt bietet weder Dev-Container noch Dockerfile noch Beitragsleitfaden – die Einrichtung liegt bei Ihnen. Beginnen Sie mit der README; die allgemeinen Schritte stehen in unserem Leitfaden für den ersten Beitrag.

Erste Schritte

  1. Lesen Sie das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreiben Sie ins Issue, dass Sie es übernehmen — das erspart doppelte Arbeit.
  3. Forken Sie das Repository und arbeiten Sie in einem Branch.
  4. Öffnen Sie einen Pull Request, der die Issue-Nummer nennt.

Mehr aus coder/ghostty-web

Alle Issues in coder/ghostty-web

Ähnliche Issues

Weitere Issues zu TypeScript

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.