summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-02-23 23:41:44 +0100
committercashmere <cashmere@autistici.org>2026-02-24 05:57:26 +0100
commita56e57cae97a4c839a6f2b7cb54cf40328c297ba (patch)
treeeba5c9ed141ab4d05e6dc5f73cced6b3b63ba4ee
parent907cdb6b3dc7625a1b7918a897ce44202f0c37c6 (diff)
downloadkitty-graphics-a56e57cae97a4c839a6f2b7cb54cf40328c297ba.tar.gz
Initial commit: kitty-graphics.el
Display images in terminal Emacs via Kitty graphics protocol with direct placements. Integrates with org-mode (including org-link-preview for org 10.0+), image-mode, and shr/eww.
-rw-r--r--.gitignore2
-rw-r--r--README.org97
-rw-r--r--kitty-graphics.el722
3 files changed, 821 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0ea2b54
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+*.elc
+.direnv/
diff --git a/README.org b/README.org
index e69de29..09a2005 100644
--- a/README.org
+++ b/README.org
@@ -0,0 +1,97 @@
+#+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]].
+
+* 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.
+
+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+)
+- *image-mode* --- terminal-aware image file viewing
+- *shr/eww* --- inline images in HTML rendering (eww, mu4e, gnus)
+
+* 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
+- Launch Emacs with ~TERM=xterm-256color~ (Emacs often can't find the ~xterm-kitty~ terminfo)
+
+* Installation
+
+** straight.el
+
+#+begin_src emacs-lisp
+(use-package kitty-graphics
+ :straight (:local-repo "~/projects/kitty-graphics")
+ :if (and (not (display-graphic-p)) (getenv "KITTY_PID"))
+ :config
+ (kitty-graphics-mode 1))
+#+end_src
+
+** Manual
+
+#+begin_src emacs-lisp
+(add-to-list 'load-path "~/projects/kitty-graphics")
+(require 'kitty-graphics)
+(when (and (not (display-graphic-p)) (getenv "KITTY_PID"))
+ (kitty-graphics-mode 1))
+#+end_src
+
+* Usage
+
+Enable the global minor mode:
+
+#+begin_src emacs-lisp
+(kitty-graphics-mode 1)
+#+end_src
+
+Then in any org-mode buffer, toggle inline images with =C-c C-x C-v=.
+
+** Commands
+
+| Command | Description |
+|----------------------------+------------------------------------------|
+| ~kitty-graphics-mode~ | Toggle the global minor mode |
+| ~kitty-gfx-display-image~ | Display a single image at point |
+| ~kitty-gfx-remove-images~ | Remove images in region or buffer |
+| ~kitty-gfx-clear-all~ | Remove all images from all buffers |
+
+** Customization
+
+| Variable | Default | Description |
+|----------------------------+---------+-------------------------------------|
+| ~kitty-gfx-max-width~ | 80 | Maximum image width in columns |
+| ~kitty-gfx-max-height~ | 24 | Maximum 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~ |
+
+* How It Works
+
+1. Image data is transmitted once via APC escape sequences (~a=t~, store only)
+2. Overlays with a ~display~ property reserve blank space in Emacs buffers
+3. After each redisplay, direct placements (~a=p~) are emitted via
+ ~send-string-to-terminal~ at the overlay's screen coordinates
+4. Each placement uses a unique placement ID (~p=PID~) so re-placements
+ at new positions replace the old rendering without accumulation
+5. Position caching skips redundant re-placements when nothing moved
+6. Placements are deleted when overlays scroll out of view
+
+* Origins
+
+This package grew out of work on [[https://github.com/akermu/emacs-libvterm][emacs-libvterm]] (vterm). The initial
+implementation added Kitty graphics passthrough to vterm --- intercepting
+APC escape sequences from programs running inside vterm and forwarding
+them to the outer terminal. That work proved the ~send-string-to-terminal~
+approach was viable and led to this standalone package that works in any
+terminal Emacs buffer, independent of vterm.
+
+* License
+
+GPL-3.0-or-later
diff --git a/kitty-graphics.el b/kitty-graphics.el
new file mode 100644
index 0000000..c2b03cd
--- /dev/null
+++ b/kitty-graphics.el
@@ -0,0 +1,722 @@
+;;; kitty-graphics.el --- Display images in terminal Emacs via Kitty graphics protocol -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025
+;;
+;; Author: vterm-graphics contributors
+;; Version: 0.1.0
+;; URL: https://git.cashmere.rs/vterm-graphics.git
+;; Keywords: terminals, images, multimedia
+;; Package-Requires: ((emacs "27.1"))
+
+;; This file is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation; either version 3, or (at your option)
+;; any later version.
+
+;;; Commentary:
+;;
+;; Display images in terminal Emacs (emacs -nw) using the Kitty graphics
+;; protocol with direct placements.
+;;
+;; Architecture: image data is transmitted once via `a=t' (stored in the
+;; terminal without display). Overlays reserve blank space in Emacs
+;; buffers. After each redisplay, direct placements (`a=p' with cursor
+;; positioning) are emitted via `send-string-to-terminal' at the correct
+;; screen positions. Each placement uses a unique placement ID (`p=PID')
+;; so repeated placements replace rather than accumulate.
+;;
+;; Requires: Kitty >= 0.20.0 (direct placement support).
+;; Important: Launch Emacs with TERM=xterm-256color for proper color support.
+;;
+;; Usage:
+;; (require 'kitty-graphics)
+;; (kitty-graphics-mode 1)
+;; ;; Then org-mode C-c C-x C-v, image-mode, eww images all work.
+
+;;; Code:
+
+(require 'cl-lib)
+
+;; Forward declarations for optional dependencies
+(declare-function org-element-context "org-element" ())
+(declare-function org-element-type "org-element" (element))
+(declare-function org-element-property "org-element" (property element))
+(declare-function org-attach-dir "org-attach" (&optional create-if-not-exists-p))
+(declare-function org-link-preview "org" (&optional arg beg end))
+(declare-function org-link-preview-region "org" (&optional include-linked refresh beg end))
+(declare-function image-mode-setup-winprops "image-mode" ())
+(declare-function shr-rescale-image "shr" (data &optional content-type width height max-width max-height))
+(defvar org-image-actual-width)
+(defvar image-mode-map)
+
+;;;; Customization
+
+(defgroup kitty-graphics nil
+ "Display images in terminal Emacs via Kitty graphics."
+ :group 'multimedia
+ :prefix "kitty-gfx-")
+
+(defcustom kitty-gfx-max-width 80
+ "Maximum image width in terminal columns."
+ :type 'integer
+ :group 'kitty-graphics)
+
+(defcustom kitty-gfx-max-height 24
+ "Maximum image height in terminal rows."
+ :type 'integer
+ :group 'kitty-graphics)
+
+(defcustom kitty-gfx-chunk-size 4096
+ "Maximum base64 chunk size for image transfer."
+ :type 'integer
+ :group 'kitty-graphics)
+
+(defcustom kitty-gfx-render-delay 0.016
+ "Delay in seconds before re-rendering images after redisplay.
+This debounces rapid redisplay events. Default is ~1 frame at 60fps."
+ :type 'number
+ :group 'kitty-graphics)
+
+(defcustom kitty-gfx-debug nil
+ "When non-nil, log debug info to *kitty-gfx-debug* buffer."
+ :type 'boolean
+ :group 'kitty-graphics)
+
+(defvar kitty-gfx--log-file "/tmp/kitty-gfx.log"
+ "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."
+ (when kitty-gfx-debug
+ (let ((msg (concat (format-time-string "%H:%M:%S ") (apply #'format fmt args) "\n")))
+ (append-to-file msg nil kitty-gfx--log-file))))
+
+;;;; Constants — kept for reference if switching back to Unicode placeholders
+;; (defconst kitty-gfx--placeholder-char #x10EEEE)
+;; (defconst kitty-gfx--diacritics [...])
+
+;;;; Internal state
+
+;; Forward declaration — defined by `define-minor-mode' below.
+(defvar kitty-graphics-mode)
+
+(defvar kitty-gfx--next-id 1
+ "Next image ID to assign (1-4294967295).
+With direct placements, any uint32 ID works — no 256-color constraint.")
+
+(defvar kitty-gfx--image-cache (make-hash-table :test 'equal)
+ "Maps file paths to (image-id . (cols . rows)).")
+
+(defvar-local kitty-gfx--overlays nil
+ "Image overlays in this buffer.")
+
+(defvar kitty-gfx--render-timer nil
+ "Timer for deferred re-rendering.")
+
+(defvar kitty-gfx--cell-pixel-width nil
+ "Terminal cell width in pixels (queried on startup).")
+
+(defvar kitty-gfx--cell-pixel-height nil
+ "Terminal cell height in pixels (queried on startup).")
+
+;; kitty-gfx--placeholder-width removed — direct placements don't use placeholders
+
+(defvar kitty-gfx--next-placement-id 1
+ "Next placement ID (p=PID) for direct placements.
+Each overlay gets a unique PID so repeated placements replace
+rather than accumulate.")
+
+;;;; Terminal detection
+
+(defun kitty-gfx--supported-p ()
+ "Return non-nil if the terminal supports Kitty graphics."
+ (and (not (display-graphic-p))
+ (or (getenv "KITTY_PID")
+ (equal (getenv "TERM_PROGRAM") "kitty")
+ (equal (getenv "TERM_PROGRAM") "WezTerm")
+ (equal (getenv "TERM_PROGRAM") "ghostty"))))
+
+(defun kitty-gfx--query-cell-size ()
+ "Try to determine terminal cell size in pixels.
+Falls back to reasonable defaults (8x16) if query fails."
+ ;; Use Kitty's XTWINOPS CSI 16 t to query cell size.
+ ;; The terminal responds with CSI 6 ; height ; width t
+ ;; For now, use sensible defaults — async query is complex in Emacs.
+ ;; TODO: Parse response from CSI 16 t if we can make it work.
+ (unless kitty-gfx--cell-pixel-width
+ (setq kitty-gfx--cell-pixel-width 8))
+ (unless kitty-gfx--cell-pixel-height
+ (setq kitty-gfx--cell-pixel-height 16)))
+
+;;;; Protocol layer
+
+(defun kitty-gfx--transmit-image (id base64-data)
+ "Transmit image data to terminal with `a=t' (store only, no display).
+ID is the image ID to assign. BASE64-DATA is the PNG data, base64-encoded.
+After this call, the image is stored in the terminal and can be placed
+with `kitty-gfx--place-image'."
+ (let* ((chunk-size kitty-gfx-chunk-size)
+ (len (length base64-data))
+ (offset 0)
+ (first t))
+ (while (< offset len)
+ (let* ((end (min (+ offset chunk-size) len))
+ (chunk (substring base64-data offset end))
+ (more (if (< end len) 1 0))
+ (ctrl (if first
+ (format "a=t,q=2,f=100,i=%d,m=%d" id more)
+ (format "m=%d,q=2" more))))
+ (send-string-to-terminal (format "\e_G%s;%s\e\\" ctrl chunk))
+ (setq offset end
+ first nil)))
+ (kitty-gfx--log "transmitted image: id=%d b64-len=%d" id len)))
+
+(defun kitty-gfx--delete-by-id (id)
+ "Delete image with ID and free data."
+ (ignore-errors
+ (send-string-to-terminal (format "\e_Ga=d,d=I,i=%d,q=2\e\\" id))))
+
+(defun kitty-gfx--delete-all-images ()
+ "Delete all visible placements and free data."
+ (ignore-errors
+ (send-string-to-terminal "\e_Ga=d,d=A,q=2\e\\")))
+
+;;;; Direct placement (the core rendering mechanism)
+
+(defun kitty-gfx--alloc-placement-id ()
+ "Allocate a unique placement ID."
+ (let ((pid kitty-gfx--next-placement-id))
+ (setq kitty-gfx--next-placement-id (1+ kitty-gfx--next-placement-id))
+ (when (> kitty-gfx--next-placement-id 4294967295)
+ (setq kitty-gfx--next-placement-id 1))
+ pid))
+
+(defun kitty-gfx--place-image (image-id placement-id cols rows term-row term-col)
+ "Place image IMAGE-ID at terminal position TERM-ROW, TERM-COL.
+PLACEMENT-ID is the unique placement ID (p=PID) — reusing the same PID
+replaces the previous placement, preventing accumulation.
+COLS x ROWS is the size in terminal cells.
+Uses direct placement: move cursor, then `a=p' with `c' and `r' params."
+ (kitty-gfx--log "place: id=%d pid=%d cols=%d rows=%d row=%d col=%d"
+ image-id placement-id cols rows term-row term-col)
+ (send-string-to-terminal
+ (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)))
+
+;;;; Position mapping
+
+(defun kitty-gfx--overlay-screen-pos (ov)
+ "Return (TERM-ROW . TERM-COL) for overlay OV, or nil if not visible.
+Coordinates are 1-indexed terminal positions."
+ (let* ((buf (overlay-buffer ov))
+ (pos (overlay-start ov))
+ (win (and buf (get-buffer-window buf))))
+ (when (and win pos (pos-visible-in-window-p pos win))
+ (let* ((edges (window-edges win))
+ (win-top (nth 1 edges))
+ (win-left (nth 0 edges))
+ (win-pos (posn-at-point pos win)))
+ (when win-pos
+ (let ((col-row (posn-col-row win-pos)))
+ (when col-row
+ (cons (+ win-top (cdr col-row) 1)
+ (+ win-left (car col-row) 1)))))))))
+
+;;;; Refresh cycle
+
+(defun kitty-gfx--refresh ()
+ "Re-place all visible images after redisplay using direct placements.
+Relies on placement IDs (p=PID) — re-placing with the same PID
+replaces the previous placement without needing to delete first.
+Caches last position per overlay to skip redundant re-placements.
+Deletes placements for overlays that scrolled out of view."
+ (when (and kitty-graphics-mode (not (display-graphic-p)))
+ (walk-windows
+ (lambda (win)
+ (with-current-buffer (window-buffer win)
+ (when kitty-gfx--overlays
+ (let* ((edges (window-edges win))
+ (win-bottom (nth 3 edges)))
+ (dolist (ov kitty-gfx--overlays)
+ (when (overlay-buffer ov)
+ (let ((pos (kitty-gfx--overlay-screen-pos ov))
+ (rows (overlay-get ov 'kitty-gfx-rows))
+ (last-row (overlay-get ov 'kitty-gfx-last-row))
+ (last-col (overlay-get ov 'kitty-gfx-last-col)))
+ (if (and pos (<= (+ (car pos) rows) (1+ win-bottom)))
+ ;; Visible and fits — place if position changed
+ (let ((new-row (car pos))
+ (new-col (cdr pos)))
+ (unless (and (eql new-row last-row)
+ (eql new-col last-col))
+ (overlay-put ov 'kitty-gfx-last-row new-row)
+ (overlay-put ov 'kitty-gfx-last-col new-col)
+ (kitty-gfx--place-image
+ (overlay-get ov 'kitty-gfx-id)
+ (overlay-get ov 'kitty-gfx-pid)
+ (overlay-get ov 'kitty-gfx-cols)
+ rows new-row new-col)))
+ ;; Not visible or overflows — delete if was placed
+ (when last-row
+ (overlay-put ov 'kitty-gfx-last-row nil)
+ (overlay-put ov 'kitty-gfx-last-col nil)
+ (kitty-gfx--delete-placement
+ (overlay-get ov 'kitty-gfx-id)
+ (overlay-get ov 'kitty-gfx-pid)))))))))))
+ nil 'visible)))
+
+(defun kitty-gfx--schedule-refresh ()
+ "Schedule an image refresh after the current redisplay completes."
+ (when kitty-gfx--render-timer
+ (cancel-timer kitty-gfx--render-timer))
+ (setq kitty-gfx--render-timer
+ (run-at-time kitty-gfx-render-delay nil
+ (lambda ()
+ (setq kitty-gfx--render-timer nil)
+ (kitty-gfx--refresh)))))
+
+(defun kitty-gfx--on-window-scroll (win _new-start)
+ "Handle window scroll for image refresh."
+ (when (buffer-local-value 'kitty-gfx--overlays (window-buffer win))
+ (kitty-gfx--schedule-refresh)))
+
+(defun kitty-gfx--on-buffer-change (_frame-or-window)
+ "Handle buffer change for image refresh.
+Clears visible placements first since the displayed buffer changed."
+ (ignore-errors
+ (send-string-to-terminal "\e_Ga=d,d=a,q=2\e\\"))
+ (kitty-gfx--schedule-refresh))
+
+(defun kitty-gfx--on-window-change (_frame)
+ "Handle window configuration change for image refresh.
+Clears visible placements first since window layout changed."
+ (ignore-errors
+ (send-string-to-terminal "\e_Ga=d,d=a,q=2\e\\"))
+ (kitty-gfx--schedule-refresh))
+
+(defun kitty-gfx--on-redisplay ()
+ "Post-command hook to schedule image refresh."
+ (kitty-gfx--schedule-refresh))
+
+;;;; Image processing
+
+(defun kitty-gfx--read-file-base64 (file)
+ "Read FILE and return base64-encoded string."
+ (with-temp-buffer
+ (set-buffer-multibyte nil)
+ (insert-file-contents-literally file)
+ (base64-encode-region (point-min) (point-max) t)
+ (buffer-string)))
+
+(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"))))
+ (when identify
+ (with-temp-buffer
+ (let ((args (if (string-suffix-p "magick" identify)
+ (list identify nil t nil "identify" "-format" "%w %h"
+ (concat file "[0]")) ; first frame only
+ (list identify nil t 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))
+ (when (zerop exit-code)
+ (goto-char (point-min))
+ (when (looking-at "\\([0-9]+\\) \\([0-9]+\\)")
+ (let ((w (string-to-number (match-string 1)))
+ (h (string-to-number (match-string 2))))
+ (kitty-gfx--log "identify: %dx%d pixels" w h)
+ (cons w h))))))))))
+
+(defun kitty-gfx--convert-to-png (file)
+ "Convert FILE to PNG if needed. Returns path to PNG file.
+Returns FILE unchanged if it is already PNG or if conversion fails."
+ (if (string-suffix-p ".png" file t)
+ file
+ (let ((convert (or (executable-find "magick")
+ (executable-find "convert"))))
+ (if (not convert)
+ file ; no converter available, try sending as-is
+ (let ((out (make-temp-file "kitty-gfx-" nil ".png")))
+ (if (string-suffix-p "magick" convert)
+ (call-process convert nil nil nil "convert" file out)
+ (call-process convert nil nil nil file out))
+ ;; Check that conversion produced a non-empty file
+ (if (and (file-exists-p out)
+ (> (file-attribute-size (file-attributes out)) 0))
+ out
+ (ignore-errors (delete-file out))
+ file))))))
+
+(defun kitty-gfx--compute-cell-dims (pixel-w pixel-h max-cols max-rows)
+ "Compute (COLS . ROWS) in terminal cells for image placement.
+With direct placements, COLS and ROWS map directly to terminal columns/rows."
+ (let* ((cw (or kitty-gfx--cell-pixel-width 8))
+ (ch (or kitty-gfx--cell-pixel-height 16))
+ (img-cols (max 1 (ceiling (/ (float pixel-w) cw))))
+ (img-rows (max 1 (ceiling (/ (float pixel-h) ch))))
+ (scale (min (if (> img-cols max-cols)
+ (/ (float max-cols) img-cols) 1.0)
+ (if (> img-rows max-rows)
+ (/ (float max-rows) img-rows) 1.0)))
+ (cols (max 1 (min (round (* img-cols scale)) max-cols)))
+ (rows (max 1 (min (round (* img-rows scale)) max-rows))))
+ (kitty-gfx--log "cell-dims: pixel=%dx%d cw=%d ch=%d img=%dx%d scale=%.2f result=%dx%d"
+ pixel-w pixel-h cw ch img-cols img-rows scale cols rows)
+ (cons cols rows)))
+
+;;;; Overlay management
+
+(defun kitty-gfx--alloc-id ()
+ "Allocate a new image ID (1-4294967295)."
+ (let ((id kitty-gfx--next-id))
+ (setq kitty-gfx--next-id (1+ kitty-gfx--next-id))
+ (when (> kitty-gfx--next-id 4294967295)
+ (setq kitty-gfx--next-id 1))
+ id))
+
+(defun kitty-gfx--make-blank-display (cols rows)
+ "Create a blank display string of COLS terminal columns x ROWS lines.
+Each line is propertized with face `default' to prevent org-link
+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)
+ "Create overlay from BEG to END for image IMAGE-ID (COLS x ROWS).
+The overlay's display property shows blank space that the terminal
+fills with the image via direct placement."
+ (let ((ov (make-overlay beg end nil t nil))
+ (pid (kitty-gfx--alloc-placement-id)))
+ (overlay-put ov 'display
+ (concat (kitty-gfx--make-blank-display cols rows) "\n"))
+ (overlay-put ov 'face 'default) ; override inherited faces (org-link underline etc.)
+ (overlay-put ov 'kitty-gfx t)
+ (overlay-put ov 'kitty-gfx-id image-id)
+ (overlay-put ov 'kitty-gfx-pid pid)
+ (overlay-put ov 'kitty-gfx-cols cols)
+ (overlay-put ov 'kitty-gfx-rows rows)
+ ;; Don't set evaporate — zero-width overlays (beg==end) would be
+ ;; deleted immediately if evaporate is set.
+ (push ov kitty-gfx--overlays)
+ ov))
+
+(defun kitty-gfx--delete-placement (id pid)
+ "Delete a specific placement PID of image ID from terminal.
+Uses d=i (lowercase) to remove the placement but keep stored image
+data so the image can be re-placed without retransmitting."
+ (ignore-errors
+ (send-string-to-terminal
+ (format "\e_Ga=d,d=i,i=%d,p=%d,q=2\e\\" id pid))))
+
+(defun kitty-gfx--remove-overlay (ov)
+ "Remove overlay OV and delete its placement from terminal."
+ (when (overlay-buffer ov)
+ (condition-case nil
+ (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)))
+ (error nil))
+ (delete-overlay ov))
+ (setq kitty-gfx--overlays (delq ov kitty-gfx--overlays)))
+
+;;;; Public API
+
+;;;###autoload
+(defun kitty-gfx-display-image (file &optional beg end max-cols max-rows)
+ "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"))
+ (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))
+ (cached (gethash abs-file kitty-gfx--image-cache))
+ (image-id (if cached (car cached) (kitty-gfx--alloc-id)))
+ (dims (cond
+ (cached (cdr cached))
+ (t (let ((px (kitty-gfx--image-pixel-size abs-file)))
+ (if px
+ (kitty-gfx--compute-cell-dims
+ (car px) (cdr px) max-c max-r)
+ (cons (min 40 max-c) (min 15 max-r)))))))
+ (cols (car dims))
+ (rows (cdr dims))
+ (start (or beg (point)))
+ (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 "yes" "no"))
+ ;; Transmit image if not cached
+ (unless cached
+ (let* ((png (kitty-gfx--convert-to-png abs-file))
+ (b64 (kitty-gfx--read-file-base64 png)))
+ (kitty-gfx--log "transmit: id=%d b64-len=%d png=%s" image-id (length b64) png)
+ (kitty-gfx--transmit-image image-id b64)
+ (puthash abs-file (cons image-id dims) kitty-gfx--image-cache)
+ (when (and png (not (string= png abs-file)))
+ (delete-file png t))))
+ ;; Create overlay with blank space
+ (kitty-gfx--make-overlay start stop image-id cols rows)
+ ;; Schedule initial render
+ (kitty-gfx--schedule-refresh)))
+
+(defun kitty-gfx-remove-images (&optional beg end)
+ "Remove all kitty-gfx overlays in region BEG..END (defaults to whole buffer)."
+ (interactive)
+ (dolist (ov (overlays-in (or beg (point-min)) (or end (point-max))))
+ (when (overlay-get ov 'kitty-gfx)
+ (kitty-gfx--remove-overlay ov))))
+
+(defun kitty-gfx-clear-all ()
+ "Remove all images from all buffers and the terminal."
+ (interactive)
+ ;; Walk all buffers, not just current
+ (dolist (buf (buffer-list))
+ (with-current-buffer buf
+ (when kitty-gfx--overlays
+ (kitty-gfx-remove-images))))
+ (kitty-gfx--delete-all-images)
+ (clrhash kitty-gfx--image-cache)
+ (setq kitty-gfx--next-id 1)
+ (setq kitty-gfx--next-placement-id 1))
+
+;;;; Minor mode
+
+;;;###autoload
+(define-minor-mode kitty-graphics-mode
+ "Display images in terminal Emacs via Kitty graphics protocol."
+ :global t
+ :lighter " KittyGfx"
+ (if kitty-graphics-mode
+ (if (kitty-gfx--supported-p)
+ (progn
+ (kitty-gfx--delete-all-images) ; clear stale state
+ (kitty-gfx--query-cell-size)
+ (kitty-gfx--install-hooks)
+ (kitty-gfx--install-integrations)
+ (message "Kitty graphics mode enabled"))
+ (setq kitty-graphics-mode nil)
+ (message "Kitty graphics: terminal not supported"))
+ (kitty-gfx--uninstall-hooks)
+ (kitty-gfx--uninstall-integrations)
+ (kitty-gfx--delete-all-images)))
+
+(defun kitty-gfx--install-hooks ()
+ "Install redisplay hooks for image refresh."
+ (add-hook 'window-scroll-functions #'kitty-gfx--on-window-scroll)
+ (add-hook 'window-size-change-functions #'kitty-gfx--on-window-change)
+ (add-hook 'window-buffer-change-functions #'kitty-gfx--on-buffer-change)
+ (add-hook 'post-command-hook #'kitty-gfx--on-redisplay))
+
+(defun kitty-gfx--uninstall-hooks ()
+ "Remove redisplay hooks."
+ (remove-hook 'window-scroll-functions #'kitty-gfx--on-window-scroll)
+ (remove-hook 'window-size-change-functions #'kitty-gfx--on-window-change)
+ (remove-hook 'window-buffer-change-functions #'kitty-gfx--on-buffer-change)
+ (remove-hook 'post-command-hook #'kitty-gfx--on-redisplay))
+
+;;;; Org-mode integration
+
+(defun kitty-gfx--image-file-p (file)
+ "Return non-nil if FILE has an image extension."
+ (let ((ext (file-name-extension file)))
+ (and ext (member (downcase ext)
+ '("png" "jpg" "jpeg" "gif" "bmp" "svg"
+ "webp" "tiff" "tif")))))
+
+(defun kitty-gfx--org-display-inline-images-tty (&optional _include-linked beg end)
+ "Display inline images in org buffer via Kitty graphics.
+Scans for file:, attachment:, and relative path links."
+ (when (derived-mode-p 'org-mode)
+ (let ((start (or beg (point-min)))
+ (stop (or end (point-max))))
+ (save-restriction
+ (widen)
+ (save-excursion
+ (goto-char start)
+ ;; Match file:, attachment:, relative (./) and absolute (/) paths
+ (while (re-search-forward
+ "\\[\\[\\(file:\\|attachment:\\|[./~]\\)" stop t)
+ (let* ((context (org-element-context))
+ (type (org-element-type context)))
+ (when (eq type 'link)
+ (let* ((link-beg (org-element-property :begin context))
+ (link-end (org-element-property :end context))
+ (path (org-element-property :path context))
+ (link-type (org-element-property :type context))
+ (file (cond
+ ((string= link-type "file") path)
+ ((string= link-type "attachment")
+ (ignore-errors
+ (require 'org-attach)
+ (when-let ((dir (org-attach-dir)))
+ (expand-file-name path dir))))
+ (t path))))
+ (when (and file
+ (file-exists-p (expand-file-name file))
+ (kitty-gfx--image-file-p file)
+ (not (cl-some (lambda (ov)
+ (overlay-get ov 'kitty-gfx))
+ (overlays-in link-beg link-end))))
+ (condition-case err
+ (kitty-gfx-display-image
+ (expand-file-name file) link-beg link-end
+ kitty-gfx-max-width kitty-gfx-max-height)
+ (error
+ (message "kitty-gfx: %s: %s"
+ file (error-message-string err))))))))))))))
+
+
+(defun kitty-gfx--org-display-advice (orig-fn &rest args)
+ "Around advice for `org-display-inline-images'."
+ (if (and kitty-graphics-mode (not (display-graphic-p)))
+ (apply #'kitty-gfx--org-display-inline-images-tty args)
+ (apply orig-fn args)))
+
+(defun kitty-gfx--org-remove-advice (orig-fn &rest args)
+ "Around advice for `org-remove-inline-images'."
+ (when (and kitty-graphics-mode (not (display-graphic-p)))
+ (kitty-gfx-remove-images))
+ (apply orig-fn args))
+
+(defun kitty-gfx--org-toggle-advice (orig-fn &rest args)
+ "Around advice for `org-toggle-inline-images'."
+ (if (and kitty-graphics-mode (not (display-graphic-p)))
+ (if (cl-some (lambda (ov) (overlay-get ov 'kitty-gfx))
+ (overlays-in (point-min) (point-max)))
+ (kitty-gfx-remove-images)
+ (kitty-gfx--org-display-inline-images-tty))
+ (apply orig-fn args)))
+
+;; org 10.0+ uses org-link-preview instead of org-toggle-inline-images
+
+(defun kitty-gfx--org-link-preview-advice (orig-fn &optional arg beg end)
+ "Around advice for `org-link-preview' (org 10.0+).
+With prefix ARG \\[universal-argument], clear previews."
+ (if (and kitty-graphics-mode (not (display-graphic-p)))
+ (cond
+ ;; C-u = clear
+ ((equal arg '(4))
+ (kitty-gfx-remove-images beg end))
+ ;; C-u C-u C-u = clear whole buffer
+ ((equal arg '(64))
+ (kitty-gfx-remove-images))
+ ;; Otherwise display images
+ (t
+ (kitty-gfx--org-display-inline-images-tty nil beg end)))
+ (funcall orig-fn arg beg end)))
+
+(defun kitty-gfx--org-link-preview-region-advice (orig-fn &optional include-linked refresh beg end)
+ "Around advice for `org-link-preview-region' (org 10.0+)."
+ (if (and kitty-graphics-mode (not (display-graphic-p)))
+ (kitty-gfx--org-display-inline-images-tty include-linked beg end)
+ (funcall orig-fn include-linked refresh beg end)))
+
+;;;; image-mode integration
+
+(defun kitty-gfx--image-mode-advice (orig-fn &rest args)
+ "Around advice for `image-mode'."
+ (if (and kitty-graphics-mode (not (display-graphic-p)))
+ (progn
+ (major-mode-suspend)
+ (setq major-mode 'image-mode
+ mode-name "Image[Kitty]")
+ (use-local-map (if (boundp 'image-mode-map) image-mode-map
+ (make-sparse-keymap)))
+ (when-let ((file (buffer-file-name)))
+ (when (kitty-gfx--image-file-p file)
+ (kitty-gfx-display-image
+ file (point-min) (point-max)
+ (min (- (window-body-width) 2) kitty-gfx-max-width)
+ (min (- (window-body-height) 2) kitty-gfx-max-height))))
+ (run-mode-hooks 'image-mode-hook))
+ (apply orig-fn args)))
+
+;;;; shr integration (eww, mu4e, gnus)
+
+(defun kitty-gfx--shr-put-image-advice (orig-fn spec alt &rest args)
+ "Around advice for `shr-put-image'.
+SPEC is an image descriptor — typically a create-image result.
+We extract the :file or :data from the image properties."
+ (if (and kitty-graphics-mode (not (display-graphic-p)))
+ (let* ((start (point))
+ ;; shr image spec is (image . PROPS) from `create-image'
+ (props (and (consp spec) (cdr spec)))
+ (data (plist-get props :data))
+ (url (plist-get props :file)))
+ (insert (or alt "[image]"))
+ (let ((end (point)))
+ (condition-case nil
+ (let ((file (cond
+ (url (when (file-exists-p url) url))
+ (data
+ (let ((tmp (make-temp-file "kitty-shr-" nil ".png")))
+ (with-temp-file tmp
+ (set-buffer-multibyte nil)
+ (insert data))
+ tmp)))))
+ (when file
+ (kitty-gfx-display-image file start end)
+ (when data (delete-file file t))))
+ (error nil))))
+ (apply orig-fn spec alt args)))
+
+;;;; Integration install/uninstall
+
+(defun kitty-gfx--install-integrations ()
+ "Install advice on org-mode, image-mode, shr."
+ (with-eval-after-load 'org
+ (advice-add 'org-display-inline-images :around
+ #'kitty-gfx--org-display-advice)
+ (advice-add 'org-remove-inline-images :around
+ #'kitty-gfx--org-remove-advice)
+ (advice-add 'org-toggle-inline-images :around
+ #'kitty-gfx--org-toggle-advice)
+ ;; org 10.0+: org-link-preview replaces org-toggle-inline-images
+ (when (fboundp 'org-link-preview)
+ (advice-add 'org-link-preview :around
+ #'kitty-gfx--org-link-preview-advice))
+ (when (fboundp 'org-link-preview-region)
+ (advice-add 'org-link-preview-region :around
+ #'kitty-gfx--org-link-preview-region-advice)))
+ (with-eval-after-load 'image-mode
+ (advice-add 'image-mode :around
+ #'kitty-gfx--image-mode-advice))
+ (with-eval-after-load 'shr
+ (advice-add 'shr-put-image :around
+ #'kitty-gfx--shr-put-image-advice)))
+
+(defun kitty-gfx--uninstall-integrations ()
+ "Remove all advice."
+ (advice-remove 'org-display-inline-images #'kitty-gfx--org-display-advice)
+ (advice-remove 'org-remove-inline-images #'kitty-gfx--org-remove-advice)
+ (advice-remove 'org-toggle-inline-images #'kitty-gfx--org-toggle-advice)
+ (when (fboundp 'org-link-preview)
+ (advice-remove 'org-link-preview #'kitty-gfx--org-link-preview-advice))
+ (when (fboundp 'org-link-preview-region)
+ (advice-remove 'org-link-preview-region #'kitty-gfx--org-link-preview-region-advice))
+ (advice-remove 'image-mode #'kitty-gfx--image-mode-advice)
+ (advice-remove 'shr-put-image #'kitty-gfx--shr-put-image-advice))
+
+;;;; Buffer cleanup
+
+(defun kitty-gfx--kill-buffer-hook ()
+ "Clean up images when buffer is killed."
+ (when (and kitty-graphics-mode kitty-gfx--overlays)
+ (dolist (ov kitty-gfx--overlays)
+ (condition-case nil
+ (when-let ((id (overlay-get ov 'kitty-gfx-id)))
+ (kitty-gfx--delete-by-id id))
+ (error nil)))
+ (setq kitty-gfx--overlays nil)))
+
+(add-hook 'kill-buffer-hook #'kitty-gfx--kill-buffer-hook)
+
+(provide 'kitty-graphics)
+;; Local Variables:
+;; indent-tabs-mode: nil
+;; End:
+;;; kitty-graphics.el ends here