Skip to content

Kitty images render vertically stretched because CSI 16 t reports a 1x1 px cell until the first redraw #642

Description

@ashlineldridge

Summary

Images drawn with the kitty graphics protocol come out vertically stretched,
roughly two and a half times taller than they should be on my font, whenever
the program drawing them was started as the terminal's own process. A square
image fills a tall rectangle.

The cause is the cell size query. A ghostel terminal answers CSI 16 t with a
1x1 pixel cell until it has rendered once, so a program that asks at startup,
before ghostel has drawn anything into the buffer, reads CSI 6 ; 1 ; 1 t,
concludes that the cells are square, and lays its images out on that basis. The
stretch is the real cell aspect ratio, 23/9 here. CSI 14 t (text area in
pixels) and CSI 18 t (text area in cells) are wrong in the same window and
return the same numbers as each other, because the pixel fields hold the row
and column counts.

I hit this with pi, a terminal coding agent that queries the cell size when its
interface starts and accepts any reply carrying two positive numbers. Anything
started through ghostel-exec as the terminal's own process is exposed the
same way, including timg and yazi previews. A program you launch by typing its
name at a shell prompt is unaffected, because the prompt has already forced a
render.

ghostel--init-buffer already seeds the real cell dimensions before the
process starts, and its comment says why, but the seed only takes effect at the
first redraw, which comes too late for the program the terminal was started to
run. 0.49.0 fixed a 1x1 cell size for ghostel-compile; this is the same
problem on the ordinary spawn path, except that it clears itself once anything
renders rather than waiting for a manual resize.

Steps to reproduce

(let ((buf (generate-new-buffer "*16t*")))
  (display-buffer buf)
  (ghostel-exec
   buf "/bin/bash"
   (list "-c"
         (concat "q(){ printf '\\e[16t'; IFS= read -rs -t 1 -d t r;"
                 " printf '%s reply: %st\\n' \"$1\""
                 " \"$(printf '%s' \"$r\" | cat -v)\"; };"
                 " q cold; sleep 0.5; q warm; sleep 20"))))

The new terminal prints:

cold reply: ^[[6;1;1t
warm reply: ^[[6;23;9t

Both replies should be ^[[6;23;9t. In that buffer
(ghostel--reported-cell-width) is 9 and (ghostel--reported-cell-height) is
23, from an 8x20 logical pixel font and a (ghostel--cell-pixel-scale) of 1.14.

CSI 14 t behaves the same way. On a terminal of 37 rows by 114 columns it
answers ^[[4;37;114t before the first redraw, which is the grid size sitting
in the pixel fields, and ^[[4;851;1026t after it, which is correct (37 times
23, and 114 times 9).

Writing output in the same call as the query does not avoid the problem:
printf 'hello\e[16t' still gets ^[[6;1;1t, because ghostel generates the
reply while parsing that chunk and the redraw the output schedules runs later.
Hosting the program under an interactive shell does not avoid it either. I
start pi as $SHELL -i -c 'pi "$@"' pi ... and it reads 1x1 every time.

Root cause

Renderer.init (src/Renderer.zig:122) seeds pending_resize with
cell_w = 1, cell_h = 1 and commits it immediately, so the terminal starts out
with width_px equal to cols and height_px equal to rows.

Renderer.resize (src/Renderer.zig:153) then only records a new
pending_resize. What applies it is commitResize (src/Renderer.zig:1043),
which runs from redraw (src/Renderer.zig:182). GhostelTerm.resize
(src/GhostelTerm.zig:227) documents that deferral as deliberate, so that
shrinking the viewport promotes rows into scrollback against fully rendered
state. The cell pixel dimensions travel in the same struct and inherit the
deferral, and that is the part a size query sees.

sizeCallback (src/handler.zig:103) divides terminal.width_px by
terminal.cols and terminal.height_px by terminal.rows. That is correct
once any resize has been committed, and it yields 1x1 before the first one.

So the seeding call in ghostel--init-buffer (lisp/ghostel.el:5406) cannot do
what its comment describes. Nothing has rendered when it runs, so the
dimensions it passes are still pending when the child's first query arrives.

Suggested fix

Apply the cell pixel dimensions in Renderer.resize straight away and keep
deferring only the column and row commit. The cell size feeds size reports and
kitty graphics geometry but does not reflow the grid, so it does not need to
wait for a render. Passing the dimensions into Renderer.init would work too,
and would mean the terminal never exists with a 1x1 cell.

A related gap, if you want to close it at the same time:
PosixPtyProcess.resizePty (src/PosixPtyProcess.zig:67) sets ws_xpixel and
ws_ypixel to 0 in its TIOCSWINSZ call, so a program that reads the window
size from the tty instead of querying never learns the cell size at all.

Environment

ghostel 0.50.0 plus unreleased main (b25a55e), Emacs 31.0.91 (GUI, emacs-plus),
macOS on aarch64 (aarch64-apple-darwin25.5.0).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions