feat: draw posters as real images on kitty and Ghostty - #163
feat: draw posters as real images on kitty and Ghostty#163TechNapoleon wants to merge 25 commits into
Conversation
bffae70 to
1d43966
Compare
| vi.mock("./hooks/useMouseWheel", () => ({ useMouseWheel: (): void => {} })); | ||
|
|
||
| vi.mock("../config/config", () => ({ | ||
| loadConfig: async (): Promise<unknown> => ({ downloadDir: "/tmp/torlink-tests", trackers: [] }), |
| }); | ||
|
|
||
| it("refuses tmux, which is how torlnk attach runs the whole app", () => { | ||
| expect(graphicsMarker({ ...KITTY_ENV, TMUX: "/tmp/tmux-1000/default,123,0" }, true)).toBe(false); |
| // being wrong here costs a screenful of garbage rather than a missing picture. | ||
| for (const env of [ | ||
| { TERM: "xterm-256color", COLORTERM: "truecolor" }, | ||
| { ...KITTY_ENV, TMUX: "/tmp/tmux-1000/default,1,0" }, |
|
Three code-scanning annotations here, all false positives on string literals in tests:
ThreatCrush passes on all three. Happy to switch the first to |
|
trying wez as its the only that is mac/win/nix |
Search results arrive as scene and fansub release names, which carry the title fused to quality, source, codec and group tags. Any metadata lookup has to recover a plain title first, so this lands the reduction on its own as a pure, import-free module ahead of the code that will consume it. The parser is total by construction: it runs once per visible row during a search, so it returns an empty title rather than throwing, and leaves the decision about whether a title is worth querying to the caller. Sanitizing for the terminal stays at the render boundary. Three rules earn their odd shapes from real sampled names. Dots convert to spaces on a tie with spaces, not only when they outnumber them, because a name with equal counts is a dotted scene name whose title contains spaces, and leaving the dots in fuses title and junk tokens. The bare "Show - 01" fansub form backfills its season from the text before the marker, since that form carries an episode only while the season sits just ahead of it. The third is the trailing release group, which is taken only when the text ahead of it corroborates a scene release — a junk token, a year, or an episode marker. A trailing "-GROUP" and an ordinary hyphenated title are the same shape, so matching on shape alone truncates "Spider-Man" to a title of "Spider", and likewise "Ant-Man" and "X-Men". Those feed a title search, where the truncation either misses or matches the wrong film, and a junk-vocabulary guard does not help because a real title word is not vocabulary. Requiring corroboration fails in the safe direction: a minimal name keeps a slightly dirty title rather than a truncated one. Separator normalisation runs ahead of the group strip to make the check possible at all, since corroboration reads the name as tokens and a dotted scene name is a single token until the dots become spaces.
A search row shows a release name and nothing else, which is the least useful thing a torrent carries. Cinemeta is keyless and IMDb-keyed, so it buys a title, year, rating, plot and poster with no account to create and no key to ship in a public binary. Two rules shape the code. The client is called from a render path, so every mapper is total and every request fails soft to null or []: a dead provider must cost a poster, not the TUI. And the IMDb id is remote input that ends up in a URL path, so it is validated on the way out, not on the way in — the same guard stripControl() applies to text bound for the terminal. Totality has to hold in the helpers rather than rely on a caller's try/catch, because searchUrl and metaUrl are exported and called directly. encodeURIComponent throws URIError on an unpaired surrogate, and a lone surrogate is remotely reachable: JSON.parse produces one from a "\ud800" escape in a tracker payload. Half an astral character carries no meaning, so it is dropped before encoding — one code path and one return shape, instead of a swallowed error and a second, undocumented result. The lookbehind is what catches an unpaired low surrogate as well; a plain \uD800-\uDFFF class would mangle well-formed emoji, which the tests pin. Remote fields are capped before they are cleaned rather than after, so a hostile description does not pay the full cost of a code-point walk before being cut down, and an oversized cast entry or genre never reaches a word wrapper. The blank check runs on the capped string, so a field padded with leading spaces up to the cap answers undefined rather than a placeholder. The matcher abstains rather than guesses. A provider's search endpoint ranks by popularity, not relevance, so "The Matrix" comes back with its sequels attached; the score has to be positively convincing before a poster is shown, because a wrong poster is worse than no poster and the user cannot tell it is wrong. Nothing imports this yet.
Metadata for a search row is looked up per selected row, so the same title is otherwise re-fetched every time the user scrolls back onto it. A module-level cache with a 30 minute TTL and an in-flight map collapses that: one film's four YTS quality rows, and the same film arriving from three trackers, resolve to a single entry and a single request. Misses are cached too, because scrolling repeatedly past an unmatched row should stop costing anything after the first pass. The cache key carries the kind as well as the id. The same IMDb id can arrive from a movies feed and from a TV feed that misclassifies it, and the two need different Cinemeta URLs; Cinemeta answers HTTP 200 with its "unknown id" stub for a wrong-type lookup rather than 404ing, so keying on the id alone lets one feed's stub poison the other's entry for a whole TTL. An in-flight request owns its own controller and counts the callers waiting on it, rather than handing every joiner the first caller's promise. Two hooks can sit on one row — the detail view and the info pane — and a shared promise cancelled by whichever caller left first would tell the other there was no metadata, with no way for it to notice: its effect deps have not changed. A caller that cancels gets its own null immediately, and the request is aborted only once nobody is left. An aborted lookup deliberately does not write the cache: a cancelled request is indistinguishable from a genuine miss at that point, and recording it would pin "no metadata" on the row for the whole TTL because the user scrolled past before the answer arrived. Negative entries carry their own two minute TTL, since at this layer a dead network and a title the provider genuinely does not have are the same empty answer, and the longer TTL would leave a session that started before DNS settled without metadata long after the network came back. YTS, EZTV and The Pirate Bay already know the IMDb id, so those rows skip the search-and-guess round trip entirely — apibay carries it on both category feeds, which makes it the largest source that can. The field is optional and every other adapter is untouched. Row dedupe carries the id across the merge. dedupeResults already folds a losing row's numFiles and added into the winner where the winner has none, on the rule that a field the winner never reported is a gap and not a decision. imdbId is the same kind of field, and the case is the common one rather than the corner: the healthiest row for a film is routinely the one from a source that carries no id at all, so without this the merge quietly hands the lookup back to guessing a title from the release name. A row is refused before the network when its source is Games-only, so FitGirl never queries at all, and when its release name carries no Latin-script title to search with — Nyaa's Chinese fansub names parse to a long but entirely unsearchable title, which a length check would wave through at one wasted request per visible row.
normalizeImdbId and imdbFromNumeric are generic IMDb id validators, not Cinemeta-specific, but living in cinemeta.ts made the lower sources/ layer import the metadata client just to sanitise a field of its own payload. Move both to a neutral src/meta/imdbId.ts and repoint yts, eztv, piratebay and cinemeta itself at it. Pure move: no behaviour or signature change, and the tests move with the functions, assertion for assertion. eztv and yts gain adapter-level tests for their id extraction while their imports are being touched, following piratebay's precedent of exporting its mapping function for exactly that purpose. The validators themselves are covered in imdbId.test.ts; what these pin is the wiring, without a network call. eztv's suite keeps main's search tests and gains these beside them rather than in place of them, so the module is now imported statically as well as through the suite's own resetModules helper. Its mockFetch moves to vi.hoisted for that: the vi.mock factory is hoisted above the file's top-level statements, so a static import of ./eztv would otherwise reach the factory before mockFetch was initialised.
The metadata pipeline built up to here — release parser, Cinemeta client, cache, hook — has had no visible surface. The detail view opened with Enter is the natural first home for it: rating, genres, director, cast and plot render below the existing Magnet row when metadata resolves, and render nothing extra otherwise, so a miss or a never-queried row looks exactly like the view did before. The rows are budgeted against the panel's real height rather than simply appended. At a realistic terminal size the panel's fixed, overflow-hidden content box has no slack once metadata sits on top of the existing torrent facts, and handing Ink more rows than fit does not clip cleanly — Yoga's flexbox shrink squeezes arbitrary rows to zero height instead, which drops facts and can hide the download/copy/export hint row entirely, the only on-screen way to discover those keys. So the available rows are computed before rendering, and rating, genres, director and cast are admitted in that priority order, with every row after the first that does not fit dropped rather than leaving a gap partway through the block. Plot is exempt from that cutoff and takes whatever is left over, capped with a forced ellipsis when it does not fully fit — the one row allowed to show less than it has.
Counting UTF-16 code units undercounts a CJK character — one unit, two terminal columns — by half. Nyaa is an anime index and Cinemeta returns Japanese, Korean and Chinese cast and plot text routinely, so the detail panel's row budget has to measure display width or it overflows on a common path rather than an edge case: rows dropped from the torrent facts, and the action hint fused with stray metadata. So the line-count and truncation math runs on an East Asian Wide/Fullwidth range table, and raw string slicing gives way to code-point-safe wrapping and ellipsizing, which also stops a surrogate-pair emoji being split in half. The table has to carry astral ranges as well as BMP ones, and that is easy to miss: an astral character costs two UTF-16 code units, which happens to match its two-column display width, so a length-based count got it right by accident. Iterating by code point removes the coincidence, and without the astral entries every emoji would measure one column — the same undercount the mechanism exists to prevent, moved from CJK text to emoji. One range needs care rather than breadth. The U+231A-U+2B55 span contains this app's own box-drawing border characters alongside genuinely wide symbols, so it is enumerated by known-wide code point rather than swept wholesale; a span-wide rule measures the panel's own chrome as double-width and corrupts every frame regardless of content. The helpers live in their own module because the detail panel is not the only view that needs them — the info pane beside the results list wants the same accounting, and a second copy of the width table is a second thing to get wrong when a new emoji block or CJK range has to be added.
Results.tsx carries the results list, the detail panel and their key handling, and had grown past 650 lines. The panel's row-budget planner is the part with the least to do with rendering: a pure function over a metadata object and a row count, with its own priority order and its own prefix-cut degradation rule. Both are worth reading — and testing — without a render in the way. Pure move. No behaviour change, and the tests that cover the budget through the rendered panel are untouched, so they still pin it end to end.
Both files stood a fixed `tick(ms)` sleep in for "the mocked promise settled and Ink flushed a frame". That guess holds on an idle machine and stops holding on a busy one, where these tests fail reliably — reading LOADING where the next row's title should already be, or counting one lookup where the row change should have forced a second. A test that only passes on an idle machine is not passing; CI runners are small and shared. Every positive fact now waits on itself with `vi.waitFor`, which retries until the fact is true rather than until a guessed interval expires. The sleeps that remain are the ones a waitFor cannot express. A purely negative step — the late resolution changed nothing, the re-sorted row did not ask again — would satisfy a waitFor on its first attempt and prove nothing, so it keeps a real sleep that gives the wrong behaviour room to appear. So do the debounces, which are wall-clock facts by construction; a sleep can only overshoot them on a loaded machine, which is the harmless direction. Each is commented with which case it is. Every converted wait was checked to need more than one attempt, so none of them resolves on the frame the mount already showed.
The results list can say how big a release is and how well it is seeded, but never what it actually is — which is the question a user scrolling twelve near-identical release names is really asking. Answering it meant opening the detail view on every candidate. The pane follows the cursor and answers it in place: title, year, rating, runtime, episode, genres, director and cast, on the row under the cursor. It auto-hides below 92 columns, where the list needs every column it has, and `i` toggles it anywhere else. The key is wired into both halves of keymap.ts, so it appears in the footer and on the `?` sheet. Fitting is measured before anything renders, not left to Ink: a panel that overflows is clipped by squeezing rows, which drops and fuses lines elsewhere in the block rather than cutting the one that overflowed.
The info pane can say what a release is in words, but a poster is the thing the eye recognises before it reads anything, and Cinemeta already hands us a URL for one. No terminal graphics protocol survives Ink's renderer — it repaints whole frames, so a sixel written into a cell is overwritten, mispositioned or measured as text on the next pass — so the art is drawn as text: U+2580 with the upper pixel as the foreground and the lower one as the background, which fits two pixel rows in one terminal row and comes out square against a cell's own 1:2 shape. jpeg-js is the one new dependency, pure JS with nothing under it. Both poster hosts are covered: the gate for this work was decoding a real progressive JPEG, because m.media-amazon.com serves baseline while images.metahub.space serves progressive even when asked for ?format=jpeg, and a decoder that only handled the first would have silently dropped every title that never went through the catalog. Both renditions are inlined as base64 constants in the tests rather than committed as fixtures. The cache key carries the pane's cell budget, not just the URL, and both resize directions are pinned on the rendered frame. Narrowing at least fails visibly, since the pane refuses a grid larger than its budget and loses its art; widening has no such backstop, and an undersized grid fits, draws, and leaves a poster that quietly no longer matches the pane it is in. Everything degrades to the text card that exists today. A URL off the host allowlist, a body over a megabyte, a WebP served with a .jpg name, a truncated download, a decoder that gives up, a pane too short, a grid left over from a wider pane mid-resize: each is a null and a card that renders exactly as it does without art. This is a React render path, so the alternative to failing soft is an unmounted TUI.
The pane's vertical budget was the binding constraint: the poster came out 8x6 at a 130x26 terminal and only reached its intended 24x18 at height 44 and above, and no split of a fixed pane fixes that. Rather than permanently trade text rows for art rows, the pane becomes a place the keyboard can go. Region gains "preview", filling the slot where → in the results list was a no-op, so the existing horizontal model simply runs one column further: sidebar ↔ list ↔ pane. Focused, the list falls back to MIN_LIST_WIDTH and the pane spends the difference, the art is sized from the width and its own aspect instead of from the rows the text card left over, and the whole card scrolls under a window with a dim line saying which way there is more of it. Both new keys are wired into HELP_GROUPS and footerHints alongside `i`. Focus can never land on a pane that is not on screen: the results view is the only place that knows the width tier, the `i` toggle and the section at once, so it reports that up and one clamped step function answers for every horizontal key — which also walks focus back out when a resize or a toggle takes the pane away while it is holding the keyboard. That single function is also what the rescue effect calls, so the recovery path cannot disagree with the keys. The focused pane stops widening once it has nothing left to spend width on: the art is capped whatever the pane does, and a text measure past the mid-fifties reads worse rather than better. At a 160-column content width that hands the list 99 columns instead of leaving it at its 52-column minimum with truncated names beside blank ones. Games is excluded because no provider answers for it and every row would read "No metadata"; `all` stays in, where those rows already say so one at a time. App.test.tsx now drives the real App — boot, search, arrows, esc, tab, all three modals, a live stdout resize and a section switch — with everything below it stubbed so mounting starts no engine and makes no request. The four ways this wiring can silently come apart (deleting the rescue effect, hard-coding the → gate shut, routing ← past the list, dropping footerHints' previewOpen argument) each fail a named test.
A literal ESC byte in a test string renders invisibly on GitHub, so a call
like `u.press` with a raw escape plus "[C" displays as `u.press("[C")` —
indistinguishable from a plain "[C", which Ink would actually deliver as
input "[C" with rightArrow false rather than as an arrow key. A maintainer
reading the diff has no way to tell the arrow-key tests are exercising the
right thing.
Add right/left/up/down to KEY in testHarness.ts, following the existing
enter/esc/ctrlU shape, and use them at the arrow-key call sites. The SGR
assertions in the poster and pane tests get the same treatment, with escaped
unicode sequences instead of raw bytes.
ansi-to-svg.ts's block-glyph branch drew only the foreground-coloured half of a half-block character (upper for U+2580, and so on) and skipped the background rect the general text path draws for every other run. That is invisible for a progress bar's solid-colour fill, and the existing previews are byte-identical either way — neither the wordmark nor the progress bar sets a background colour on these glyphs, so the new rect never fires for them. It matters for anything using the half-block trick, where the background fills the cell and the foreground covers half of it: without the rect, art drawn that way loses its bottom pixel on every row and renders as banded stripes over the page background instead of a picture. Paint boxFill (st.inverse ? fg : st.bg) under the glyph's own partial rect, mirroring the existing boxFill handling in the general path one branch down.
Add a fourth preview scenario ("info") to render-previews-impl.tsx showing
the metadata pane beside the results list, with poster art and the facts
card, at a terminal width wide enough to reach the poster tier. previewLayout
needs a content width of 86 or more for art, and the other scenarios' shared
80 columns never gets there, so this one runs at 120 on its own constants
without touching them. README gains one line on the `i`/`→`/`←` keys plus the
new screenshot.
browse.svg and downloads.svg also change in this regeneration, and not
because of this feature. Both were last committed at 661f90f, before the
`f Filter` footer hint and the "Highlight Selected Items" (baairon#130) row styling
landed on main — that is, before 7b29746, the commit this branch forks from.
Running `npm run previews` on a clean checkout of main reproduces the
identical diff in both files, so the drift predates this work; regenerating
simply corrects it.
planPaneLines and the line builders around it move verbatim out of MetaPane into src/ui/paneCard.ts, the same way the text-width helpers and the detail panel's row planner moved out earlier: a pure module the component and its tests read the same numbers from, with the row arithmetic now pinned without a render. The previews script was carrying a second, hand-rolled copy of the card and calling posterBudget directly for it. It now draws the app's card, so the screenshot cannot document a layout the app no longer has. Output is byte-identical.
The plot was fetched, capped and cached but never rendered, so the pane held a blank area under the cast credit — most of one when focused, where the card is six rows in a window of nineteen. planPaneLines now plans it last and outside the shared cutoff, the way the detail panel already does: it is the one field with no natural length, so it takes whatever the fixed-height facts above it underspent rather than being refused whole because a two-row credit did not fit. Zero rows is a legitimate answer on a short pane. Focused, the budget is infinite, so the whole synopsis is built and the window scrolls it. No spacer row above it — the pane is meant to read as one quiet block — and the planner needs no focus flag, because an infinite budget already says everything focus means here. The call is memoised on the row, the width and the budget: the pane re-renders on every search tick, and the word wrapper is linear in the plot.
A fixed 24-column cap bound the art in every configuration, so the poster measured the same at every terminal width and at both focus states: widening the pane bought text measure and nothing else. The tier table already caps how wide a pane gets, so the second cap was only ever a proxy for "the art should not eat the pane" — and fitCells does the aspect arbitration from the bounds alone. Width is now the pane's full inner width, and a taller pane grows the picture first, since unfocused art and plot compete for the same slack. Focused, a poster that fitCells had to cap by rows comes back narrower than the pane that asked for it, leaving columns empty beside it while the card stacked underneath had one row to say anything in. So the pane spends those columns on the card: the art takes the left column, the text flows down the right. splitTextCols owns the decision and the component does no arithmetic of its own; it answers null below MIN_TEXT_COLS, and null is the stacked layout the pane has always drawn — which is also the only layout unfocused, where a 34-column pane split two ways is neither. What bounds the text is the text measure, not the pane. MAX_TEXT_COLS wraps the card at 56 however wide the terminal is, and a focused pane may grow to artCols + gap + 56, so the picture is carried on top of the measure rather than out of it. Bounding the whole pane instead would cap the card at whatever the poster left over — fifteen columns at a 120-column terminal — and force the stack at exactly the sizes the split exists for. The split engages from a flat 113 terminal columns at every height, so a taller terminal is never less likely to split than a short one. Focusing guarantees MIN_FOCUSED_TEXT_ROWS of card on screen without scrolling — the identity block and two rows of plot, which is the difference between knowing a description exists and reading one. posterBudget decides which axis pays for it: wide enough to seat the card beside the picture and the art gives up columns while keeping the pane's whole height; otherwise it gives up rows and the card sits underneath. seatsCardBeside settles that from dimensions alone, before any byte is fetched, because previewLayout and posterBudget both need the answer and deciding it from the decoded art would make the pane's width depend on a decode that depends on the pane's width. previewLayout takes paneInnerRows for the same reason; callers with no pane yet get the stacked width, which is the width every caller had before. Scrolling keeps one window over one row list: row i is art row i beside card row i, the total is the taller column, and scrollStart slices it exactly as before. The poster is still text and still scrolls with the pane. The previews script now imports the poster rendition constants rather than carrying its own copy, so the screenshot reasons from the same numbers the app does. Results.test.tsx's usePoster mock calls the real fitCells rather than returning a grid at the full budget width — the narrowing is what the split is decided from, and a mock that skipped it would exercise only one of the two layouts.
Give the preview fixture a plot and screenshot the focused split state, where the poster and the full synopsis actually render side by side. This fixture's credits alone saturate the unfocused pane's fixed text budget, so the unfocused state would show no plot at all and document the feature as absent. The script derives the split from the same shared layout functions the app uses — previewLayout, posterBudget, fitCells, planPaneLines — and imports the poster rendition constants rather than carrying its own copy, so a screenshot cannot document a layout the app no longer has. It now also asserts the card fits without scrolling, which is the guarantee the focused pane makes. The stand-in poster ramps between colours already exported from theme.ts, since the previews never touch the network and there is no JPEG to decode.
The poster hook's tests stood a fixed `tick(ms)` sleep in for "the mocked promise settled and Ink flushed a frame". That guess holds on an idle machine and stops holding on a busy one, where the test asserting the intermediate loading state fails outright. Every positive fact now waits on itself with `vi.waitFor`, which retries until the fact is true rather than until a guessed interval expires, and each converted wait was checked to need more than one attempt so none of them resolves on the frame the mount already showed. The sleeps that remain are the ones a waitFor cannot express: a purely negative step — the unmounted row's late response paid for no decode — would satisfy a waitFor on its first attempt and prove nothing, so it keeps a real sleep that gives the wrong behaviour room to appear. The two exhaustive layout sweeps get an explicit timeout for the same reason. At thousands of configurations each they run comfortably inside vitest's 5 s default on an idle machine and exceed it on a loaded one, and a CI runner is small and shared. Budgeting them deliberately is better than a global raise, which would also hide a genuinely hung test.
The pane slices poster art with its scroll offset, which is what keeps a half-scrolled poster on screen instead of abandoning it. That property was expressed inline in MetaPane against one art shape. Add a second shape beside PosterCells — a grid whose rows are strings a graphics terminal resolves into a real image — and move the window into sliceArt, written once against the union of both. PosterCells itself is untouched, so every grid built by hand in the tests and the previews script still compiles; `in`-narrowing supplies the discriminant instead.
Ink's sanitizer drops an APC image escape and a sixel DCS alike, so the picture cannot be written into a cell. kitty's Unicode-placeholder path is text and survives intact: a cell is U+10EEEE plus marks naming its row and column, and the image id rides in the foreground colour Ink already emits. Every cell names both its row and its column rather than inferring the column from its neighbour, which is what lets the pane clip and slice the art and still have it decode. The pixels are raw RGB deflated with node:zlib — kitty takes PNG or raw and not JPEG, and sampleGrid already emits exactly the payload format, so the tier costs no dependency. The diacritic table is kitty's own derivation — combining class 230, no part in canonical composition — cut at the last entry no Unicode version can move. Transcribing a Unicode table by hand is exactly the kind of thing that rots silently, so its test re-derives that rule from ICU via Intl.Segmenter and asserts equality, with spot values checked independently of the derivation. A wrong transcription fails the suite rather than producing quietly wrong art.
1d43966 to
2a0d963
Compare
A terminal that cannot parse an APC escape prints it, so guessing wrong here costs a screenful of garbage rather than a missing picture. The detector is therefore a series of vetoes that writes nothing to the tty until every one has passed: no tty or CI, tmux by TMUX or by TERM (torlnk attach runs the whole app inside it), anything short of certain truecolour (the image id travels as a 24-bit foreground colour, so a downgraded palette names a different image), and finally no positive marker for a terminal family that implements Unicode placeholders. That last one is a placeholder list and not a graphics list, and the two are not the same set. WezTerm implements the graphics protocol and answers the query below with OK, but has never shipped placeholders — asked for since 2023, wezterm/wezterm#7924 still open — so a marker for it would clear every veto here and then draw a poster-shaped hole. The protocol offers no query for placeholder support, which is why this stays a family list rather than a capability check, and why the list is as short as it is. Only then does it ask, with a query transmission fenced behind a device attributes request: every terminal answers DA1, and it answers after the graphics reply, so a terminal that ignored the query gives a fast definite negative. The query's payload is the four bytes `AAAA` exactly. It is a one-pixel probe image, and base64 of three zero bytes needs no padding — appending `=` makes it invalid, which a strict terminal rejects outright: Ghostty logs `error.Base64Invalid` and replies with DA1 alone, indistinguishable from a terminal that has no graphics support at all. A test pins the whole query string rather than a prefix and a suffix, because a byte in the middle is precisely where this can hide, and the probe was checked against a real Ghostty session as well — the failure mode here passes every unit test. Both replies are consumed rather than left for Ink to read as keystrokes: the probe holds the tier it saw until the DA1 fence arrives or its budget expires, then drains whatever is still buffered, since pausing a stream does not discard what it already holds. A device-attributes report is filtered out of the search field too, which covers the one case the drain cannot — a reply that arrives after the probe has given up. Raw mode, the listener and a resumed stream are all handed back as they were found. TORLINK_GRAPHICS=off is the user's escape hatch; =kitty forces the tier past the probe, which is how the path can be exercised at all on a terminal that cannot be asked.
The tier is settled once at startup, above render(). The poster hook keys its cache by tier as well as by budget, asks for a larger source rendition when the terminal draws real pixels, and transmits the image before the state update that paints the placeholders addressing it — Node orders writes on a stream, which is the whole synchronisation between the two halves. Poster gains a branch that is cheaper than the one it already had: the image id is one colour for the whole picture, so a row is one Text instead of up to cols of them. MetaPane needed nothing but the widened type — cells are cells, and the tier changes what one contains rather than how many there are. Teardown names the ids this process issued, one escape each, rather than the `d=A` that frees every image in the terminal *window*. Quitting torlink should not drop a preview another tool drew there, and nothing else distinguishes ours from theirs. `q=2` keeps the acknowledgement off stdin, where Ink's parse-keypress would read it as input. The pane's scroll is asserted against the art itself: with graphics art mounted, one press of j moves the top visible row's row diacritic from 0 to 1. The picture scrolled because a row of it says which row it is, not because anything re-decoded. Frames carrying placeholders are measured by grapheme, never by textWidth.ts, and Poster.test.tsx pins that divergence with the reason it has to stay. TORLINK_GRAPHICS is documented in `--help` alongside the two token variables, not only in the README.
The teardown escape is now proportional to the session: one delete per image id issued, so a long browse writes a larger string than a single fixed escape ever did. Node's write to a TTY is synchronous on POSIX but not on Windows, so a large write immediately before process exit can truncate — and with the alt-screen restore trailing it, a truncated write takes the restore with it and leaves the user staring at the alternate buffer with no prompt. Emit the restore first. A lost tail then costs some images left in the terminal's own store, which is invisible and bounded, instead of a terminal the user has to fix by hand. Deleting by id is screen-independent, so nothing about the ordering weakens it.
decodeGraphicsPoster sized the source at a flat 8x16 pixels a cell and returned null once that passed MAX_RAW_BYTES, which is 3 125 cells — so the bigger the pane, the likelier it got no native image at all. At a 220x64 terminal the unfocused info pane drew a real image (30x23 cells, 265 KB) while the focused one exceeded the cap (68x51, 1332 KB) and fell back to half-blocks: focusing a row *downgraded* its poster, the inverse of what the tier is for. Scale the pixels-per-cell down to fit the budget instead. The transmission already declares the source size and the cell box separately and the terminal scales one into the other, so a clamped poster keeps its footprint and is only softer — 115 pixels a cell at 68x51, against the two that half-blocks would have given it. The scale is one square root applied to both axes, so nothing is stretched, and it is still computed from the cell dimensions before sampling, so an oversized pane never allocates a megabyte to decide. Both floors round down, so the buffer can only land under the ceiling. MAX_PLACEHOLDER_CELLS is untouched: art past the diacritic table still falls back to half-blocks. Worth stating plainly, since it is a real change in what goes over the wire: a pane past 3 125 cells used to send zero image bytes and now sends up to the full budget, again each time the cursor settles on a new row at that size. That is the trade the tier exists to make, and TORLINK_GRAPHICS=off opts out.
2a0d963 to
bc0d9a3
Compare
|
@ralyodio rebased onto main (918d3f6) too — conflicts resolved, so this applies cleanly now. And your comment saved this PR from shipping something wrong, so thank you for it. You going to install WezTerm made me go and check what WezTerm can actually do, and the answer is: not this. The tier here can't write an image into a cell directly — Ink's sanitizer drops an APC escape — so it uses kitty's Unicode-placeholder path instead: U+10EEEE cells with the image id carried in the foreground colour. WezTerm implements the kitty graphics protocol, but it has never shipped placeholders. They've been asked for since April 2023, wezterm/wezterm#7924 is still open, and there isn't a commit for it in the tree. The nasty part is that the probe cannot see the difference. It sends a query transmission asking "could you accept this image?", and WezTerm truthfully answers So WezTerm is out: removed from the marker, the README and this PR's title, with the reasoning in the code and a test pinning the refusal so it can't quietly drift back. Two lines and a test to add back the day #7924 lands. On WezTerm you now get #162's half-blocks, which is the right answer rather than a broken pane. If you'd rather confirm than take my word for it: One thing worth saying plainly, since cross-platform was your reason for picking wez: there is currently no terminal that does this on all three. kitty and Ghostty are macOS and Linux; on Windows nothing implements it yet (microsoft/terminal#17309 is open). That's a fair argument for treating this tier as a bonus and judging the feature on #162's half-blocks, which is what most people will actually see. If you do have a Mac or Linux box with kitty or Ghostty and still fancy a look: git clone -b pr3-kitty-graphics https://github.com/TechNapoleon/torlink
cd torlink && npm install && npm run devSearch for a film, arrow onto a result, |
What and why
#162 draws the poster as coloured half-blocks, which is the best a terminal can do with text. Some
terminals can do better — kitty and Ghostty can draw a real image — and on those the poster stops
looking pixelated.
The same pane on a terminal that can draw images. Here is the half-block fallback for comparison — same pane, same poster, every other terminal.
Ink's sanitizer drops an APC image escape and a sixel DCS alike, so the picture can't simply be
written into a cell. kitty's Unicode-placeholder path is text and survives: a cell is U+10EEEE
plus combining marks naming its row and column, and the image id rides in the foreground colour Ink
already emits. Every cell names both its row and column rather than inferring from its neighbour,
which is what lets the pane clip and scroll the art and still have it decode.
It's opt-out:
TORLINK_GRAPHICS=off(also0,none) gives half-blocks everywhere;TORLINK_GRAPHICS=kittyforces it past the probe, which is how the path can be exercised on aterminal that can't be asked. Documented in both the README and
--help.The detector writes nothing to the tty until every veto passes — no tty, CI, tmux (by
TMUXorby
TERM, sincetorlnk attachruns the app inside it), anything short of certain truecolour, andfinally no positive marker for a terminal family that implements Unicode placeholders. A terminal
that can't parse an APC escape prints it, so guessing wrong costs a screenful of garbage.
That last veto is a placeholder list, not a graphics list, and this PR originally got it wrong.
WezTerm implements the graphics protocol and answers the query transmission with
OK, so it clearedthe probe — but it has never shipped Unicode placeholders (asked for since 2023;
wezterm/wezterm#7924 is still open, and there are no
commits for it), which means it would have drawn a poster-shaped hole rather than a poster. The
protocol offers no query for placeholder support, so a family list is the only honest gate.
WezTerm is out, and gets the half-block tier like every other terminal; it is two lines and a
test to add back the day #7924 lands.
About the 129-entry Unicode table
A hand-transcribed Unicode table is exactly the kind of thing that rots silently, so it isn't taken
on trust:
src/meta/kittyGraphics.test.tsre-derives the whole table from ICU viaIntl.Segmenterand asserts equality, with spot values checked independently of the derivation. Ifthe transcription were wrong, that test fails. The table is a deliberately truncated prefix — art
needing more than 129 cells on an axis returns
nulland the half-block tier answers.One behaviour change worth stating plainly
A poster's source resolution is scaled to fit a fixed byte budget rather than being refused when it
exceeds it. That removes a real inversion — on a tall terminal, focusing the pane used to push the
poster over the cap and drop it back to half-blocks, so focusing made the picture worse. But it does
mean a large pane that previously sent zero image bytes now sends up to the budget, and sends it
again each time the cursor settles on a new row at that size. On a big terminal over SSH, that's the
trade;
TORLINK_GRAPHICS=offopts out.Teardown names the ids this process issued rather than the
d=Athat frees every image in theterminal window — quitting torlink shouldn't drop a preview another tool drew there. The
alt-screen restore is emitted before those deletes: Node's write to a TTY is synchronous on POSIX
but not on Windows, and a truncated exit write must not take the restore with it and strand the user
in the alternate buffer.
Checklist
npm run typecheckis cleannpm testpassesHELP_GROUPSandfooterHintsinsrc/ui/keymap.ts— no new keys in this PRStorefield, I updatedmakeStoreinscripts/render-previews-impl.tsx— no new Store fields in this PR64 test files, 700 tests. The capability probe was also verified against a real Ghostty session
rather than only in unit tests — the payload is the kind of thing that passes every test while
failing on hardware, so there's a test pinning the exact query byte string.