Skip to content

Draw a card's picture inside the card frame - #22

Merged
Aduneer merged 1 commit into
mainfrom
images-in-cards
Aug 19, 2026
Merged

Draw a card's picture inside the card frame#22
Aduneer merged 1 commit into
mainfrom
images-in-cards

Conversation

@Aduneer

@Aduneer Aduneer commented Aug 19, 2026

Copy link
Copy Markdown
Owner

The natural sequel to audio, and the card frame absorbs it: an eleventh deck column names an image beside the deck, resolved by Deck::resolve() exactly as the audio column is, and drawn above the prompt.

the demo

Where the rows come from

The open question in the backlog was how count_frame_lines() could know how many rows a picture occupies, since an image is not a line of text. It turns out we get to say. A terminal speaking the kitty protocol scales an image into a box of cells you name — but give it both r and c and it stretches to fill them rather than fitting inside, so a 4:1 panorama comes out looking 8:1. The aspect arithmetic therefore has to happen in FlashTerm, which is what fit() is, and it needs the picture's real dimensions.

Those are free: PNG, GIF and JPEG all carry them in the header — a fixed offset for the first two, a segment walk for the third. Only the header is read, so a large photograph costs no more than a thumbnail, and the escape sequence names the file rather than carrying it: about sixty bytes whatever the picture weighs, which matters on a screen redrawn after every keypress.

Two bugs the demo GIF found, and nothing else would have

Re-rendering demo.gif caught both. Neither is visible to a unit test or a golden transcript, because both are about what a terminal does with bytes that are perfectly well-formed.

Detection read $TERM_PROGRAM. That is an ordinary environment variable, so it is inherited and outlives the terminal that set it — a session under tmux, over ssh, or inside a screen recorder still carries it. vhs inherited ghostty from the launching shell, FlashTerm believed it, reserved eight rows in a terminal that cannot draw a kitty graphic, and left a hole in the card:

Detection is now $TERM alone, which is replaced per session and so tells the truth. A hole where a picture should be is worse than no picture, so the guess is now deliberately pessimistic; terminals that can draw but do not say so in $TERM — WezTerm — want FLASHTERM_IMAGE=kitty, which is documented.

The chafa fallback forced --format=kitty, which is exactly backwards: that path exists for terminals that cannot draw one. Letting chafa choose was no better — it picks kitty astonishingly readily, doing so even with $TERM=xterm-256color and its output going down a pipe. It is now asked for symbols, the one answer that is right everywhere, which also means pictures work over ssh and inside tmux.

That makes the picture several lines of text, and text has to be placed: every line after the first would otherwise start at column zero and write straight through the frame's left border. So chafa's output is captured rather than inherited and each line is put where it belongs. Its cursor-hide/show pair is stripped — the trailing one sat on a line of its own and counted as one more row than the frame had reserved.

Both ways of drawing now leave the cursor on the row they started on, so the frame's arithmetic does not depend on which one ran.

Graceful everywhere else

A deck of pictures is still a deck. No terminal support, output in a pipe, FLASHTERM_IMAGE=none, a missing file, or a file that is not really an image — all produce an ordinary text card, byte-identical to one with no picture at all. Three golden cases prove exactly that and a fourth proves the picture appears when it should:

case frame rows escape
review-image 11 1
review-image-none 3 0
review-image-missing 3 0
review-image-not-an-image 3 0

A deck with no pictures also still serialises byte for byte as v0.2.0 wrote it, since columns are written only as far as the last one a card uses.

Harness

FLASHTERM_IMAGE=none is pinned for every golden case. Without it a transcript observes $TERM and whether chafa happens to be installed — the same case would have drawn a picture locally and nothing on CI, the exact failure #16 established the pinning rule for.

Transcripts were also going binary, which would have behaved differently under mawk on CI than gawk locally. Escapes are now collapsed to readable tokens, awk runs under LC_ALL=C, and binary fixtures are summarised rather than dumped. The tokens are not cosmetic — the transcript now pins the layout arithmetic:

<CURSOR A12><CR><CURSOR C20><IMAGE a=T,f=100,t=f,C=1,c=24,r=8;aW1hZ2VzL3BlcnJvLnBuZw==><CURSOR B12><CR>

<CURSOR A12> failing to match <CURSOR B12> now fails visibly.

Demo and examples

demo.gif re-rendered; el árbol carries a picture, drawn as coloured blocks — which is what a reader without a graphics terminal actually sees. The tape clears TERM_PROGRAM and KITTY_WINDOW_ID for the reason above.

examples/colores.csv is new, with swatches in examples/images/. It runs English → Spanish, against every other language deck here, and that is the point: a picture shows the meaning, so on rojo → red it hands over the answer and on red → rojo it cannot. Its ids are pre-minted so studying it does not dirty the repo.

No example audio ships and none should--generate-audio exists precisely so recordings are rendered locally rather than committed.

Testing

680 unit checks (up from 679) and 47 golden cases (up from 42), green on gcc and clang, optimised and sanitised, and clean in an env -i clean room. Verified from a fresh clone, since this adds binary fixtures.

Visually confirmed in Ghostty across three deliberately awkward aspect ratios — 3:2, a 2:3 portrait, and a 4:1 panorama — plus a card with no picture, and in the vhs recording via the chafa path.

The natural sequel to audio, and the card frame absorbs it: an eleventh deck
column names an image beside the deck, resolved by Deck::resolve() exactly as
the audio column is, and drawn above the prompt.

