Cell width uses `Math.ceil` of the `M` advance, so float noise adds 1px per cell and text looks letter-spaced
还没有人认领这个 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.0and0.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 genericmonospace 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);
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.ceilof theMadvance)". 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, andceil(18.00012) / 2is 9.5 at DPR 2. UsingMath.roundin 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.ceilwas 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,通用步骤见我们的新手贡献指南。
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
coder/ghostty-web 的其他 Issue
-
buffer translateToString()/getChars() return '' for empty cells, collapsing cursor-positioned text未关闭
难度 2/5 1-3 小时 新手友好度 75/100
coder/ghostty-web#205 ·
-
难度 2/5 1-3 小时 新手友好度 68/100
coder/ghostty-web#204 ·
-
Alt+<printable key> sends the bare key with no ESC prefix (readline/emacs/tmux meta bindings lost)未关闭
难度 2/5 1-3 小时 新手友好度 72/100
coder/ghostty-web#203 ·
-
难度 2/5 1-3 小时 新手友好度 72/100
coder/ghostty-web#199 ·
-
难度 4/5 1-2 天 新手友好度 48/100
coder/ghostty-web#210 ·
查看 coder/ghostty-web 的全部 Issue
相似的 Issue
-
难度 2/5 1-3 小时 新手友好度 76/100
Northeastern-Electric-Racing/FinishLine#4660 ·
维护者通常 2 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
mihaip/infinite-mac#475 ·
-
Let TypeScript accept an initial object in ReactiveDict可能已有人在做 @yograj-vijay-yadav 今天认领。 未关闭bug good first issue help wanted workshop
难度 2/5 1-3 小时 新手友好度 92/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 83/100
marmelab/atomic-crm#401 ·
维护者通常 1 天内回复
-
account: profile
难度 2/5 1-3 小时 新手友好度 75/100
维护者通常 5 天内回复