diff options
| author | cashmere <cashmere@autistici.org> | 2026-03-29 06:13:48 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-03-29 06:13:48 +0200 |
| commit | 6dff6b6a9501a86d1d5713a7901c3803d422fdda (patch) | |
| tree | dc594ff6838458e722b5a1b4d95ea0c17e4e78e2 | |
| parent | 73c3a7eb5d65ec81cc2f43d4a9413544c7e538c6 (diff) | |
| parent | fab72f4962d992fff5818b11d195c18b715eb9b8 (diff) | |
| download | kitty-graphics-6dff6b6a9501a86d1d5713a7901c3803d422fdda.tar.gz | |
feat: add Sixel graphics backend with protocol auto-detection
- Introduce alist-based backend dispatch (kitty/sixel)
- Add Sixel encoding via ImageMagick with temp-file cache
- Auto-detect protocol: try Kitty first, then Sixel
- Add kitty-gfx-preferred-protocol defcustom
- Sixel erase via space-overwriting (stateless protocol)
- Cleanup temp files on kill-emacs
- Update README and AGENTS.md for multi-protocol support
- Bump version to 0.3.0
| -rw-r--r-- | AGENTS.md | 267 | ||||
| -rw-r--r-- | PLAN-sixel-expansion.md | 298 | ||||
| -rw-r--r-- | README.org | 70 | ||||
| -rw-r--r-- | kitty-graphics.el | 580 |
4 files changed, 1090 insertions, 125 deletions
diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..62d8813 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,267 @@ +# AGENTS.md -- kitty-graphics + +## Project Overview + +Pure Emacs Lisp package (`kitty-graphics.el`) for displaying images in +terminal Emacs (`emacs -nw`) using either the Kitty graphics protocol or +Sixel, with automatic protocol detection. Also renders org-mode headings +at scaled font sizes using the Kitty text sizing protocol (OSC 66). +Integrates with org-mode, image-mode, shr/eww/mu4e/gnus, dired, and dirvish. + +Language: **Emacs Lisp** (`lexical-binding: t`). Single source file (~1876 lines). +Current version: **0.3.0** (see Versioning section). + +## Build / Validate + +### Byte-compile (primary lint check -- run after every change) + +```sh +emacs -Q -batch -f batch-byte-compile kitty-graphics.el +``` + +**This is the closest thing to CI** -- always run it before considering +a change complete. + +### Load-test (verify file evaluates without error) + +```sh +emacs -Q -batch -l kitty-graphics.el -f kill-emacs +``` + +## Testing + +No automated test framework. All testing is manual/interactive and +requires a supported terminal (Kitty, WezTerm, Ghostty for Kitty backend; +foot, Konsole, xterm, mlterm, mintty for Sixel backend). +The `tests/` directory contains `test-kitty-gfx.org`, `test-image.png`, +and `test-document.pdf` for manual testing -- use these rather than +creating new test files. + +**Important**: Always set `TERM=xterm-256color` when launching terminal +Emacs -- the `xterm-kitty` terminfo is often missing. + +### Test with org-mode + +```sh +TERM=xterm-256color emacs -nw -Q -l kitty-graphics.el \ + --eval "(kitty-graphics-mode 1)" tests/test-kitty-gfx.org +# Then: C-c C-x C-v (org-toggle-inline-images) +``` + +### Test text sizing (org heading scales) + +```sh +TERM=xterm-256color emacs -nw -Q -l kitty-graphics.el \ + --eval "(setq kitty-gfx-heading-sizes-auto t)" \ + --eval "(kitty-graphics-mode 1)" tests/test-kitty-gfx.org +# Headings should render at scaled sizes automatically. +# S-TAB to unfold all, then scroll to verify fold/unfold/scroll. +``` + +### Test image-mode + +```sh +TERM=xterm-256color emacs -nw -Q -l kitty-graphics.el \ + --eval "(kitty-graphics-mode 1)" tests/test-image.png +``` + +### Debug logging + +Set `kitty-gfx-debug` to `t` to log debug info to `/tmp/kitty-gfx.log`. + +### Dev environment + +The repo uses a Nix flake (`.envrc` → `use flake`). If you have direnv +and Nix, dependencies are provided automatically. This is optional -- +only `emacs` and optionally `imagemagick` are needed. + +### Agent workflow + +When you need the user to manually test a feature in a Kitty-compatible +terminal, or have any question that requires user action, notify them: + +```sh +notify-send "kitty-graphics" "Please test: <description of what to test>" +``` + +## Code Style + +### File structure (mandatory order) + +``` +;;; file.el --- Description -*- lexical-binding: t; -*- +;;; Commentary: +;;; Code: +(require ...) ; dependencies +(declare-function ...) ; forward declarations for optional deps +(defgroup ...) ; customization group +(defcustom ...) ; user options +(defconst ...) ; constants +(defvar / defvar-local) ; internal state +;; ... implementation sections with ;;;; headers ... +(provide 'feature) +;;; file.el ends here +``` + +### Formatting + +- **Spaces only**, no tabs (`indent-tabs-mode: nil`; enforced by `Local Variables` block) +- `lexical-binding: t` required in the first line +- `;;;;` section headers to separate logical sections + +### Naming conventions + +| Category | Pattern | Example | +|-----------------------|--------------------------------|----------------------------------| +| Public API | `kitty-gfx-` prefix | `kitty-gfx-display-image` | +| Internal functions | `kitty-gfx--` (double dash) | `kitty-gfx--transmit-image` | +| Buffer-local vars | `defvar-local` + double dash | `kitty-gfx--overlays` | +| Customization vars | `defcustom` + single dash | `kitty-gfx-max-width` | +| Global internal vars | `defvar` + double dash | `kitty-gfx--next-id` | +| Advice functions | `kitty-gfx--MODE-VERB-advice` | `kitty-gfx--org-display-advice` | +| Minor mode | `kitty-graphics-mode` | | + +All identifiers use `kebab-case`. + +### Requires / Dependencies + +- Only `cl-lib` is `require`d directly +- All other dependencies (org, image-mode, shr, dired, dirvish) use + `declare-function` for forward declarations and `with-eval-after-load` + for deferred integration -- **never `require` optional packages at + top level** + +### Error handling + +- `user-error` for user-facing errors (e.g., unsupported terminal) +- `condition-case` around I/O (terminal queries, files, image processing) +- `ignore-errors` around `send-string-to-terminal` -- terminal writes + must never crash Emacs +- `when`/`unless` guards rather than explicit error branches +- `unwind-protect` for cleanup (see `kitty-gfx--refresh`) + +### Advice convention + +All advice uses `:around` and guards on both mode and terminal: + +```elisp +(defun kitty-gfx--FOO-advice (orig-fn &rest args) + (if (and kitty-graphics-mode (not (display-graphic-p))) + ... ;; terminal path + (apply orig-fn args))) +``` + +New mode integrations **must** follow this pattern. Each mode needs +explicit advice -- there is no generic image display hook. + +## Architecture + +### Backend dispatch + +Protocol-specific code is isolated behind an alist-based backend system: + +- `kitty-gfx--backends` -- alist mapping `'kitty` / `'sixel` to operation alists +- `kitty-gfx--active-backend` -- symbol for the detected backend +- `kitty-gfx--backend-fn` -- dispatch helper: `(funcall (kitty-gfx--backend-fn 'place) ...)` +- `kitty-gfx--detect-protocol` -- tries Kitty (env check), then Sixel; sets active backend + +Each backend implements: `detect`, `prepare`, `place`, `delete`, `cleanup`, `cleanup-all`. + +### Image pipeline (shared) + +1. **Prepare**: backend-specific -- Kitty transmits via APC `a=t`; Sixel encodes via ImageMagick to temp-file +2. **Reserve space**: `kitty-gfx--make-overlay` -- overlay with blank `display` property +3. **Place**: backend-specific -- Kitty emits `a=p` with `p=PID`; Sixel reads temp-file and emits DCS +4. **Refresh**: `kitty-gfx--refresh` -- debounced via `run-at-time`; walks windows, + re-places moved overlays, deletes hidden ones +5. **Cache**: `kitty-gfx--image-cache` -- keyed by file path → image ID (integer). + Dimensions are computed fresh each time. LRU eviction via `kitty-gfx--cache-lru` + (max `kitty-gfx-cache-size` entries, default 64). + Sixel also caches encoded data in temp-files under `/tmp/kitty-gfx-sixel-*.six`. + +Overlay properties: `kitty-gfx` (bool marker), `kitty-gfx-id` (image ID), +`kitty-gfx-pid` (placement ID), `kitty-gfx-cols`/`kitty-gfx-rows` (cell size), +`kitty-gfx-last-row`/`kitty-gfx-last-col` (cached position, nil if hidden), +`kitty-gfx-file` (source file path, needed by Sixel for re-encoding). +All terminal output is wrapped in synchronized output (BSU/ESU, DEC mode +2026) to prevent flicker. + +### Text sizing (OSC 66) + +Unlike images, the text sizing protocol is **stateless** — there are no +image IDs or placement IDs. The terminal renders scaled text inline +but any redraw clears it. + +1. **Detect**: `kitty-gfx--query-text-sizing-support` — 3 CPR queries + to probe width-only vs scale vs no support. Cached in + `kitty-gfx--text-sizing-support` (`scale`, `width`, or `none`). +2. **Scan**: `kitty-gfx--org-apply-heading-sizes` — walks org headings, + looks up scale from `kitty-gfx-heading-scales` alist, creates overlays. +3. **Overlay**: `kitty-gfx--make-heading-overlay` — overlay **without** + `display` property (Emacs shows the heading line normally; OSC 66 is + painted on top by the refresh cycle). No vertical space reservation. +4. **Place**: `kitty-gfx--place-heading` — emits `\e]66;s=SCALE;TEXT\a` + at cursor position with SGR bold + 24-bit foreground color from + `org-level-N` faces. +5. **Refresh**: `kitty-gfx--refresh-heading-overlay` — **always re-emits** + OSC 66 when visible (stateless protocol). Erases old position on move + via `kitty-gfx--erase-heading` (writes spaces across all rows — + per spec, overwriting any cell in the topmost row of a multicell + character erases the entire block). + +Heading overlay properties (in addition to the common ones above): +`kitty-gfx-heading` (bool), `kitty-gfx-heading-text`, `kitty-gfx-heading-scale`, +`kitty-gfx-heading-level`. Heading overlays have `modification-hooks` so +editing the heading text removes the stale overlay for re-creation. +Re-scan after edits is debounced via `kitty-gfx--heading-rescan-timer` +(buffer-local, 0.2s) to prevent redundant scans from rapid typing. + +Key design differences from images: + +- Heading overlays **preserve their position cache** in + `on-window-change` (images clear it). This lets the refresh cycle + compare old→new position and erase properly. Images can afford to + clear cache because re-placing with the same PID is idempotent. +- In `on-buffer-change`, heading overlays are **defensively erased** + when their buffer becomes hidden (not visible in any window). This + prevents ghost multicell artifacts if the new buffer's content does + not fully overwrite the heading's terminal cells. +- Overlap detection (`kitty-gfx--heading-occupied-rows`) is reset + **per-window** inside `walk-windows`, not globally. This ensures + headings in one window don't block headings at different terminal + rows in another window showing the same buffer. + +## Known Issues & Planned Work + +- **Fixed**: Overlays remain visible when org headings are collapsed (#1) +- **Limitation**: Does not work inside tmux (neither Kitty passthrough nor Sixel) (#2) +- **Limitation**: Each mode needs explicit `:around` advice integration +- **Limitation**: GIF files are not supported (multi-frame conversion issues, + no animation support in terminal) +- **Limitation**: Sixel is 256 colors (vs truecolor on Kitty), stateless (re-emits on scroll) +- **Done**: LaTeX fragment preview in org-mode (#3) +- **Done**: doc-view / pdf-view-mode integration (#4) +- **Done**: Text sizing protocol for org heading sizes (OSC 66, Kitty >= 0.40.0) +- **Done**: Sixel backend with auto-detection (v0.3.0) +- **WIP**: Sixel image misplacement in org-mode (reported in foot terminal) +- **Planned**: comint-mime integration for shell buffer images (#5) +- **Planned**: Newsticker (RSS reader) integration (#6) + +## Dependencies + +- **Required**: Emacs >= 27.1, a supported terminal (Kitty/WezTerm/Ghostty + for Kitty backend; foot/Konsole/xterm/mlterm/mintty for Sixel) +- **Required for Sixel**: ImageMagick (`magick`/`convert`) for encoding +- **Optional for Kitty**: ImageMagick for non-PNG formats and pixel-accurate sizing +- **No external Elisp deps** beyond built-in `cl-lib` + +## Versioning + +Version is declared in the file header (`Version: X.Y.Z` in +`kitty-graphics.el` line 6). This is the **single source of truth**. + +- **Patch** (0.2.x): bug fixes, minor improvements +- **Minor** (0.x.0): new features, new mode integrations +- **Major** (x.0.0): breaking changes to public API (`kitty-gfx-` symbols) + +Bump the version in the header when preparing a release. diff --git a/PLAN-sixel-expansion.md b/PLAN-sixel-expansion.md new file mode 100644 index 0000000..ae32efc --- /dev/null +++ b/PLAN-sixel-expansion.md @@ -0,0 +1,298 @@ +# PLAN: Sixel Backend Expansion — terminal-graphics.el + +## Meta +- **Date**: 2026-03-20 +- **Mode**: SCOPE EXPANSION +- **Version**: 0.2.1 → 0.3.0 +- **Estimated Effort**: 28-41 hours (~3-5 days) +- **LOC Delta**: +300-400 lines (1948 → ~2300) +- **Risk**: MEDIUM (terminal quirks) +- **Confidence**: HIGH (clean abstraction perimeter) + +--- + +## Motivation + +kitty-graphics.el is the first-ever native Emacs Lisp package for terminal +image display. Currently supports Kitty/WezTerm/Ghostty (~3 terminals). +Sixel support unlocks ~20 additional terminals including foot, Konsole, +xterm, mlterm, mintty, VS Code Terminal, **and tmux**. + +tmux is the killer use case — it's the only way to get images in tmux +from Emacs, since Kitty protocol doesn't work through tmux. + +## Key Architecture Decisions + +### 1. Backend Dispatch: Alist (Decision A) + +A backend is an alist of functions: +```elisp +((prepare . kitty-gfx--kitty-prepare) + (place . kitty-gfx--kitty-place) + (delete . kitty-gfx--kitty-delete) + (cleanup . kitty-gfx--kitty-cleanup)) +``` + +`kitty-gfx--backends` is an alist of `(SYMBOL . BACKEND-ALIST)`. +`kitty-gfx--active-backend` holds the active key ('kitty or 'sixel). +Dispatch: `(funcall (alist-get 'place (alist-get backend kitty-gfx--backends)) ...)` + +**Why**: ~2h more than if/else, but every future protocol (iTerm2, etc.) +is a 5-line alist entry instead of branches everywhere. + +### 2. Sixel Cache: Temp-Files (Decision B) + +Sixel-encoded data is stored in temp-files under `/tmp/kitty-gfx-sixel-*.six`. +Cache lookup: file-exists-p → insert-file-contents → send-string-to-terminal. + +**Why**: Eliminates GC pressure from multi-100KB Emacs strings. Disk I/O +is negligible vs. the ImageMagick shell-out that already happens. +Cache-size can stay at 64 entries. Cleanup via kill-emacs-hook. + +### 3. Refresh Strategy: Dirty-Flag (Decision A) + +Sixel overlays are only re-emitted when `last-row`/`last-col` has changed +OR an explicit dirty flag is set (resize, buffer-switch). Standing images +on a static screen = zero cost. + +**Why**: Minimal-invasive. One bool per overlay. The existing position +tracking provides the dirty signal for free. + +### 4. Encoding: Synchronous with Debounce (Decision B) + +Window resize triggers synchronous re-encode with 500ms debounce for +Sixel backend. User sees 1-2s freeze for 5+ visible images on resize. +Accepted tradeoff — resize is infrequent. + +**Why**: Async encoding (make-process + sentinel) adds race conditions +and ~6h complexity for a rare interaction. Ship first, optimize if +anyone complains. + +--- + +## Protocol Comparison + +``` + KITTY PROTOCOL SIXEL PROTOCOL + Statefulness: STATEFUL (transmit once, STATELESS (emit all data + place many times) every time) + Image Storage: TERMINAL-SIDE CLIENT-SIDE (temp-files) + Placement: NAMED (PID) — atomic replace ANONYMOUS — erase + re-emit + Delete: EXPLICIT (APC a=d) IMPLICIT (overwrite spaces) + Encoding: BASE64 PNG (lossless) 6-bit palette + RLE + Wire Format: APC (\e_G...\e\\) DCS (ESC P q ... ESC \) + Max Colors: Full RGBA (truecolor + alpha) 256 colors (no alpha) +``` + +--- + +## System Architecture — Target State + +``` + ┌─────────────────────────────────────────────────────────────┐ + │ INTEGRATION LAYER │ + │ org-mode │ image-mode │ shr │ doc-view │ dired │ eshell │ + │ (20+ :around advice functions — ALL UNCHANGED) │ + │ ALL call: kitty-gfx-display-image │ + └──────────────────────────┬──────────────────────────────────┘ + │ + ┌──────────────────────────▼──────────────────────────────────┐ + │ ORCHESTRATION LAYER │ + │ kitty-gfx-display-image │ + │ 1. convert-to-png (ImageMagick — shared) │ + │ 2. compute-cell-dims (shared) │ + │ 3. DISPATCH to active backend: │ + │ (funcall (kitty-gfx--backend-fn 'prepare) ...) │ + │ 4. make-overlay (shared) │ + │ 5. schedule-refresh │ + └──────────────────────────┬──────────────────────────────────┘ + │ + ┌──────────────────────────▼──────────────────────────────────┐ + │ BACKEND DISPATCH │ + │ │ + │ kitty-gfx--active-backend ──▶ 'kitty / 'sixel │ + │ │ + │ ┌─────────────────┐ ┌──────────────────────────┐ │ + │ │ KITTY BACKEND │ │ SIXEL BACKEND │ │ + │ │ │ │ │ │ + │ │ prepare: │ │ prepare: │ │ + │ │ transmit APC │ │ png→sixel encode │ │ + │ │ cache: id only │ │ cache: temp-file path │ │ + │ │ │ │ │ │ + │ │ place: │ │ place: │ │ + │ │ APC a=p (50B) │ │ read tmpfile → DCS Pq │ │ + │ │ │ │ (50-500KB) │ │ + │ │ │ │ │ │ + │ │ delete: │ │ delete: │ │ + │ │ APC a=d (30B) │ │ overwrite with spaces │ │ + │ │ │ │ │ │ + │ │ cleanup: │ │ cleanup: │ │ + │ │ delete-by-id │ │ delete temp-files │ │ + │ └─────────────────┘ └──────────────────────────┘ │ + └─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Protocol-Specific Functions — Refactor Map + +Only 6 functions emit terminal-specific escape sequences today. +These become backend dispatch points: + +| Current Function | Backend Method | Kitty | Sixel | +|-------------------------------|---------------|----------------------|---------------------------| +| `kitty-gfx--transmit-image` | `prepare` | APC a=t chunked | magick → sixel tmpfile | +| `kitty-gfx--place-image` | `place` | APC a=p (50 bytes) | read tmpfile → DCS emit | +| `kitty-gfx--delete-placement` | `delete` | APC a=d,d=i | overwrite with spaces | +| `kitty-gfx--delete-by-id` | `cleanup` | APC a=d,d=I | delete temp-file | +| `kitty-gfx--delete-all-images`| `cleanup-all` | APC a=d,d=A | delete all temp-files | +| `kitty-gfx--supported-p` | (detection) | KITTY_PID/TERM check | DA1 query + env check | + +Everything else (~70% of codebase) is protocol-agnostic. + +--- + +## Performance Model + +``` + OPERATION | KITTY | SIXEL | RATIO + ---------------------------|------------------|--------------------|-------- + First display (cold) | ~200ms | ~300ms | 1.5x + Placement (warm cache) | ~0.1ms (50B) | ~5-50ms (50-500KB) | 50-500x + Refresh (no change) | ~0ms (skip) | ~0ms (dirty-flag) | 1x + Refresh (position change) | ~0.1ms | ~10-100ms | 100-1000x + Window resize (5 images) | ~0.5ms | ~1500ms | 3000x + Delete | ~0.1ms | ~1-5ms | 10-50x +``` + +Dirty-flag eliminates steady-state cost. Resize is debounced (500ms). + +--- + +## Error & Rescue Registry + +| Method | Error | Rescued | Action | User Sees | +|---------------------------|-----------------------|---------|-----------------------|---------------------| +| detect-protocol (DA1) | timeout | Y | env-var fallback | Transparent | +| detect-protocol (DA1) | parse-error | Y | env-var fallback | Transparent | +| sixel-encode | missing ImageMagick | Y | user-error | "Install ImageMagick"| +| sixel-encode | process-error | Y | skip image, log | Image missing | +| sixel-encode | empty output | Y | skip image, log | Image missing | +| sixel-place (DCS emit) | io-error | Y | ignore-errors | Image missing | +| sixel-cache-put | disk full | Y | condition-case | Fallback in-memory | +| backend-dispatch | missing backend key | Y | error with message | Clear error msg | +| sixel-delete (erase) | position stale | PARTIAL | sync-begin/end | Brief flicker | + +CRITICAL GAPS: 0 + +--- + +## Failure Modes Registry + +| Codepath | Failure Mode | Rescued? | Test? | User Sees? | Logged? | +|------------------|---------------------|----------|-------|-------------|---------| +| sixel-encode | magick not found | Y | #2 | user-error | Y | +| sixel-encode | corrupt PNG input | Y | — | skip image | Y | +| sixel-encode | huge output (>1MB) | Y | #16 | downscaled | Y | +| sixel-place | terminal overflow | PARTIAL | #6 | slow/hang | Y | +| sixel-cache | disk full | Y | — | fallback | Y | +| detect-protocol | DA1 timeout | Y | #5 | fallback | Y | +| detect-protocol | terminal lies | N | — | wrong proto | N | +| refresh (sixel) | rapid scroll 5 imgs | Y (flag) | #6 | skip re-emit| Y | +| resize (sixel) | 5 imgs re-encode | Y (dbnc) | #7 | 1-2s freeze | Y | + +--- + +## Rollout Phases + +### Phase 1: Backend Refactor (in-place) +- Introduce alist dispatch +- Extract existing Kitty code into 'kitty backend +- NO new functionality, only restructuring +- Byte-compile + load-test +- Commit: "refactor: extract protocol backend dispatch" + +### Phase 2: Sixel Backend +- kitty-gfx--sixel-encode, -place, -delete, -cleanup +- Temp-file cache system +- Dirty-flag in refresh cycle +- Commit: "feat: add Sixel graphics protocol backend" + +### Phase 3: Auto-Detection +- DA1 query + env-var fallback chain +- kitty-gfx-preferred-protocol defcustom ('auto / 'kitty / 'sixel) +- Commit: "feat: auto-detect terminal graphics protocol" + +### Phase 4: Polish +- Repo rename: kitty-graphics → terminal-graphics.el +- README update +- Version bump: 0.3.0 +- MELPA recipe submission + +--- + +## Test Matrix + +| # | What | Type | Spec | +|---|-------------------------------|--------|-----------------------------------------| +| 1 | Sixel encode happy path | Manual | PNG in org-buffer, foot terminal | +| 2 | Sixel encode failure | Manual | Remove ImageMagick, attempt display | +| 3 | Backend auto-detect Kitty | Manual | In Kitty: mode on → backend = 'kitty | +| 4 | Backend auto-detect Sixel | Manual | In foot: mode on → backend = 'sixel | +| 5 | Fallback when DA1 fails | Manual | Set TERM_PROGRAM manually, block stdin | +| 6 | Scroll performance (5 images) | Manual | Org file with 5 images, rapid scroll | +| 7 | Window resize | Manual | Display image, C-x 3 split → re-encode | +| 8 | Cache hit | Manual | Toggle images off/on → faster 2nd time | +| 9 | Cache cleanup on kill-emacs | Manual | Exit Emacs, check /tmp → files gone | +| 10| tmux + Sixel | Manual | tmux (--enable-sixel) + foot | +| 11| Kitty regression | Manual | Kitty: +/-/0 zoom still works | +| 12| Sixel image-mode zoom | Manual | foot: +/-/0 zoom → re-encodes | +| 13| doc-view in Sixel | Manual | foot: open PDF → pages render | +| 14| shr/eww in Sixel | Manual | foot: eww with images | +| 15| defcustom override | Manual | Force 'sixel in Kitty → uses Sixel | +| 16| Large image DoS protection | Manual | 4K image → capped, no terminal hang | + +--- + +## NOT in Scope + +| Item | Rationale | +|------------------------------|--------------------------------------------------| +| iTerm2 Protocol Backend | Trivial after Sixel (~2h), separate PR | +| tmux Kitty Passthrough | Separate feature, separate PR | +| Async Sixel Encoding | Only if users complain about resize freeze | +| OSC 66 Text Sizing Ship | Already designed in AGENTS.md, separate feature | +| GIF/Animation Support | Out of scope for this iteration | +| In-Emacs Sixel Encoder | Would eliminate ImageMagick dep, ~40h, not now | +| comint-mime Integration | Issue #5, separate feature | + +--- + +## Delight Opportunities (post-ship) + +1. **tmux Kitty passthrough** — DCS wrapper, ~1h after backend abstraction +2. **Auto-detect with modeline indicator** — `[K]` or `[S]` in lighter +3. **kitty-gfx-preferred-protocol defcustom** — user override for power users +4. **Sixel color palette size defcustom** — 256 vs 1024, speed vs quality +5. **iTerm2 backend** — ~2h, covers macOS iTerm2 users + +--- + +## Naming Strategy + +- Emacs symbol prefix: keep `kitty-gfx-` (breaking change otherwise) +- Repository name: rename `kitty-graphics` → `terminal-graphics.el` +- GitHub rename preserves all stars, forks, issues, redirects old URLs +- MELPA package name: `terminal-graphics` (or keep `kitty-graphics`) +- Explain in Commentary section why prefix differs from repo name + +--- + +## GitHub Rename Notes + +- Stars, forks, issues ALL preserved on rename +- Old URLs auto-redirect to new name +- git clone/fetch/push from old URL continues to work +- CAVEAT: "do not reuse the original name" — if you create a NEW repo + called `kitty-graphics` after renaming, redirects break +- Recommendation: simple rename, no new-repo trick needed @@ -1,7 +1,7 @@ #+TITLE: kitty-graphics.el #+AUTHOR: cashmere -Display images in terminal Emacs (~emacs -nw~) using the [[https://sw.kovidgoyal.net/kitty/graphics-protocol/][Kitty graphics protocol]]. +Display images in terminal Emacs (~emacs -nw~) using the [[https://sw.kovidgoyal.net/kitty/graphics-protocol/][Kitty graphics protocol]] or [[https://en.wikipedia.org/wiki/Sixel][Sixel]]. * Demo @@ -17,11 +17,16 @@ Besides, I don't even have the guts to release it, even though my intention of s * Overview -~kitty-graphics.el~ renders images directly in terminal Emacs using Kitty's -graphics protocol with direct placements. Images are transmitted once to the -terminal, then positioned at overlay screen coordinates after each redisplay. -They scroll with text, survive buffer switches, and work in split windows --- -they're managed as overlays in Emacs's display engine. +~kitty-graphics.el~ renders images directly in terminal Emacs using either the +Kitty graphics protocol or Sixel, selected automatically at startup. The backend +dispatch tries Kitty first (fast ~KITTY_PID~ env check), then probes for Sixel +support, so the package works across ~20+ terminals without configuration. + +With the Kitty backend, images are transmitted once and positioned via direct +placements at overlay screen coordinates after each redisplay. The Sixel backend +encodes images via ImageMagick and re-emits them on scroll. Both backends +integrate with Emacs's overlay display engine --- images scroll with text, +survive buffer switches, and work in split windows. Integrations are provided for: - *org-mode* --- inline images via =C-c C-x C-v= (both =org-toggle-inline-images= and =org-link-preview= for org 10.0+) @@ -35,8 +40,10 @@ Integrations are provided for: * Requirements - Emacs >= 27.1 -- [[https://sw.kovidgoyal.net/kitty/][Kitty]] >= 0.20.0 (or WezTerm/Ghostty with direct placement support) -- ImageMagick (~magick~ / ~convert~ / ~identify~) for non-PNG formats and image sizing +- A supported terminal: + - *Kitty backend*: [[https://sw.kovidgoyal.net/kitty/][Kitty]] >= 0.20.0, WezTerm, or Ghostty + - *Sixel backend*: foot, Konsole, xterm (with ~+sixel~), mlterm, mintty, VS Code Terminal, and others with Sixel support +- ImageMagick (~magick~ / ~convert~ / ~identify~) --- required for Sixel, and for non-PNG formats on Kitty - For LaTeX preview: a TeX distribution with ~dvipng~ (e.g. ~texlive~) - For doc-view: ~ghostscript~ (for PDF), ~dvipng~ (for DVI) --- same tools as GUI Emacs - Launch Emacs with ~TERM=xterm-256color~ (Emacs often can't find the ~xterm-kitty~ terminfo) @@ -52,7 +59,7 @@ Integrations are provided for: #+begin_src emacs-lisp (use-package kitty-graphics :straight (:local-repo "~/projects/kitty-graphics") - :if (and (not (display-graphic-p)) (getenv "KITTY_PID")) + :if (not (display-graphic-p)) :config (kitty-graphics-mode 1)) #+end_src @@ -62,7 +69,7 @@ Integrations are provided for: #+begin_src emacs-lisp (add-to-list 'load-path "~/projects/kitty-graphics") (require 'kitty-graphics) -(when (and (not (display-graphic-p)) (getenv "KITTY_PID")) +(when (not (display-graphic-p)) (kitty-graphics-mode 1)) #+end_src @@ -95,28 +102,33 @@ Then: ** Customization -| Variable | Default | Description | -|------------------------+---------+------------------------------------------| -| ~kitty-gfx-max-width~ | 120 | Maximum inline image width in columns | -| ~kitty-gfx-max-height~ | 40 | Maximum inline image height in rows | -| ~kitty-gfx-render-delay~ | 0.016 | Debounce delay for re-rendering (s) | -| ~kitty-gfx-debug~ | nil | Log debug info to ~/tmp/kitty-gfx.log~ | +| Variable | Default | Description | +|--------------------------------+---------+------------------------------------------| +| ~kitty-gfx-preferred-protocol~ | auto | Graphics protocol: auto, kitty, or sixel | +| ~kitty-gfx-max-width~ | 120 | Maximum inline image width in columns | +| ~kitty-gfx-max-height~ | 40 | Maximum inline image height in rows | +| ~kitty-gfx-render-delay~ | 0.016 | Debounce delay for re-rendering (s) | +| ~kitty-gfx-debug~ | nil | Log debug info to ~/tmp/kitty-gfx.log~ | These defaults control inline image sizing (org-mode, eww, dired previews). Doc-view ignores these and fills the window, with =+= / =-= / =0= for zoom. * How It Works -1. On startup, cell pixel size is queried via ~CSI 16 t~ (XTWINOPS) for - accurate image scaling. Falls back to 8x16 if the query times out. -2. Image data is transmitted once via APC escape sequences (~a=t~, store only) -3. Overlays with a ~display~ property reserve blank space in Emacs buffers -4. After each redisplay, direct placements (~a=p~) are emitted via - ~send-string-to-terminal~ at the overlay's screen coordinates -5. All placement output is wrapped in synchronized output (~BSU/ESU~, - =DEC mode 2026=) to prevent partial rendering and flicker -6. Each placement uses a unique placement ID (~p=PID~) so re-placements - at new positions replace the old rendering without accumulation +1. On startup, backend dispatch selects a protocol: if ~kitty-gfx-preferred-protocol~ + is ~auto~, it checks for Kitty (via ~KITTY_PID~), then probes Sixel via a DA1 + query. The mode-line shows ~[K]~, ~[S]~, or ~[?]~ to indicate the active backend. +2. Cell pixel size is queried via ~CSI 16 t~ (XTWINOPS) for accurate image + scaling. Falls back to 8x16 if the query times out. +3. *Kitty path*: image data is transmitted once via APC escape sequences (~a=t~, + store only). Direct placements (~a=p~) position the image at overlay screen + coordinates after each redisplay, using unique placement IDs (~p=PID~). +4. *Sixel path*: images are encoded to Sixel via ImageMagick and emitted + directly at overlay screen coordinates. Since Sixel is stateless, images + are re-emitted whenever they scroll to a new position. +5. Overlays with a ~display~ property reserve blank space in Emacs buffers +6. All output is wrapped in synchronized output (~BSU/ESU~, =DEC mode 2026=) + to prevent partial rendering and flicker 7. Position caching skips redundant re-placements when nothing moved 8. Placements are deleted when overlays scroll out of view @@ -127,10 +139,10 @@ TIFF, BMP) are converted to PNG via ImageMagick before transmission. * Known Limitations -- No tmux support -- Primary focus on kitty terminal. While other terminals should work too, I wont give any guarantee +- No tmux support (neither Kitty passthrough nor Sixel) - Animated GIFs display only the first frame -- Without ImageMagick, only PNG files work and image sizing falls back to a fixed default +- Sixel is limited to 256 colors (vs truecolor on Kitty) and is stateless --- images are re-emitted on every scroll, which may be slower on large images +- Without ImageMagick, only PNG files work on the Kitty backend; the Sixel backend requires ImageMagick unconditionally - Doc-view page may briefly flash at wrong position before centering on initial load * License diff --git a/kitty-graphics.el b/kitty-graphics.el index 098de48..84f5cce 100644 --- a/kitty-graphics.el +++ b/kitty-graphics.el @@ -3,7 +3,7 @@ ;; Copyright (C) 2025-2026 ;; ;; Author: cashmere -;; Version: 0.2.1 +;; Version: 0.3.0 ;; URL: https://github.com/cashmeredev/kitty-graphics.el ;; Keywords: terminals, images, multimedia ;; Package-Requires: ((emacs "27.1")) @@ -104,11 +104,21 @@ This debounces rapid redisplay events. Default is ~1 frame at 60fps." "File path for debug log output.") (defun kitty-gfx--log (fmt &rest args) - "Log to `kitty-gfx--log-file' when `kitty-gfx-debug' is non-nil." + "Log to `kitty-gfx--log-file' and *kitty-gfx-debug* buffer when debug is on." (when kitty-gfx-debug (let ((msg (concat (format-time-string "%H:%M:%S.%3N ") (apply #'format fmt args) "\n"))) - (ignore-errors (append-to-file msg nil kitty-gfx--log-file))))) + (ignore-errors (append-to-file msg nil kitty-gfx--log-file)) + (ignore-errors + (let ((buf (get-buffer-create "*kitty-gfx-debug*"))) + (with-current-buffer buf + (goto-char (point-max)) + (insert msg) + ;; Trim buffer at 2500 lines + (when (> (line-number-at-pos (point-max)) 2500) + (goto-char (point-min)) + (forward-line 500) + (delete-region (point-min) (point))))))))) ;;;; Constants — kept for reference if switching back to Unicode placeholders ;; (defconst kitty-gfx--placeholder-char #x10EEEE) @@ -119,6 +129,20 @@ This debounces rapid redisplay events. Default is ~1 frame at 60fps." ;; Forward declaration — defined by `define-minor-mode' below. (defvar kitty-graphics-mode) +(defvar kitty-gfx--active-backend nil + "Symbol identifying the active graphics backend: `kitty' or `sixel'. +Set by `kitty-gfx--detect-protocol'.") + +(defvar kitty-gfx--backends nil + "Alist mapping backend symbols to operation alists. +Each backend alist maps operation symbols to functions: + `detect' — () -> bool: return non-nil if backend is supported + `prepare' — (file image-id) -> id-or-nil: prepare/transmit image + `place' — (ov id pid cols rows term-row term-col): place image + `delete' — (ov id pid): delete placement + `cleanup' — (file id): cleanup resources for file + `cleanup-all' — (): cleanup all resources.") + (defvar kitty-gfx--next-id 1 "Next image ID to assign (1-4294967295). With direct placements, any uint32 ID works — no 256-color constraint.") @@ -130,6 +154,15 @@ terminal data deleted via `a=d'." :type 'integer :group 'kitty-graphics) +(defcustom kitty-gfx-preferred-protocol 'auto + "Preferred graphics protocol to use. +Choices: `auto' (try Kitty first, then Sixel), `kitty', or `sixel'. +Default is `auto'." + :type '(choice (const :tag "Auto-detect (Kitty → Sixel)" auto) + (const :tag "Kitty graphics protocol" kitty) + (const :tag "Sixel protocol" sixel)) + :group 'kitty-graphics) + (defvar kitty-gfx--image-cache (make-hash-table :test 'equal) "Maps file paths to image IDs (integers). Only stores the terminal-side image ID — display dimensions are @@ -161,17 +194,52 @@ rather than accumulate.") ;;;; Terminal detection -(defun kitty-gfx--supported-p () - "Return non-nil if the terminal supports Kitty graphics." - (let ((supported (and (not (display-graphic-p)) - (or (getenv "KITTY_PID") - (equal (getenv "TERM_PROGRAM") "kitty") - (equal (getenv "TERM_PROGRAM") "WezTerm") - (equal (getenv "TERM_PROGRAM") "ghostty"))))) - (kitty-gfx--log "supported-p: %s (graphic=%s KITTY_PID=%s TERM_PROGRAM=%s)" - supported (display-graphic-p) - (getenv "KITTY_PID") (getenv "TERM_PROGRAM")) - supported)) +(defun kitty-gfx--backend-fn (op) + "Return the backend function for operation OP. +Looks up OP in the active backend's operation alist. +Signals an error if no backend is active or OP is missing." + (unless kitty-gfx--active-backend + (error "No active graphics backend")) + (let* ((backend-alist (alist-get kitty-gfx--active-backend kitty-gfx--backends)) + (fn (alist-get op backend-alist))) + (unless fn + (error "Backend %s does not implement operation %s" + kitty-gfx--active-backend op)) + fn)) + +(defun kitty-gfx--detect-protocol () + "Detect and activate a graphics protocol backend. +Returns non-nil if a supported backend is found. +Sets `kitty-gfx--active-backend' to the detected backend symbol." + (if (display-graphic-p) + (progn + (kitty-gfx--log "detect-protocol: GUI frame, no terminal graphics") + (setq kitty-gfx--active-backend nil) + nil) + (let ((pref kitty-gfx-preferred-protocol) + (detected nil)) + (kitty-gfx--log "detect-protocol: preference=%s" pref) + (cond + ;; Explicit backend preference + ((eq pref 'kitty) + (let ((fn (alist-get 'detect (alist-get 'kitty kitty-gfx--backends)))) + (when (and fn (funcall fn)) + (setq detected 'kitty)))) + ((eq pref 'sixel) + (let ((fn (alist-get 'detect (alist-get 'sixel kitty-gfx--backends)))) + (when (and fn (funcall fn)) + (setq detected 'sixel)))) + ;; Auto: try Kitty first (fast env check), then Sixel + (t + (let ((kitty-fn (alist-get 'detect (alist-get 'kitty kitty-gfx--backends)))) + (if (and kitty-fn (funcall kitty-fn)) + (setq detected 'kitty) + (let ((sixel-fn (alist-get 'detect (alist-get 'sixel kitty-gfx--backends)))) + (when (and sixel-fn (funcall sixel-fn)) + (setq detected 'sixel))))))) + (setq kitty-gfx--active-backend detected) + (kitty-gfx--log "detect-protocol: result=%s" detected) + detected))) (defun kitty-gfx--query-cell-size () "Query terminal for cell size in pixels using CSI 16 t (XTWINOPS). @@ -290,6 +358,218 @@ Uses direct placement: move cursor, then `a=p' with `c' and `r' params." (format "\e7\e[%d;%dH\e_Gq=2,a=p,i=%d,p=%d,c=%d,r=%d\e\\\e8" term-row term-col image-id placement-id cols rows)))) +;;;; Kitty backend + +(defun kitty-gfx--kitty-detect () + "Return non-nil if the terminal supports Kitty graphics protocol." + (let ((supported (or (getenv "KITTY_PID") + (equal (getenv "TERM_PROGRAM") "kitty") + (equal (getenv "TERM_PROGRAM") "WezTerm") + (equal (getenv "TERM_PROGRAM") "ghostty")))) + (kitty-gfx--log "kitty-detect: %s (KITTY_PID=%s TERM_PROGRAM=%s)" + supported (getenv "KITTY_PID") (getenv "TERM_PROGRAM")) + supported)) + +(defun kitty-gfx--kitty-prepare (file image-id) + "Prepare image FILE for Kitty display. +Converts to PNG if needed, encodes to base64, transmits to terminal. +Returns IMAGE-ID on success, nil on failure." + (let* ((png (kitty-gfx--convert-to-png file)) + (temp-p (and png (not (string= png file))))) + (unwind-protect + (let ((b64 (when png (kitty-gfx--read-file-base64 png)))) + (if (not b64) + (progn + (kitty-gfx--log "kitty-prepare: skipped %s (conversion failed)" file) + nil) + (kitty-gfx--log "kitty-prepare: transmit id=%d b64-len=%d png=%s" + image-id (length b64) png) + (kitty-gfx--transmit-image image-id b64) + image-id)) + (when temp-p + (ignore-errors (delete-file png)))))) + +(defun kitty-gfx--kitty-place (_ov image-id placement-id cols rows term-row term-col) + "Place Kitty image at terminal position. +OV is the overlay (unused by Kitty backend but part of the interface)." + (kitty-gfx--place-image image-id placement-id cols rows term-row term-col)) + +(defun kitty-gfx--kitty-delete (_ov image-id placement-id) + "Delete Kitty placement PID of image ID." + (kitty-gfx--delete-placement image-id placement-id)) + +(defun kitty-gfx--kitty-cleanup (_file image-id) + "Cleanup Kitty image data for FILE (identified by IMAGE-ID)." + (kitty-gfx--delete-by-id image-id)) + +(defun kitty-gfx--kitty-cleanup-all () + "Cleanup all Kitty images." + (kitty-gfx--delete-all-images)) + +;;;; Sixel backend + +(defvar kitty-gfx--sixel-temp-files nil + "List of temporary Sixel files created for caching.") + +(defvar kitty-gfx--sixel-cache (make-hash-table :test 'equal) + "Maps (file . dims-string) to temp sixel file paths.") + +(defun kitty-gfx--sixel-detect () + "Return non-nil if the terminal likely supports Sixel protocol." + (let* ((term (getenv "TERM")) + (term-prog (getenv "TERM_PROGRAM")) + (in-tmux (getenv "TMUX")) + (supported (and term + (not in-tmux) ; Sixel doesn't work in tmux + (or (string-match-p "xterm\\|vt[0-9]" term) + (member term-prog '("foot" "Konsole" "mintty" "mlterm"))) + t))) + (kitty-gfx--log "sixel-detect: %s (TERM=%s TERM_PROGRAM=%s TMUX=%s)" + supported term term-prog (if in-tmux "yes" "no")) + supported)) + +(defun kitty-gfx--sixel-cache-path (file cols rows) + "Return deterministic temp file path for FILE at COLS x ROWS." + (let* ((key (format "%s:%dx%d" file cols rows)) + (hash (md5 key))) + (expand-file-name (concat "kitty-gfx-sixel-" hash ".six") temporary-file-directory))) + +(defun kitty-gfx--sixel-encode (png-file cols rows) + "Encode PNG-FILE as Sixel data for COLS x ROWS cells. +Returns Sixel data string or nil on failure. +Computes pixel dimensions from cell size." + (let* ((cw (or kitty-gfx--cell-pixel-width 8)) + (ch (or kitty-gfx--cell-pixel-height 16)) + (pixel-w (* cols cw)) + (pixel-h (* rows ch)) + (convert (or (executable-find "magick") (executable-find "convert")))) + (if (not convert) + (progn + (kitty-gfx--log "sixel-encode: no ImageMagick, cannot encode") + (message "kitty-gfx: Sixel backend requires ImageMagick") + nil) + (when (string-suffix-p "convert" convert) + (kitty-gfx--log "sixel-encode: WARNING deprecated `convert' binary resolved: %s (use `magick' instead)" convert)) + (kitty-gfx--log "sixel-encode: %s -> %dx%d pixels via %s" + png-file pixel-w pixel-h convert) + (with-temp-buffer + (set-buffer-multibyte nil) + (let* ((args (list convert nil '(t nil) nil + png-file "-geometry" (format "%dx%d" pixel-w pixel-h) + "sixel:-")) + (exit-code (apply #'call-process args))) + (if (zerop exit-code) + (let ((data (buffer-string))) + (kitty-gfx--log "sixel-encode: success (%d bytes)" (length data)) + data) + (kitty-gfx--log "sixel-encode: FAILED exit=%d" exit-code) + nil)))))) + +(defun kitty-gfx--sixel-prepare (file _image-id) + "Prepare FILE for Sixel display. +For Sixel, preparation just validates the file exists and is convertible. +Actual encoding happens at place-time (needs dimensions). +Returns non-nil on success." + (let ((png (kitty-gfx--convert-to-png file))) + (when png + (kitty-gfx--log "sixel-prepare: %s -> %s" file png) + ;; Cache the PNG path for later encoding + (puthash file png kitty-gfx--sixel-cache) + t))) + +(defun kitty-gfx--sixel-place (ov _image-id _placement-id cols rows term-row term-col) + "Place Sixel image at terminal position. +Encodes on-demand if not cached, then emits DCS sequence." + (let* ((file (overlay-get ov 'kitty-gfx-file)) + (png (gethash file kitty-gfx--sixel-cache)) + (cache-path (kitty-gfx--sixel-cache-path file cols rows)) + (sixel-data nil)) + (if (not png) + (kitty-gfx--log "sixel-place: no PNG cached for %s" file) + ;; Check if sixel encoding is cached + (if (file-exists-p cache-path) + (progn + (kitty-gfx--log "sixel-place: using cached sixel %s" cache-path) + (with-temp-buffer + (set-buffer-multibyte nil) + (insert-file-contents-literally cache-path) + (setq sixel-data (buffer-string)))) + ;; Encode on-demand + (kitty-gfx--log "sixel-place: encoding %s at %dx%d" png cols rows) + (setq sixel-data (kitty-gfx--sixel-encode png cols rows)) + (when sixel-data + ;; Cache the encoding + (ignore-errors + (with-temp-file cache-path + (set-buffer-multibyte nil) + (insert sixel-data))) + (push cache-path kitty-gfx--sixel-temp-files))) + ;; Emit Sixel sequence if we have data + (when sixel-data + (let* ((cw (or kitty-gfx--cell-pixel-width 8)) + (ch (or kitty-gfx--cell-pixel-height 16))) + (kitty-gfx--log "sixel-place: emitting at row=%d col=%d data-len=%d pixel-target=%dx%d" + term-row term-col (length sixel-data) (* cols cw) (* rows ch))) + (ignore-errors + (send-string-to-terminal + (format "\e7\e[%d;%dH%s\e8" term-row term-col sixel-data))))))) + +(defun kitty-gfx--sixel-delete (ov _image-id _placement-id) + "Delete Sixel placement by overwriting with spaces. +Sixel has no placement IDs — erase by writing spaces over the region." + (let ((last-row (overlay-get ov 'kitty-gfx-last-row)) + (last-col (overlay-get ov 'kitty-gfx-last-col)) + (rows (overlay-get ov 'kitty-gfx-rows)) + (cols (overlay-get ov 'kitty-gfx-cols))) + (when (and last-row last-col rows cols) + (kitty-gfx--log "sixel-delete: erase row=%d col=%d %dx%d" + last-row last-col cols rows) + (ignore-errors + (send-string-to-terminal + (format "\e7%s\e8" + (mapconcat + (lambda (r) + (format "\e[%d;%dH%s" (+ last-row r) last-col (make-string cols ?\s))) + (number-sequence 0 (1- rows)) + ""))))))) + +(defun kitty-gfx--sixel-cleanup (file _image-id) + "Cleanup Sixel resources for FILE." + (remhash file kitty-gfx--sixel-cache) + ;; Remove cached sixel encodings for this file + (dolist (temp-file kitty-gfx--sixel-temp-files) + (when (string-match-p (regexp-quote (md5 file)) temp-file) + (kitty-gfx--log "sixel-cleanup: deleting %s" temp-file) + (ignore-errors (delete-file temp-file)) + (setq kitty-gfx--sixel-temp-files (delete temp-file kitty-gfx--sixel-temp-files))))) + +(defun kitty-gfx--sixel-cleanup-all () + "Cleanup all Sixel resources." + (kitty-gfx--log "sixel-cleanup-all: deleting %d temp files" + (length kitty-gfx--sixel-temp-files)) + (dolist (temp-file kitty-gfx--sixel-temp-files) + (ignore-errors (delete-file temp-file))) + (setq kitty-gfx--sixel-temp-files nil) + (clrhash kitty-gfx--sixel-cache)) + +;; Register backends +(setq kitty-gfx--backends + `((kitty . ((detect . ,#'kitty-gfx--kitty-detect) + (prepare . ,#'kitty-gfx--kitty-prepare) + (place . ,#'kitty-gfx--kitty-place) + (delete . ,#'kitty-gfx--kitty-delete) + (cleanup . ,#'kitty-gfx--kitty-cleanup) + (cleanup-all . ,#'kitty-gfx--kitty-cleanup-all))) + (sixel . ((detect . ,#'kitty-gfx--sixel-detect) + (prepare . ,#'kitty-gfx--sixel-prepare) + (place . ,#'kitty-gfx--sixel-place) + (delete . ,#'kitty-gfx--sixel-delete) + (cleanup . ,#'kitty-gfx--sixel-cleanup) + (cleanup-all . ,#'kitty-gfx--sixel-cleanup-all))))) + +;; Cleanup temp files on exit +(add-hook 'kill-emacs-hook #'kitty-gfx--sixel-cleanup-all) + ;;;; Position mapping (defun kitty-gfx--in-folded-region-p (pos) @@ -334,31 +614,23 @@ the window's scroll range." ;; (outline, org-fold, etc.) but allow cosmetic invisibility ;; like org-link bracket hiding. (not (kitty-gfx--in-folded-region-p pos))) - (let* ((edges (window-edges win)) - (win-top (nth 1 edges)) - (win-left (nth 0 edges)) + ;; posn-col-row returns coordinates relative to the window BODY + ;; (text area). Use window-body-edges to convert to frame coords. + ;; body-left accounts for margins/fringes; body-top accounts for + ;; header-line. +1 converts 0-based frame coords to 1-based terminal. + (let* ((body (window-body-edges win)) + (body-left (nth 0 body)) + (body-top (nth 1 body)) (win-pos (posn-at-point pos win))) (when win-pos (let* ((col-row (posn-col-row win-pos)) (row (cdr col-row)) (posn-col (car col-row)) - (body-left (nth 0 (window-body-edges win))) - (buf-col (with-current-buffer buf - (save-excursion - (goto-char pos) - (current-column)))) - ;; posn-col-row is correct for most overlays (includes - ;; line numbers, margins). But overlays with display - ;; properties wider than the underlying text inflate - ;; posn-col. Detect this: if posn-col minus buf-col - ;; exceeds a reasonable gutter width, it's inflated — - ;; fall back to body-left + buf-col. - (col (if (> (- posn-col buf-col) - (- body-left win-left)) - (+ body-left buf-col) - (+ win-left posn-col)))) + (posn-xy (posn-x-y win-pos))) + (kitty-gfx--log "screen-pos-detail: pid=%s posn-col=%d posn-row=%d posn-xy=%S body-left=%d body-top=%d" + (overlay-get ov 'kitty-gfx-pid) posn-col row posn-xy body-left body-top) (when col-row - (let ((result (cons (+ win-top row 1) (1+ col)))) + (let ((result (cons (+ body-top row 1) (+ body-left posn-col 1)))) (kitty-gfx--log "screen-pos: pid=%s pos=%d -> row=%d col=%d" (overlay-get ov 'kitty-gfx-pid) pos (car result) (cdr result)) @@ -375,6 +647,10 @@ Deletes placements for overlays that scrolled out of view. All terminal output is wrapped in synchronized output (BSU/ESU) to prevent flicker." (when (and kitty-graphics-mode (not (display-graphic-p))) + ;; Force redisplay so posn-at-point sees up-to-date pixel positions + ;; after display property changes (e.g., org-toggle-inline-images + ;; creating multi-line blank overlays). + (redisplay t) ;; Re-query cell size if invalidated (e.g., after terminal resize) (unless (and kitty-gfx--cell-pixel-width kitty-gfx--cell-pixel-height) (kitty-gfx--query-cell-size)) @@ -443,16 +719,15 @@ if the overlay scrolled out of view." new-row new-col) (overlay-put ov 'kitty-gfx-last-row new-row) (overlay-put ov 'kitty-gfx-last-col new-col) - (kitty-gfx--place-image id pid - (overlay-get ov 'kitty-gfx-cols) - rows new-row new-col))) + (funcall (kitty-gfx--backend-fn 'place) + ov id pid (overlay-get ov 'kitty-gfx-cols) rows new-row new-col))) ;; Not visible or overflows — delete if was placed (when last-row (kitty-gfx--log "refresh-ov: pid=%d hiding (was row=%d col=%d)" pid last-row last-col) (overlay-put ov 'kitty-gfx-last-row nil) (overlay-put ov 'kitty-gfx-last-col nil) - (kitty-gfx--delete-placement id pid)))))) + (funcall (kitty-gfx--backend-fn 'delete) ov id pid)))))) (defvar kitty-gfx--refresh-pending nil "Non-nil if a refresh was requested during the cooldown period.") @@ -494,6 +769,8 @@ then invalidates position caches and schedules a refresh." (let ((visible-bufs nil)) (walk-windows (lambda (w) (push (window-buffer w) visible-bufs)) nil 'visible) + (kitty-gfx--log "on-buffer-change: visible-bufs=(%s)" + (mapconcat #'buffer-name visible-bufs ", ")) ;; Delete placements for buffers that are no longer in any window (dolist (buf (buffer-list)) (with-current-buffer buf @@ -505,8 +782,10 @@ then invalidates position caches and schedules a refresh." (when (overlay-buffer ov) (let ((id (overlay-get ov 'kitty-gfx-id)) (pid (overlay-get ov 'kitty-gfx-pid))) - (when (and id pid (overlay-get ov 'kitty-gfx-last-row)) - (kitty-gfx--delete-placement id pid))) + (if (overlay-get ov 'kitty-gfx-last-row) + (when (and id pid) + (kitty-gfx--delete-placement id pid)) + (kitty-gfx--log "on-buffer-change: ov pid=%s has no last-row (never placed or already cleaned)" pid))) (overlay-put ov 'kitty-gfx-last-row nil) (overlay-put ov 'kitty-gfx-last-col nil))))))) ;; Reset cache for visible buffers so they re-place correctly. @@ -576,14 +855,16 @@ buffer before settling to one)." (defun kitty-gfx--image-pixel-size (file) "Return (WIDTH . HEIGHT) in pixels for image FILE, or nil." - (let ((identify (or (executable-find "identify") - (executable-find "magick")))) + (let ((identify (or (executable-find "magick") + (executable-find "identify")))) (when identify + (when (string-suffix-p "identify" identify) + (kitty-gfx--log "image-pixel-size: WARNING deprecated `identify' binary resolved: %s (use `magick' instead)" identify)) (with-temp-buffer (let ((args (if (string-suffix-p "magick" identify) - (list identify nil t nil "identify" "-format" "%w %h" + (list identify nil '(t nil) nil "identify" "-format" "%w %h" (concat file "[0]")) ; first frame only - (list identify nil t nil "-format" "%w %h" + (list identify nil '(t nil) nil "-format" "%w %h" (concat file "[0]"))))) (let ((exit-code (apply #'call-process args))) (kitty-gfx--log "identify: exit=%d output=%S" exit-code (buffer-string)) @@ -615,9 +896,7 @@ conversion fails — callers must handle nil gracefully." (let ((out (make-temp-file "kitty-gfx-" nil ".png"))) (kitty-gfx--log "convert-to-png: %s -> %s via %s" file out convert) (let ((exit-code - (if (string-suffix-p "magick" convert) - (call-process convert nil nil nil "convert" file out) - (call-process convert nil nil nil file out)))) + (call-process convert nil nil nil file out))) (kitty-gfx--log "convert-to-png: exit-code=%s" exit-code) ;; Check that conversion produced a non-empty file (if (and (file-exists-p out) @@ -677,8 +956,8 @@ With direct placements, COLS and ROWS map directly to terminal columns/rows." kitty-gfx--cache-lru) (let* ((victim (car (last kitty-gfx--cache-lru))) (victim-id (gethash victim kitty-gfx--image-cache))) - (when victim-id - (kitty-gfx--delete-by-id victim-id)) + (when (and victim-id kitty-gfx--active-backend) + (funcall (kitty-gfx--backend-fn 'cleanup) victim victim-id)) (remhash victim kitty-gfx--image-cache) (setq kitty-gfx--cache-lru (butlast kitty-gfx--cache-lru)) (kitty-gfx--log "cache-evict: %s id=%s (remaining=%d)" @@ -709,8 +988,9 @@ underline/color from bleeding through the overlay." (mapconcat (lambda (_) (propertize (make-string cols ?\s) 'face 'default)) (number-sequence 1 rows) "\n")) -(defun kitty-gfx--make-overlay (beg end image-id cols rows &optional reuse-pid) +(defun kitty-gfx--make-overlay (beg end image-id cols rows file &optional reuse-pid) "Create overlay from BEG to END for image IMAGE-ID (COLS x ROWS). +FILE is the source file path (needed by some backends for re-encoding). The overlay's display property shows blank space that the terminal fills with the image via direct placement. When REUSE-PID is non-nil, reuse that placement ID instead of @@ -727,6 +1007,7 @@ delete step, avoiding visual glitches in some terminals." (overlay-put ov 'kitty-gfx-pid pid) (overlay-put ov 'kitty-gfx-cols cols) (overlay-put ov 'kitty-gfx-rows rows) + (overlay-put ov 'kitty-gfx-file file) ;; Don't set evaporate — zero-width overlays (beg==end) would be ;; deleted immediately if evaporate is set. (push ov kitty-gfx--overlays) @@ -756,8 +1037,8 @@ visual glitches from delete+re-place sequences in some terminals)." (when (overlay-buffer ov) (unless keep-placement (condition-case err - (when (and id pid) - (kitty-gfx--delete-placement id pid)) + (when (and id pid kitty-gfx--active-backend) + (funcall (kitty-gfx--backend-fn 'delete) ov id pid)) (error (kitty-gfx--log "remove-overlay: error deleting placement: %s" (error-message-string err))))) @@ -772,8 +1053,8 @@ visual glitches from delete+re-place sequences in some terminals)." "Display image FILE in the current buffer. BEG/END span the overlay region. MAX-COLS/MAX-ROWS limit size." (interactive "fImage file: ") - (unless (kitty-gfx--supported-p) - (user-error "Terminal does not support Kitty graphics")) + (unless kitty-gfx--active-backend + (user-error "Terminal does not support graphics")) (let* ((max-c (or max-cols kitty-gfx-max-width)) (max-r (or max-rows kitty-gfx-max-height)) (abs-file (expand-file-name file)) @@ -792,22 +1073,13 @@ BEG/END span the overlay region. MAX-COLS/MAX-ROWS limit size." (stop (or end (point)))) (kitty-gfx--log "display-image: file=%s id=%d cols=%d rows=%d beg=%s end=%s cached=%s" abs-file image-id cols rows start stop (if cached-id "yes" "no")) - ;; Transmit image if not cached + ;; Prepare image if not cached (backend-specific: transmit or validate) (unless cached-id - (let* ((png (kitty-gfx--convert-to-png abs-file)) - (temp-p (and png (not (string= png abs-file))))) - (unwind-protect - (let ((b64 (when png (kitty-gfx--read-file-base64 png)))) - (if (not b64) - (kitty-gfx--log "display-image: skipped %s (conversion failed)" abs-file) - (kitty-gfx--log "transmit: id=%d b64-len=%d png=%s" image-id (length b64) png) - (kitty-gfx--transmit-image image-id b64) - (kitty-gfx--cache-put abs-file image-id))) - (when temp-p - (ignore-errors (delete-file png)))))) + (when (funcall (kitty-gfx--backend-fn 'prepare) abs-file image-id) + (kitty-gfx--cache-put abs-file image-id))) ;; Create overlay with blank space (even for cached images, dims are fresh) (when (or cached-id (gethash abs-file kitty-gfx--image-cache)) - (kitty-gfx--make-overlay start stop image-id cols rows) + (kitty-gfx--make-overlay start stop image-id cols rows abs-file) ;; Schedule initial render (kitty-gfx--schedule-refresh)))) @@ -850,19 +1122,11 @@ The buffer should be writable (caller handles `inhibit-read-only')." (cached-id (kitty-gfx--cache-get abs-file)) (image-id (or cached-id (kitty-gfx--alloc-id)))) (unless cached-id - (let* ((png (kitty-gfx--convert-to-png abs-file)) - (temp-p (and png (not (string= png abs-file))))) - (unwind-protect - (let ((b64 (when png (kitty-gfx--read-file-base64 png)))) - (if (not b64) - (kitty-gfx--log "centered: skipped %s (conversion failed)" abs-file) - (kitty-gfx--transmit-image image-id b64) - (kitty-gfx--cache-put abs-file image-id))) - (when temp-p - (ignore-errors (delete-file png)))))) + (when (funcall (kitty-gfx--backend-fn 'prepare) abs-file image-id) + (kitty-gfx--cache-put abs-file image-id))) ;; Create overlay at the scaled dimensions (when (or cached-id (gethash abs-file kitty-gfx--image-cache)) - (kitty-gfx--make-overlay img-start (point) image-id img-cols img-rows reuse-pid) + (kitty-gfx--make-overlay img-start (point) image-id img-cols img-rows abs-file reuse-pid) (kitty-gfx--schedule-refresh))))) (defun kitty-gfx-remove-images (&optional beg end) @@ -885,42 +1149,164 @@ The buffer should be writable (caller handles `inhibit-read-only')." (with-current-buffer buf (when kitty-gfx--overlays (kitty-gfx-remove-images)))) - (kitty-gfx--delete-all-images) + (when kitty-gfx--active-backend + (funcall (kitty-gfx--backend-fn 'cleanup-all))) (clrhash kitty-gfx--image-cache) (setq kitty-gfx--cache-lru nil) (setq kitty-gfx--next-id 1) (setq kitty-gfx--next-placement-id 1) (kitty-gfx--log "clear-all: done (reset IDs to 1)")) +;;;; Debug commands + +(defun kitty-gfx-debug-state () + "Dump all critical kitty-gfx state to *kitty-gfx-debug-state* buffer." + (interactive) + (let ((buf (get-buffer-create "*kitty-gfx-debug-state*"))) + (with-current-buffer buf + (erase-buffer) + (insert "=== kitty-gfx debug state ===\n") + (insert (format "Timestamp: %s\n" (format-time-string "%Y-%m-%d %H:%M:%S.%3N"))) + (insert (format "Backend: %s\n" kitty-gfx--active-backend)) + (insert (format "Cell pixel size: %sx%s\n" + kitty-gfx--cell-pixel-width kitty-gfx--cell-pixel-height)) + (insert (format "ImageMagick: magick=%s convert=%s identify=%s\n" + (executable-find "magick") + (executable-find "convert") + (executable-find "identify"))) + (insert (format "Next ID: %d Next PID: %d\n" + kitty-gfx--next-id kitty-gfx--next-placement-id)) + (insert "\n--- Windows ---\n") + (walk-windows + (lambda (win) + (let ((edges (window-edges win)) + (body (window-body-edges win))) + (insert (format " win=%s buf=%s edges=%S body=%S size=%dx%d\n" + win (buffer-name (window-buffer win)) + edges body + (window-body-width win) (window-body-height win))))) + nil 'visible) + (insert "\n--- Overlays ---\n") + (let ((count 0)) + (dolist (b (buffer-list)) + (let ((ovs (buffer-local-value 'kitty-gfx--overlays b))) + (when ovs + (dolist (ov ovs) + (cl-incf count) + (let ((alive (not (null (overlay-buffer ov))))) + (insert (format " [%d] buf=%s alive=%s id=%s pid=%s cols=%s rows=%s\n" + count (buffer-name b) alive + (overlay-get ov 'kitty-gfx-id) + (overlay-get ov 'kitty-gfx-pid) + (overlay-get ov 'kitty-gfx-cols) + (overlay-get ov 'kitty-gfx-rows))) + (insert (format " file=%s\n" + (overlay-get ov 'kitty-gfx-file))) + (insert (format " buf-pos=%s-%s last-row=%s last-col=%s\n" + (and alive (overlay-start ov)) + (and alive (overlay-end ov)) + (overlay-get ov 'kitty-gfx-last-row) + (overlay-get ov 'kitty-gfx-last-col))) + (when alive + (let ((screen-pos (kitty-gfx--overlay-screen-pos ov))) + (insert (format " computed-screen-pos=%S\n" screen-pos))))))))) + (insert (format "\nTotal overlays: %d\n" count))) + (insert "\n--- Image cache ---\n") + (insert (format " entries=%d lru-len=%d\n" + (hash-table-count kitty-gfx--image-cache) + (length kitty-gfx--cache-lru))) + (maphash (lambda (k v) (insert (format " %s -> %s\n" k v))) + kitty-gfx--image-cache) + (insert "\n--- Sixel cache ---\n") + (insert (format " entries=%d\n" (hash-table-count kitty-gfx--sixel-cache))) + (maphash (lambda (k v) (insert (format " %s -> %s\n" k v))) + kitty-gfx--sixel-cache)) + (display-buffer buf) + (message "kitty-gfx: debug state dumped to *kitty-gfx-debug-state*"))) + +(defun kitty-gfx-debug-overlay-at-point () + "Show deep debug info for the kitty-gfx overlay at point." + (interactive) + (let ((found nil)) + (dolist (ov (overlays-at (point))) + (when (overlay-get ov 'kitty-gfx-id) + (setq found ov))) + (if (not found) + (message "kitty-gfx: no overlay at point") + (let* ((id (overlay-get found 'kitty-gfx-id)) + (pid (overlay-get found 'kitty-gfx-pid)) + (cols (overlay-get found 'kitty-gfx-cols)) + (rows (overlay-get found 'kitty-gfx-rows)) + (file (overlay-get found 'kitty-gfx-file)) + (cw (or kitty-gfx--cell-pixel-width 8)) + (ch (or kitty-gfx--cell-pixel-height 16)) + (pixel-w (* (or cols 0) cw)) + (pixel-h (* (or rows 0) ch)) + (last-row (overlay-get found 'kitty-gfx-last-row)) + (last-col (overlay-get found 'kitty-gfx-last-col)) + (pos (overlay-start found)) + (win (selected-window)) + (win-pos (and pos (posn-at-point pos win))) + (col-row (and win-pos (posn-col-row win-pos))) + (edges (window-edges win)) + (body-edges (window-body-edges win)) + (buf-col (save-excursion + (goto-char pos) + (current-column))) + (screen-pos (kitty-gfx--overlay-screen-pos found)) + (disp-prop (overlay-get found 'display)) + (disp-len (if (stringp disp-prop) (length disp-prop) nil))) + (message (concat + "kitty-gfx overlay: id=%s pid=%s file=%s\n" + " cols=%s rows=%s cell=%dx%d pixel=%dx%d\n" + " posn-col-row=%S win-edges=%S body-edges=%S buf-col=%d\n" + " computed-screen-pos=%S last-row=%s last-col=%s\n" + " display-prop-len=%s") + id pid file + cols rows cw ch pixel-w pixel-h + col-row edges body-edges buf-col + screen-pos last-row last-col + disp-len))))) + ;;;; Minor mode ;;;###autoload (define-minor-mode kitty-graphics-mode - "Display images in terminal Emacs via Kitty graphics protocol." + "Display images in terminal Emacs via graphics protocol (Kitty or Sixel)." :global t - :lighter " KittyGfx" + :lighter (:eval (concat " KittyGfx[" + (pcase kitty-gfx--active-backend + ('kitty "K") + ('sixel "S") + (_ "?")) + "]")) (if kitty-graphics-mode - (if (kitty-gfx--supported-p) + (if (kitty-gfx--detect-protocol) (progn - (kitty-gfx--log "mode: enabling") - (kitty-gfx--delete-all-images) ; clear stale state + (kitty-gfx--log "mode: enabling (backend=%s)" kitty-gfx--active-backend) + (when kitty-gfx--active-backend + (funcall (kitty-gfx--backend-fn 'cleanup-all))) ; clear stale state (kitty-gfx--query-cell-size) (kitty-gfx--install-hooks) (kitty-gfx--install-integrations) - (kitty-gfx--log "mode: enabled (cell=%dx%d)" + (kitty-gfx--log "mode: enabled (backend=%s cell=%dx%d)" + kitty-gfx--active-backend kitty-gfx--cell-pixel-width kitty-gfx--cell-pixel-height) - (message "Kitty graphics mode enabled")) + (message "Kitty graphics mode enabled (%s backend)" + kitty-gfx--active-backend)) (kitty-gfx--log "mode: terminal not supported, aborting enable") (setq kitty-graphics-mode nil) (message "Kitty graphics: terminal not supported")) (kitty-gfx--log "mode: disabling") (kitty-gfx--uninstall-hooks) (kitty-gfx--uninstall-integrations) - (kitty-gfx--delete-all-images) + (when kitty-gfx--active-backend + (funcall (kitty-gfx--backend-fn 'cleanup-all))) (when kitty-gfx--render-timer (cancel-timer kitty-gfx--render-timer)) (setq kitty-gfx--render-timer nil - kitty-gfx--refresh-pending nil) + kitty-gfx--refresh-pending nil + kitty-gfx--active-backend nil) (kitty-gfx--log "mode: disabled"))) (defun kitty-gfx--install-hooks () @@ -947,7 +1333,7 @@ Deletes image placements from the terminal, clears position caches, then schedules a refresh that re-places only the overlays that are still visible (not inside a fold)." (kitty-gfx--log "on-org-cycle: overlays=%d" (length kitty-gfx--overlays)) - (when (and kitty-graphics-mode kitty-gfx--overlays) + (when (and kitty-graphics-mode kitty-gfx--overlays kitty-gfx--active-backend) (kitty-gfx--sync-begin) (unwind-protect (dolist (ov kitty-gfx--overlays) @@ -955,7 +1341,7 @@ still visible (not inside a fold)." (let ((id (overlay-get ov 'kitty-gfx-id)) (pid (overlay-get ov 'kitty-gfx-pid))) (when (and id pid) - (kitty-gfx--delete-placement id pid))) + (funcall (kitty-gfx--backend-fn 'delete) ov id pid))) (overlay-put ov 'kitty-gfx-last-row nil) (overlay-put ov 'kitty-gfx-last-col nil))) (kitty-gfx--sync-end)) @@ -994,7 +1380,7 @@ Scans for file:, attachment:, and relative path links." ((string= link-type "attachment") (ignore-errors (require 'org-attach) - (when-let ((dir (org-attach-dir))) + (when-let* ((dir (org-attach-dir))) (expand-file-name path dir)))) (t path)))) (when (and file @@ -1114,7 +1500,7 @@ image via Kitty graphics instead of an Emacs image spec." (kitty-gfx-display-image movefile beg end) ;; Tag the most recently created overlay with org properties ;; so org-clear-latex-preview can find and clean it up. - (when-let ((ov (car kitty-gfx--overlays))) + (when-let* ((ov (car kitty-gfx--overlays))) (overlay-put ov 'org-overlay-type 'org-latex-overlay) (overlay-put ov 'modification-hooks (list (lambda (o after &rest _) @@ -1140,7 +1526,7 @@ Values > 1.0 zoom in, < 1.0 zoom out.") (defun kitty-gfx--image-mode-render () "Render the current image file centered at current scale." - (when-let ((file (buffer-file-name))) + (when-let* ((file (buffer-file-name))) (when (kitty-gfx--image-file-p file) (let* ((inhibit-read-only t) (win-w (- (window-body-width) 2)) @@ -1392,7 +1778,7 @@ FILE is the file to preview, PREVIEW-WINDOW is the target window. Returns a buffer recipe, or nil if not in terminal or not an image." (when (and kitty-graphics-mode (not (display-graphic-p)) - (kitty-gfx--supported-p)) + kitty-gfx--active-backend) (kitty-gfx--log "dirvish-preview: %s" file) (let* ((buf-name (format " *kitty-dirvish: %s*" (file-name-nondirectory file))) (buf (get-buffer-create buf-name)) @@ -1584,19 +1970,21 @@ and image-mode simultaneously)." (condition-case nil (let ((id (overlay-get ov 'kitty-gfx-id)) (pid (overlay-get ov 'kitty-gfx-pid))) - (when (and id pid) + (when (and id pid kitty-gfx--active-backend) ;; Always delete the placement (it's buffer-specific) - (kitty-gfx--delete-placement id pid)) + (funcall (kitty-gfx--backend-fn 'delete) ov id pid)) ;; Only delete the image data if no other buffer uses it (when (and id (not (memq id deleted-ids))) (if (kitty-gfx--image-id-in-other-buffers-p id) (kitty-gfx--log "kill-buffer-hook: id=%d still used in other buffers, keeping" id) - (kitty-gfx--delete-by-id id) (push id deleted-ids)))) (error nil))) ;; Remove cache entries only for IDs we actually deleted (when deleted-ids (kitty-gfx--log "kill-buffer-hook: cleaning cache for ids=%S" deleted-ids) + (when kitty-gfx--active-backend + (dolist (id deleted-ids) + (funcall (kitty-gfx--backend-fn 'cleanup) nil id))) (maphash (lambda (file id) (when (memq id deleted-ids) (kitty-gfx--cache-remove file))) |