Everything below was settled by experiment rather than by reading a
specification, because every part of it was wrong the first time.

WHERE THE ROWS COME FROM

The open question was how count_frame_lines() could know how many rows a
picture occupies, since an image is not a line of text. It turns out we get to
say. A terminal speaking the kitty protocol scales an image into a box of cells
you name -- but give it both `r` and `c` and it *stretches* to fill them rather
than fitting inside, so a 4:1 panorama comes out looking 8:1. The aspect
arithmetic therefore has to happen here, which is what fit() is, and it needs
the picture's real dimensions.

Those are free: PNG, GIF and JPEG all carry them in the header, at a fixed
offset for the first two and a segment walk for the third. Only the header is
read, so a large photograph costs no more than a thumbnail, and the escape
sequence names the *file* rather than carrying it -- about sixty bytes whatever
the picture weighs, which matters on a screen redrawn after every keypress.

The cursor is left inside the image rather than below it, so the frame is drawn
first and the picture dropped into the rows it reserved.

TWO BUGS THE DEMO GIF FOUND, AND NOTHING ELSE WOULD HAVE

Re-rendering demo.gif is what caught both. Neither is visible to a unit test or
a golden transcript, because both are about what a terminal does with bytes
that are perfectly well-formed.

* Detection read $TERM_PROGRAM. That is an ordinary environment variable, so it
  is inherited and outlives the terminal that set it: a session under tmux,
  over ssh, or inside a screen recorder still carries it. vhs inherited
  "ghostty" from the shell that launched it, FlashTerm believed it, reserved
  eight rows in a terminal that cannot draw a kitty graphic, and left a hole in
  the card. Detection is now $TERM alone, which is replaced per session and so
  tells the truth. Terminals that can draw but do not say so -- WezTerm -- want
  FLASHTERM_IMAGE=kitty, which is documented.

  A hole where a picture should be is worse than no picture, so this guess is
  now deliberately pessimistic.

* The chafa fallback forced --format=kitty, which is exactly backwards: that
  path exists for terminals that cannot draw one. Letting chafa choose was no
  better -- it picks kitty astonishingly readily, doing so even with $TERM set
  to xterm-256color and its output going down a pipe. It is now asked for
  symbols, the one answer that is right everywhere, which also means pictures
  work over ssh and inside tmux.

  That makes the picture several lines of text, and text has to be *placed*:
  every line after the first would otherwise start at column zero and write
  through the frame's left border. So chafa's output is captured rather than
  inherited and each line is put where it belongs. Its cursor-hide/show pair is
  stripped, the trailing one having sat on a line of its own and counted as one
  more row than the frame reserved.

Both ways of drawing now leave the cursor on the row they started on, so the
frame's arithmetic does not depend on which one ran.

GRACEFUL EVERYWHERE ELSE

A deck of pictures is still a deck. No terminal support, output in a pipe,
FLASHTERM_IMAGE=none, a missing file, or a file that is not really an image:
all of them produce an ordinary text card, byte-identical to one with no
picture at all. Three golden cases exist to prove exactly that, and the fourth
proves the picture appears when it should.

Also: a deck with no pictures still serialises byte for byte as v0.2.0 wrote
it, since columns are written only as far as the last one a card uses.

HARNESS

FLASHTERM_IMAGE=none is pinned for every golden case. Without it a transcript
observes $TERM and whether chafa happens to be installed -- the same case would
have drawn a picture here and nothing on CI, which is the exact failure #16
established the pinning rule for.

Transcripts were also going binary. Escapes are now collapsed to readable
tokens (<IMAGE ...>, <CURSOR A12>), awk runs under LC_ALL=C so bytes are bytes
under mawk and gawk alike, and binary fixtures are summarised rather than
dumped. The tokens are not cosmetic: the transcript now pins the layout
arithmetic, and <CURSOR A12> not matching <CURSOR B12> fails visibly.

DEMO AND EXAMPLES

demo.gif re-rendered; "el árbol" carries a picture, drawn as coloured blocks,
which is what a reader without a graphics terminal actually sees. The tape
clears TERM_PROGRAM and KITTY_WINDOW_ID for the reason above.

examples/colores.csv is new, with swatches in examples/images/. It runs English
to Spanish, against every other language deck here, and that is the point: a
picture shows the *meaning*, so on "rojo -> red" it hands over the answer and
on "red -> rojo" it cannot. Its ids are pre-minted so that studying it does not
dirty the repo.

No example audio ships and none should: --generate-audio exists precisely so
recordings are rendered locally rather than committed.

680 unit checks and 47 golden cases, green on gcc and clang, optimised and
sanitised, and clean in an env -i clean room.
@Aduneer
Aduneer merged commit 2d9777b into main Aug 19, 2026
4 checks passed
@Aduneer
Aduneer deleted the images-in-cards branch August 19, 2026 18:55
Aduneer added a commit that referenced this pull request Aug 19, 2026
Pictures on cards, sync that puts two machines' reviews back together, and a CI
matrix that checks what used to be checked by hand. PRs #20-#23.

The CHANGELOG entries gained their PR numbers, which the file's own preamble
promises and these four had been written without.

No transcript changed for the version bump, which is what the <VERSION>
placeholder in normalise.awk exists for: releasing does not touch 47 expected
files. The demo GIF is already current -- it was re-rendered in #22, and doing
so is what caught the two terminal-detection bugs that release fixes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant