Draw a card's picture inside the card frame - #22
Merged
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.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 bothrandcand 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 whatfit()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.gifcaught 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 undertmux, overssh, or inside a screen recorder still carries it. vhs inheritedghosttyfrom 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
$TERMalone, 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 — wantFLASHTERM_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-256colorand its output going down a pipe. It is now asked forsymbols, the one answer that is right everywhere, which also means pictures work oversshand insidetmux.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:review-imagereview-image-nonereview-image-missingreview-image-not-an-imageA 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=noneis pinned for every golden case. Without it a transcript observes$TERMand 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>failing to match<CURSOR B12>now fails visibly.Demo and examples
demo.gifre-rendered; el árbol carries a picture, drawn as coloured blocks — which is what a reader without a graphics terminal actually sees. The tape clearsTERM_PROGRAMandKITTY_WINDOW_IDfor the reason above.examples/colores.csvis new, with swatches inexamples/images/. It runs English → Spanish, against every other language deck here, and that is the point: a picture shows the meaning, so onrojo → redit hands over the answer and onred → rojoit cannot. Its ids are pre-minted so studying it does not dirty the repo.No example audio ships and none should —
--generate-audioexists 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 -iclean 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.