Around Day 4 we got live xterm rendering working, and pretty quickly I noticed something off with the text cursor. When xterm doesn’t have focus, it draws the cursor as an outline rectangle instead of a solid block. Every time the cursor blinked or the terminal scrolled, the outline left a faint ghost of where it used to be. Not the whole rectangle, just one edge of pixels bleeding through. Looked almost like a faint motion blur on the cursor.
The strokes themselves looked slightly fuzzy too. Not bad enough to ruin the rendering, but bad enough that I noticed.
claude, what’s going on with the stroked outlines around the cursor? they look kind of fuzzy and they’re leaving trails
This is a coordinate-system mismatch. X11 and Core Graphics use different conventions for what an integer coordinate means, and the difference is exactly half a pixel.
X11 uses pixel-center addressing. When a client says XDrawLine(0, 0, 5, 0), it’s drawing through the centers of the pixels at columns 0
through 5 inclusive, six pixels of foreground color in a single row. Every
endpoint is the center of a pixel. The X protocol has been this way since
1985, and every X client and X server has assumed it since.
Core Graphics uses grid-line addressing. When you stroke a CGPath from
(0, 0) to (5, 0), you’re drawing along the line between the pixels
at row -1 and row 0. A 1-pixel-wide stroke painted along that grid line
covers half a pixel on each side. Anti-aliasing turns it into a smudge
that’s 50% opacity in two rows of pixels instead of 100% opacity in one
row. Even with AA off, the rasterizer has to pick one of the two
adjacent rows, and which one depends on rounding behavior we don’t
control.
So when I dispatched an X stroke directly through Core Graphics without adjustment, every line ended up half a pixel off from where the client expected it. The stroke straddled the boundary between two pixel rows. Single strokes looked subtly fuzzy. The cursor’s outline-draw-then-erase pattern (same shape, drawn again in the background color to erase it) left residue at the boundary because the erase pass landed in the same straddled position as the original. Some pixels never got fully painted the first time, and never got fully erased the second time. Those are the ghost edges you’re seeing.
The fix is a one-line CTM translation before every stroke. In
CocoaWindowBridge.applyStrokePlane:
public func applyStrokePlane(_ ctx: CGContext) {
ctx.translateBy(x: 0.5, y: 0.5)
// ... other stroke-specific settings ...
}
That shifts the entire coordinate system by half a user-pixel before any
stroke is rasterized. Now an X integer coordinate like (5, 0) lands at
CG position (5.5, 0.5), which is the center of the pixel at column 5,
row 0. A 1-pixel-wide stroke covers exactly one row of pixels at 100%
opacity. The outline rectangle’s edges land cleanly inside the pixels X
expects. The erase pass covers the exact same pixels. The ghosts vanish.
Here’s the part that took a minute to get right: the translation is in
user space, not device space. I almost wrote it as +0.5 device pixels
when I first traced this, which would have meant +0.5 user pixels at
1× scale, +0.25 user pixels at 2×, and +0.167 user pixels
at 3×. Each scale would need a different shift, and at non-integer
scales it’d be impossible to land cleanly.
But user space is defined such that 1 user-pixel corresponds to 1 X
logical pixel, regardless of the device scale Core Graphics is rasterizing
into. When I translate by +0.5 in user space, Core Graphics applies the
current scale transform on top of that. At 1×, the half-user-pixel
shift becomes a half-device-pixel shift, which aligns to pixel centers. At
3×, the half-user-pixel shift becomes 1.5 device pixels, which is
also a pixel center (specifically, the center of the second device pixel
in the 3-device-pixel run that makes up one user pixel). The math works
out at every integer scale.
That’s why applyStrokePlane doesn’t take a scale parameter and doesn’t
need per-scale fixup. The +0.5 is scale-agnostic because it’s expressed
in the coordinate system X is already speaking. The coordinate-system
mismatch lived above the scale-transform layer, and the fix lives at the
same layer.
The deeper lesson, which kept coming back later in the project: when a specification (the X protocol) and an implementation surface (Core Graphics) disagree about what an integer coordinate means, the fix is almost always a single-line CTM adjustment at the boundary between them. The trick is figuring out which layer the disagreement lives in. Here it was user space, not device space. Later, with y-flip, it was something similar but inverted. The pattern is “find the boundary, find the layer, translate.”