Add Kitty graphics protocol support on top of this branch - #2
Open
kotamat wants to merge 7 commits into
Open
Conversation
It may cause a `NullPointerException` on Android `6`. ``` java.lang.RuntimeException: Unable to create service com.termux.app.TermuxService: java.lang.NullPointerException: Attempt to invoke interface method 'java.lang.String java.lang.CharSequence.toString()' on a null object reference at android.app.ActivityThread.handleCreateService(ActivityThread.java:3048) at android.app.ActivityThread.access$2000(ActivityThread.java:156) at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1493) at android.os.Handler.dispatchMessage(Handler.java:102) at android.os.Looper.loop(Looper.java:148) at android.app.ActivityThread.main(ActivityThread.java:5609) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:746) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:636) Caused by: java.lang.NullPointerException: Attempt to invoke interface method 'java.lang.String java.lang.CharSequence.toString()' on a null object reference at android.app.Notification$Builder.processLegacyText(Notification.java:3217) at android.app.Notification$Builder.access$1100(Notification.java:2009) at android.app.Notification$BigTextStyle.makeBigContentView(Notification.java:4283) at android.app.Notification$BigTextStyle.populateBigContentView(Notification.java:4321) at android.app.Notification$Style.buildStyled(Notification.java:3858) at android.app.Notification$Builder.build(Notification.java:3661) at com.termux.app.TermuxService.buildNotification(TermuxService.java:841) at com.termux.app.TermuxService.runStartForeground(TermuxService.java:206) at com.termux.app.TermuxService.onCreate(TermuxService.java:120) at android.app.ActivityThread.handleCreateService(ActivityThread.java:3038) ... 8 more ``` - https://cs.android.com/android/platform/superproject/+/android-6.0.1_r1:frameworks/base/core/java/android/app/Notification.java;l=4135 Closes termux#5113
This reverts commit 8aca6db as sponsorship ended after 3 months.
…ds could not be decoded as it hangs the terminal `TerminalBitmap.build()` first decodes only the bounds of the image data received for an iTerm image (`OSC 1337`) by calling `BitmapFactory.decodeByteArray()` with `BitmapFactory.Options.inJustDecodeBounds` set to `true`, so that an image that is too large to be drawn is not decoded at all. If `BitmapFactory` fails to decode the bounds, then `BitmapFactory.Options.outWidth` and `outHeight` are set to `0` instead of the image dimensions, which was not checked before the values were used. A `0` value for `imageWidth` or `imageHeight` results in a division by `0` while calculating the `wFactor` and `hFactor` scaling factors for an image whose aspect ratio is to be preserved, and in the `scaleFactor` loop after it never terminating, since the `imageHeight >= 2 * newHeight * scaleFactor` and `imageWidth >= 2 * newWidth * scaleFactor` conditions stay `true` for `0 >= 0` for any value of `scaleFactor`, until `scaleFactor` overflows to `0` after `201` iterations and then stays `0` forever. The terminal is hung on the thread that processes the terminal output and does not recover. The hang is triggered by sending an iTerm image command with a `width` parameter and image data that cannot be decoded, like `OSC 1337;File=inline=1;width=10:<invalid base64 image data> ST`.
…t before an image on the same line is not lost `TerminalRenderer.render()` accumulates consecutive columns of a line that have the same style into a text run, which is drawn with a single `drawTextRun()` call once the style changes or the end of the line is reached. The branch for a cell whose style is a terminal bitmap did not draw the run that was still pending before it and only reset the run state, so any text before an image on the same line was discarded and never drawn at all. The start of the run after the bitmap was also wrong. The `lastRunStartColumn` was set to `column + 1` after `column` had already been incremented past the cell of the bitmap, so the next run started one column too late and its text was drawn shifted by one cell. The `lastRunStartIndex` was set before `currentCharIndex` was incremented past the cell of the bitmap, so it pointed at the character of the bitmap cell instead of at the first character of the next run. Both issues affect sixel (`DCS q`) and iTerm (`OSC 1337`) images equally and are reproduced by printing text before an image escape sequence without a newline between them.
…he column after it is the last column of the screen After a terminal bitmap has been added to the screen for an image, the cursor is moved to the column after the image on the last row the image covers if that position is still on the screen, and is otherwise wrapped to the first column of the row after the image. The condition used to check this was `col < mColumns - 1`, where `col` is the `0` based index of the column after the last column the image covers, which is off by one. The position is on the screen as long as `col` is a valid column index, so the condition must be `col < mColumns`. With the old condition, an image whose following column is the last column of the screen was treated as if it did not fit, and so one row more than the image covers was consumed and the cursor was left at the first column of that row. Note that this changes the behaviour for sixel (`DCS q`) and iTerm (`OSC 1337`) images, since this is the shared code path for the cursor position after any terminal bitmap. On a `56` column screen with the cursor at column `54`, printing an iTerm image that is `2` cells wide and `1` cell high covers the columns `54` and `55` and the cursor position reported by `CSI 6 n` changes from row `7` column `1` to row `6` column `56`.
…_G`) Support for displaying images sent with the kitty graphics protocol has been added on top of the existing terminal bitmap infrastructure that is used for sixel (`DCS q`) and iTerm (`OSC 1337`) images. A command is in the format `APC _ G <control data> [; <payload>] ST`, where `control data` is a comma separated list of `<key>=<value>` pairs with single character keys and `payload` is the `base64` encoded image data. Only a minimal subset of the protocol is supported, which is enough to transmit an image and display it. The `CSI` `Primary Device Attributes` response is not changed, since clients detect kitty graphics protocol support by sending an `a=q` query command and checking the response for it. The following is supported: - The `a=t` (transmit), `a=T` (transmit and display), `a=p` (display an image that was already transmitted), `a=q` (query) and `a=d` (delete) actions. - The `t=d` (direct) transmission medium, where the image data is sent in the command payload. - The `f=100` (`PNG`), `f=24` (raw `RGB`) and `f=32` (raw `RGBA`) image formats, with the pixel dimensions of the raw formats passed with the `s` and `v` keys. - The `m=1`/`m=0` chunked transmission of the image data of a single image with multiple commands. - The `i=<image id>` and `p=<placement id>` keys, and the `d=a`, `d=A`, `d=i` and `d=I` delete modes. - The `c=<columns>` and `r=<rows>` keys for the number of cells to display the image in. - The `x=`, `y=`, `w=` and `h=` keys for displaying only a source rectangle of the transmitted image. - The `C=1` key for not moving the cursor after displaying an image. - The `q=<quiet level>` key for suppressing success and error responses. The following is **not** supported and results in an `ENOTSUP` error response: - The `t=f`, `t=t` and `t=s` transmission mediums, where the image data is read from a file or shared memory. Reading arbitrary files that the terminal has access to on behalf of a client is a security concern, so this is intentionally not implemented. - The `o=z` (`zlib`) compressed image data. - The `a=f` (animation frame), `a=a` (animate) and `a=c` (compose) actions. The keys for unsupported features that a client is expected to be able to send for images this terminal can display are ignored instead of resulting in an error response. These are the `z` (z-index), `X` and `Y` (cell pixel offset) and `U` (unicode placeholder) keys. Not honouring `X` and `Y` means an image is aligned to the cell grid instead of being offset by up to one cell width and height. Implementation: - `KittyImage` parses the control data of a command, decodes the `base64` image data as it is received and holds the state of an image that is transmitted with multiple commands. The `base64` image data is not collected in `TerminalEmulator.mTerminalControlArgs` first, so a client may send an entire image with a single command instead of splitting it into the chunks of at most `4096` bytes the protocol recommends, and the total image data size is only bound by `KittyImage.IMAGE_DATA__MAX_LENGTH` (`TerminalBitmap.MAX_BITMAP_SIZE`). Only the control data is stored in the args buffer, which is limited to `4KB` by `TerminalEmulator.KITTY_GRAPHICS_CONTROL_DATA__MAX_LENGTH`, and a command with longer control data is discarded instead of being printed on the terminal as text. - `TerminalEmulator` gained an APC command type, set by `setApcTypeVariables()` from the first code point after the `APC` escape sequence, so that a kitty graphics command takes the fast path in `processCodePoint()` while all other APC commands keep being parsed and ignored like `xterm` does. The command is dispatched by `doApcKittyGraphics()` and responded to with `APC _Gi=<image id>;OK ST` or `APC _Gi=<image id>;<error code>:<error message> ST` as per the `q` key. - The image data transmitted with the `a=t` and `a=T` actions is stored for its image id in `TerminalEmulator.mKittyImages` so that it can be displayed again with an `a=p` command without being transmitted again. The map is in insertion order and the oldest images are evicted first once `KITTY_IMAGES__MAX_COUNT` (`32`) or `KITTY_IMAGES__MAX_TOTAL_SIZE` (`8MB`) is exceeded, and it is cleared on a terminal reset. - `TerminalBitmap.buildForKittyImage()` creates the bitmap for a placement. It cannot use the iTerm image path since the source rectangle of the `x=`, `y=`, `w=` and `h=` keys must be cropped before the image is scaled to the cells it is displayed in, otherwise the wrong part of the image would be scaled, and the raw `RGB`/`RGBA` formats are not decoded by `BitmapFactory`. The scaling calculation that both paths share has been extracted into `getScaledImageSize()`. A `PNG` is only subsampled while decoding if the entire image is displayed, since the source rectangle coordinates are in the coordinate space of the full size image, and its size is checked against `MAX_BITMAP_SIZE` before decoding instead. - `TerminalBuffer.addTerminalBitmapForKittyImage()` adds the bitmap to the screen, and `deleteKittyImagePlacements()` and `deleteAllKittyImagePlacements()` remove the bitmaps of the placements deleted by an `a=d` command and clear the cells of the screen and the transcript they were rendered in. The image id and placement id are stored in the `TerminalBitmap` itself instead of a separate registry so that they do not need to be kept in sync when bitmaps are removed and their bitmap numbers are reused. - `TerminalBuffer.releaseUnreferencedTerminalBitmaps()` releases the bitmaps of the cells that a new bitmap overwrote if they are no longer referenced by any cell. This does not rely on `doTerminalBitmapsGC()`, which only runs once per `timeDelta`, since an `a=p` command can display an already transmitted image with around 30 bytes, so a client that displays a new image at the same position for every frame would otherwise keep every one of them in memory. `KittyGraphicsTest` has been added with 74 tests covering the control data parsing, the supported and unsupported actions, keys and formats, the response and quiet levels, chunked transmission, the cursor position after an image, deletion and the image data limits. The comment of `ApcTest.testApcConsumed()` has been updated, since the `yazi` capability query it sends is now answered on stdin instead of being consumed silently. - https://sw.kovidgoyal.net/kitty/graphics-protocol/
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.
Summary
This PR implements the Kitty graphics protocol (termux#5068) by wiring up the APC (
ESC _ G ... ESC \) parsing path that already exists on this branch but is inert —doApc()is a no-op and the call fromreceiveApcEsc()is commented out ("Eat APC sequences silently for now").This PR is based directly on
sixel4and adds a second front-end that shares the same backend your image work introduced here — the same image registry, bitmap storage, and rendering path. It does not modify your Sixel or iTerm2 code paths, aside from three independent bug fixes described below. The diff againstsixel4is purely additive: 9 files changed, 3,459 lines added, across 7 commits.I've opened the same underlying change against
termux/termux-app'smasterbranch as well, as termux#5238. That copy necessarily also carries this branch's own commits along with it, sincemasterdoesn't have this work yet, so the diff there is larger and includes code that isn't mine. You only need to look at one of the two, whichever is more convenient — they're the same change.Relationship to this branch and to the maintainer's stated direction
In issue termux#5068 on
termux/termux-app, @robertkirkman noted that Sixel should be prioritized for merge ahead of Kitty support, since Termux isn't in the position kitty.js is in and shouldn't drop Sixel. I agree with that ordering, and this PR is written on that basis: it assumes your Sixel/iTerm2 work (termux#2973) merges tomasterfirst, and adds Kitty as a second, independent front-end on top of it, without touching your Sixel/iTerm2 code paths.I'm opening this directly against
sixel4rather than waiting for termux#2973 to land onmaster, so it's visible and reviewable in parallel, and so the bug fixes below (which apply to this branch directly, independent of Kitty) aren't lost in the meantime. I'm not asking for this to be merged ahead of your work — this is offered as something to pick up if and when it's useful, entirely on your timeline. As I see it, the options are: take this whole PR once termux#2973 merges tomaster, leave it separate/deferred while termux#2973 is reviewed on its own, or take just the three independent bug fixes described below now and set the Kitty front-end aside for later (or not at all). Whatever's most convenient for you is fine with me.What's implemented
a=t(transmit)a=T(transmit + display)a=p(put / placement)a=q(capability query)a=d(delete: all,d=a/d=A,d=i/d=I)t=d(direct, base64)f=100(PNG)f=24(RGB raw)f=32(RGBA raw)m=1/m=0i=(full 32-bit unsigned range)p=(image_id, placement_id)c=/r=s=/v=x=/y=/w=/h=q=(0/1/2)OK,<CODE>:<msg>)C=1(don't move cursor after placement)t=f/t=t/t=s(file, temp file, shared memory)ENOTSUPo=z(zlib)ENOTSUPa=f/a=a/a=cENOTSUPU=1)z=(z-index)X=/Y=(sub-cell pixel offset)On
U=1: some clients (e.g. yazi) can use the Unicode placeholder path, but since Termux advertises Sixel support in DA1, yazi selects Sixel instead of Kitty's placeholder mechanism in that case, so this gap does not affect yazi in practice. It would matter for a client that requires Unicode placeholders specifically.Reuse of this branch's infrastructure
This PR does not introduce a parallel image system. It reuses, unmodified in behavior:
TextStyleTerminalBuffer's image registry and scroll-driven garbage collectionTerminalBitmap's cell-splitting and size-limiting logicTerminalRenderer's existing bitmap draw branchYour Sixel and iTerm2 code paths (
build(byte[]...),build(TerminalSixel...),addTerminalBitmapForImage()) are structurally unchanged by this PR — Kitty parsing produces the same intermediate bitmap representation that Sixel/iTerm2 already produce, and goes through the same storage/rendering code from there.Tests
sixel4, unmodified../gradlew :terminal-emulator:testreports 219 tests / 0 failures / 0 errors for bothtestDebugUnitTestandtestReleaseUnitTest.len % 4 == 1, excess padding, data after padding, padding in a non-final chunk), unknown actions/keys, interruption mid-chunk-transfer, sequences left open withm=1and never closed, oversized dimensions, deletion of nonexistent ids, out-of-scope keys for a given action, very largei=values, and response suppression behavior for eachq=level.java.util.Base64as an independent reference implementation, across a range of chunk split points, to confirm the incremental decoder produces byte-identical output to decoding the same payload in one piece.Verification on emulator
All of the following was verified by running the build on an Android emulator (API 35, AOSP system image, arm64-v8a, headless, software rendering) and inspecting the result — not just by reading the code. Verification consisted of pixel measurements on captured screenshots,
dumpsys meminforeadings, andadb logcat -b crashchecks, unless noted otherwise:f=24), raw RGBA (f=32), and PNG (f=100), at sizes from 1×1 up to 64×64.a=p: one transmit followed by five separate put commands, all five renders confirmed present.c=/r=cell-based scaling is pixel-accurate against a 19×38px cell size: both dimensions specified matches exactly, one dimension specified preserves aspect ratio, neither specified renders at native size.C=1leaves the cursor position unchanged, confirmed via DSR cursor-position queries before and after placement.Verification on physical device
The above was also verified on a physical device: Samsung SM-F966Q (Galaxy Z Fold7-generation), Android 16 / SDK 36, arm64-v8a, 10.85GB RAM. Measured terminal calibration: origin at (7,119), cell size 19×38px, 56 columns × 56 rows. A 1-cell image renders as exactly 19×38 = 722px of solid color with zero bleed into neighboring cells; this was reproduced twice.
Verified items (each backed by a pixel measurement or a DSR-based numeric check):
L2_A/ image /L2_B/ image /L2_C), three adjacent segments (text in columns 0–3, a 6-cell-wide bitmap in columns 4–9, text in columns 10–13), and a case where styled text spans a renderer flush boundary —L7_RED_rendered in (205,0,0) before an image,L7_GRNrendered in (0,205,0) after it — confirming that the flush correctly carrieslastRunStyleacross the boundary.f=24,f=32, andf=100, with histograms matching exactly across formats (no channel-order drift).OutOfMemoryErroror ANR occurred.cmd device_state state 3was used to force a column-count change (56×56 → 102×48), exercising the terminal's reflow path. No crash. iTerm2, sixel, and Kitty images all produce identical reflow artifacts — see the corresponding entry in Known Limitations below.Memory under sustained placement load
Measured under a streaming workload (one
a=ttransmit followed by 200a=pre-placements of the same image), sampled 1,738 times:Memory returns to baseline+13MB within roughly 10 seconds and stays flat from there; the residual is consistent with the single displayed 1064×760×4 = 3.2MB bitmap. This was checked against a false-pass: the on-screen marker
=== HEAVY DONE 200 frames ===confirmed the full run completed, the rendered image area measured 1064×760 with each of the four quadrants at exactly 200,870px, and there were noKittyImage/TerminalBitmaperror log lines during the run.Why this matters:
a=pre-places an already-cached image with a command of roughly 30 bytes, so placements can happen at a much higher rate than Sixel/iTerm2 allow, since those protocols require re-sending the full image on every frame and are bandwidth-bound as a result. A naive implementation could accumulate orphaned bitmaps under that kind of load. This implementation performs targeted collection at placement time: it collects thebitmapNums referenced by the cells being overwritten, scans only the rows withmHasTerminalBitmapset, and exits early once a reference to a given bitmap is found elsewhere. It does not callrecycle()— references are simply dropped and left to the GC, since the renderer may hold a reference from a different thread. The existing throttleddoTerminalBitmapsGCon this branch is unmodified.Included bug fixes (independent of Kitty, directly applicable to this branch)
While building the Kitty front-end on top of this branch's image pipeline, I found and fixed three pre-existing issues unrelated to the Kitty protocol itself. Each is isolated into its own commit on top of
sixel4, so any of them can be reviewed, taken, or set aside independently of the rest of this PR — you don't need to take the Kitty commits to take these.Infinite loop in
TerminalBitmap.build(). WhenBitmapFactoryfails to decode bounds for the input,outWidth/outHeightcome back as 0, and the aspect-ratio-preservingscaleFactorloop condition0 >= 0never terminates. In testing,scaleFactoroverflows and pins at 0 after 201 iterations, so the loop spins indefinitely rather than overflowing into a crash. On the current state ofsixel4, this is reachable directly, with no Kitty involvement, fromOSC 1337;File=inline=1;width=10:<garbage>— a malformed iTerm2 image sequence can hang the JVM.Text preceding an image on the same row is dropped. In
TerminalRenderer.java(lines 106–121), the bitmap-drawing branch discards a pending text run instead of callingdrawTextRunfor it, and separately has an off-by-one inlastRunStartColumn(column + 1). This reproduces identically with this branch's existing iTerm2 (OSC 1337) images, so it isn't Kitty-specific — it's a pre-existing rendering issue in the shared renderer path that this PR's tests happened to surface.Cursor position when an image ends exactly in the last column. The bounds check used
col < mColumns - 1, which treats an image that ends exactly at the last column as "didn't fit," consuming one more row than the image actually covers and leaving the cursor at the start of the following row.col < mColumnsis the correct condition.The evidence for this one comes specifically from the existing iTerm2 (
OSC 1337) path, not from a Kitty-vs-Kitty comparison — on a pristinesixel4checkout, Kitty's APC sequences are still parsed and discarded, so no Kitty image is ever displayed and there is nothing to compare against there. To isolate the bug from anything Kitty-related, I built a pristinesixel4checkout in a separate git worktree and compared it directly against this PR's tree using an iTerm2 image, both built and run the same way:sixel4, a 2×1 iTerm2 image placed in columns 54–55 (1-based) of a 56-column screen: cursor-position DSR reports[7;1(wrapped to the next row, column 1).[6;56(the image's last row, column 56 — the next empty cell after the image, so subsequent characters do not overlap it).Because this changes existing Sixel/iTerm2 cursor behavior, not just Kitty, it's kept as its own commit so it can be evaluated, taken, or dropped independently of the rest of this PR. If you consider the current behavior intentional, this commit can be dropped without affecting the Kitty support — the only consequence would be that a Kitty image ending exactly in the last column would keep consuming one extra row, same as iTerm2/Sixel currently do. Three unit tests are included (image ending exactly in the last column, image that genuinely doesn't fit, and the normal case).
Known limitations / honest caveats
s=/v=values are used as the primary input to source-rectangle validation. The decoded bitmap dimensions remain the ground truth for rendering, so this is safe in the sense that it can't produce out-of-bounds reads, but a client that declares dimensions inconsistent with the actual PNG could get a legitimate crop request rejected withEINVAL.d=a/d=Adeletes all Kitty placements regardless of current on-screen visibility; the spec describes this action as scoped to placements "visible on screen."TextStyleencodesbitmapNumin 16 bits whileTERMINAL_BITMAP__NUM_ENDis defined asInteger.MAX_VALUE. This mismatch predates this PR — it's already present onsixel4— and is not touched here.TerminalBuffer.resize()does not remap bitmap-bearing cells. This is existing behavior already present onsixel4, not changed by this PR.Emulator vs. physical device: notable differences
What I'd like reviewers to look at
master, keep it separate/deferred while Add graphics in terminal support: - Sixel and iTerm2 protocols termux/termux-app#2973 is under review, or just take the three bug fixes below independent of the Kitty front-end — happy to go whichever way is most useful to you.sixel4instead of riding along here — in particular bug fix [FR] option to read password with termux-api? termux/termux-app#3, since it changes existing Sixel/iTerm2 cursor behavior and you may want to weigh in on whether the current behavior was intentional.U=1/ Unicode-placeholder gap — whether that's an acceptable scope cut for a first pass, or a blocker.ENOTSUPpaths (file/shared-memory transmission, zlib, animation), in case there's a preferred convention already used elsewhere in the codebase.