Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

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

未关闭 适合新手
#207 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

评估

难度
2/5
预计耗时
1-3 小时
新手友好度
68/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
技术栈
typescript
领域
frontend

调研方向

从 lib/renderer.ts 第 198–200 行附近开始,这里 measureFont() 会测量 M 的前进宽度并设置单元格宽度。阅读该宽度在 renderer 中的用法,然后运行 bun test;issue 指出,现有测试没有覆盖这种舍入行为。完成标准是单元格宽度不再产生所述的额外间距和框线绘制字符间隙,并且测试套件通过。

由索引模型根据 Issue 内容生成。

描述

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.
主要语言
TypeScript
星标
3k
派生
193
平均合并
1 天 40 分钟
30 天内合并 PR
1

环境准备

这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

coder/ghostty-web 的其他 Issue

查看 coder/ghostty-web 的全部 Issue

相似的 Issue

更多 TypeScript Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。