summaryrefslogtreecommitdiff
path: root/kitty-graphics.el
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-02-27 08:29:34 +0100
committercashmere <cashmere@autistici.org>2026-02-27 08:29:34 +0100
commite8f259d8c03126d548ec28dd9a4bcc41096ab9f9 (patch)
treecf158a74da9951babec31c208b2ad863c4b82141 /kitty-graphics.el
parentf7bd1f752f40b4607813835c7e9ac0311c58097b (diff)
downloadkitty-graphics-e8f259d8c03126d548ec28dd9a4bcc41096ab9f9.tar.gz
feat: robustness overhaul — LRU cache, better error handling, reduced flickerv0.2.1
- LRU image cache with configurable size and eviction (kitty-gfx-cache-size) - Cache stores only image IDs; dimensions computed fresh each time - Leading-edge debounce for faster initial refresh response - Targeted placement deletion on buffer/window change (no more delete-all flicker) - Cell pixel size re-queried on window resize - Image overflow check prevents visual corruption at window bottom - convert-to-png returns nil on failure instead of passing non-PNG data - unwind-protect for temp file cleanup in display-image paths - kill-buffer-hook checks cross-buffer image sharing before deleting - ignore-errors around all send-string-to-terminal calls - Millisecond-precision debug logging throughout - Dead overlay pruning in refresh cycle - GIF removed from supported formats (multi-frame issues) - Window-size-change hook in image-mode for split/unsplit re-render
Diffstat (limited to 'kitty-graphics.el')
-rw-r--r--kitty-graphics.el696
1 files changed, 494 insertions, 202 deletions
diff --git a/kitty-graphics.el b/kitty-graphics.el
index 59b25c3..52f2d60 100644
--- a/kitty-graphics.el
+++ b/kitty-graphics.el
@@ -3,7 +3,7 @@
;; Copyright (C) 2025-2026
;;
;; Author: cashmere
-;; Version: 0.2.0
+;; Version: 0.2.1
;; URL: https://github.com/cashmeredev/kitty-graphics.el
;; Keywords: terminals, images, multimedia
;; Package-Requires: ((emacs "27.1"))
@@ -105,8 +105,9 @@ This debounces rapid redisplay events. Default is ~1 frame at 60fps."
(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))))
+ (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)))))
;;;; Constants — kept for reference if switching back to Unicode placeholders
;; (defconst kitty-gfx--placeholder-char #x10EEEE)
@@ -121,8 +122,22 @@ This debounces rapid redisplay events. Default is ~1 frame at 60fps."
"Next image ID to assign (1-4294967295).
With direct placements, any uint32 ID works — no 256-color constraint.")
+(defcustom kitty-gfx-cache-size 64
+ "Maximum number of images to keep in the terminal-side cache.
+When exceeded, the least recently used image is evicted and its
+terminal data deleted via `a=d'."
+ :type 'integer
+ :group 'kitty-graphics)
+
(defvar kitty-gfx--image-cache (make-hash-table :test 'equal)
- "Maps file paths to (image-id . (cols . rows)).")
+ "Maps file paths to image IDs (integers).
+Only stores the terminal-side image ID — display dimensions are
+computed fresh each time to avoid stale values from different
+display contexts (window sizes, zoom levels, etc.).")
+
+(defvar kitty-gfx--cache-lru nil
+ "LRU list of file paths in `kitty-gfx--image-cache'.
+Most recently used at the front.")
(defvar-local kitty-gfx--overlays nil
"Image overlays in this buffer.")
@@ -147,11 +162,15 @@ rather than accumulate.")
(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"))))
+ (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--query-cell-size ()
"Query terminal for cell size in pixels using CSI 16 t (XTWINOPS).
@@ -197,11 +216,13 @@ Falls back to reasonable defaults if query fails or times out."
"Begin synchronized output (BSU).
The terminal buffers output until `kitty-gfx--sync-end' is called,
preventing partial rendering and flicker."
+ (kitty-gfx--log "sync-begin")
(ignore-errors (send-string-to-terminal "\e[?2026h")))
(defun kitty-gfx--sync-end ()
"End synchronized output (ESU).
Flushes buffered output to the terminal all at once."
+ (kitty-gfx--log "sync-end")
(ignore-errors (send-string-to-terminal "\e[?2026l")))
;;;; Protocol layer
@@ -214,7 +235,10 @@ with `kitty-gfx--place-image'."
(let* ((chunk-size kitty-gfx-chunk-size)
(len (length base64-data))
(offset 0)
- (first t))
+ (first t)
+ (chunk-count 0))
+ (kitty-gfx--log "transmit-begin: id=%d b64-len=%d chunk-size=%d chunks=%d"
+ id len chunk-size (ceiling (/ (float len) chunk-size)))
(while (< offset len)
(let* ((end (min (+ offset chunk-size) len))
(chunk (substring base64-data offset end))
@@ -222,18 +246,22 @@ with `kitty-gfx--place-image'."
(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))
+ (ignore-errors
+ (send-string-to-terminal (format "\e_G%s;%s\e\\" ctrl chunk)))
+ (cl-incf chunk-count)
(setq offset end
first nil)))
- (kitty-gfx--log "transmitted image: id=%d b64-len=%d" id len)))
+ (kitty-gfx--log "transmit-done: id=%d chunks-sent=%d" id chunk-count)))
(defun kitty-gfx--delete-by-id (id)
"Delete image with ID and free data."
+ (kitty-gfx--log "delete-by-id: id=%d" id)
(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."
+ (kitty-gfx--log "delete-all-images")
(ignore-errors
(send-string-to-terminal "\e_Ga=d,d=A,q=2\e\\")))
@@ -245,6 +273,7 @@ with `kitty-gfx--place-image'."
(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))
+ (kitty-gfx--log "alloc-pid: %d" pid)
pid))
(defun kitty-gfx--place-image (image-id placement-id cols rows term-row term-col)
@@ -255,9 +284,10 @@ 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)))
+ (ignore-errors
+ (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
@@ -266,17 +296,19 @@ Uses direct placement: move cursor, then `a=p' with `c' and `r' params."
Checks org-fold (org 9.6+, text-property based) first, then falls
back to overlay-based invisibility for legacy org and outline-mode.
Ignores cosmetic invisibility like hidden link brackets (`org-link')."
- (or
- ;; org-fold (org 9.6+): text-property based folding.
- ;; org-fold-folded-p only checks structural folds (outline, block,
- ;; drawer), not cosmetic link bracket hiding.
- (and (fboundp 'org-fold-folded-p)
- (condition-case nil
- (org-fold-folded-p pos)
- (error nil)))
- ;; Legacy / non-org overlay-based folding (outline-mode, etc.)
- (let ((inv (get-char-property pos 'invisible)))
- (and inv (not (eq inv 'org-link))))))
+ (let ((folded
+ (or
+ ;; org-fold (org 9.6+): text-property based folding.
+ (and (fboundp 'org-fold-folded-p)
+ (condition-case nil
+ (org-fold-folded-p pos)
+ (error nil)))
+ ;; Legacy / non-org overlay-based folding (outline-mode, etc.)
+ (let ((inv (get-char-property pos 'invisible)))
+ (and inv (not (eq inv 'org-link)))))))
+ (when folded
+ (kitty-gfx--log "in-folded-region: pos=%d folded=%s" pos folded))
+ folded))
(defun kitty-gfx--overlay-screen-pos (ov)
"Return (TERM-ROW . TERM-COL) for overlay OV, or nil if not visible.
@@ -287,6 +319,14 @@ the window's scroll range."
(let* ((buf (overlay-buffer ov))
(pos (overlay-start ov))
(win (and buf (get-buffer-window buf))))
+ (unless (and win pos
+ (pos-visible-in-window-p pos win)
+ (not (kitty-gfx--in-folded-region-p pos)))
+ (kitty-gfx--log "screen-pos: pid=%s pos=%s HIDDEN (win=%s visible=%s folded=%s)"
+ (overlay-get ov 'kitty-gfx-pid) pos
+ (and win t)
+ (and win pos (pos-visible-in-window-p pos win))
+ (and pos (kitty-gfx--in-folded-region-p pos))))
(when (and win pos
(pos-visible-in-window-p pos win)
;; Check that the text isn't hidden by structural folding
@@ -317,8 +357,11 @@ the window's scroll range."
(+ body-left buf-col)
(+ win-left posn-col))))
(when col-row
- (cons (+ win-top row 1)
- (1+ col)))))))))
+ (let ((result (cons (+ win-top row 1) (1+ col))))
+ (kitty-gfx--log "screen-pos: pid=%s pos=%d -> row=%d col=%d"
+ (overlay-get ov 'kitty-gfx-pid) pos
+ (car result) (cdr result))
+ result))))))))
;;;; Refresh cycle
@@ -331,19 +374,43 @@ 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)))
- (kitty-gfx--sync-begin)
- (unwind-protect
- (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)
- (kitty-gfx--refresh-overlay ov win-bottom)))))))
- nil 'visible)
- (kitty-gfx--sync-end))))
+ ;; 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))
+ (let ((total-overlays 0)
+ (placed 0)
+ (hidden 0)
+ (pruned 0))
+ (kitty-gfx--log "refresh: begin")
+ (kitty-gfx--sync-begin)
+ (unwind-protect
+ (walk-windows
+ (lambda (win)
+ (with-current-buffer (window-buffer win)
+ (when kitty-gfx--overlays
+ ;; Prune dead overlays (overlay-buffer returns nil)
+ (let ((before (length kitty-gfx--overlays)))
+ (setq kitty-gfx--overlays
+ (cl-delete-if-not #'overlay-buffer kitty-gfx--overlays))
+ (let ((removed (- before (length kitty-gfx--overlays))))
+ (when (> removed 0)
+ (cl-incf pruned removed)
+ (kitty-gfx--log "refresh: pruned %d dead overlays from %s"
+ removed (buffer-name)))))
+ (let* ((edges (window-edges win))
+ (win-bottom (nth 3 edges)))
+ (kitty-gfx--log "refresh: win=%s buf=%s overlays=%d bottom=%d"
+ win (buffer-name) (length kitty-gfx--overlays) win-bottom)
+ (dolist (ov kitty-gfx--overlays)
+ (cl-incf total-overlays)
+ (kitty-gfx--refresh-overlay ov win-bottom)
+ (if (overlay-get ov 'kitty-gfx-last-row)
+ (cl-incf placed)
+ (cl-incf hidden)))))))
+ nil 'visible)
+ (kitty-gfx--sync-end))
+ (kitty-gfx--log "refresh: done total=%d placed=%d hidden=%d pruned=%d"
+ total-overlays placed hidden pruned))))
(defun kitty-gfx--refresh-overlay (ov win-bottom)
"Refresh a single overlay OV. WIN-BOTTOM is the window's bottom edge.
@@ -353,77 +420,140 @@ if the overlay scrolled out of view."
(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) win-bottom))
- ;; Visible (start is on screen) — 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))))))
+ (let ((pid (overlay-get ov 'kitty-gfx-pid))
+ (id (overlay-get ov 'kitty-gfx-id)))
+ (if (and pos
+ ;; Start row is on screen
+ (<= (car pos) win-bottom)
+ ;; Image must fit: start + rows must not exceed win-bottom.
+ ;; This prevents placing images that overflow the terminal
+ ;; and cause visual corruption.
+ (<= (+ (car pos) rows -1) win-bottom))
+ ;; Visible and fits — place if position changed
+ (let ((new-row (car pos))
+ (new-col (cdr pos)))
+ (if (and (eql new-row last-row)
+ (eql new-col last-col))
+ (kitty-gfx--log "refresh-ov: pid=%d unchanged at row=%d col=%d"
+ pid new-row new-col)
+ (kitty-gfx--log "refresh-ov: pid=%d moved %s -> row=%d col=%d"
+ pid
+ (if last-row (format "row=%d,col=%d" last-row last-col) "nil")
+ 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)))
+ ;; 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))))))
+
+(defvar kitty-gfx--refresh-pending nil
+ "Non-nil if a refresh was requested during the cooldown period.")
(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)))))
+ "Schedule an image refresh using leading-edge debounce.
+On the first call, refresh is scheduled via `run-at-time' 0 (fires
+after the current redisplay completes) and a cooldown timer starts
+\(duration `kitty-gfx-render-delay'). Calls during cooldown are
+suppressed but flagged; when the cooldown expires a single trailing
+refresh fires to capture the final state."
+ (if kitty-gfx--render-timer
+ ;; Cooldown active — flag that another refresh is wanted.
+ (setq kitty-gfx--refresh-pending t)
+ ;; No cooldown — schedule refresh after redisplay + start cooldown.
+ ;; run-at-time 0 ensures posn-at-point sees up-to-date positions.
+ (setq kitty-gfx--refresh-pending nil)
+ (run-at-time 0 nil #'kitty-gfx--refresh)
+ (setq kitty-gfx--render-timer
+ (run-at-time kitty-gfx-render-delay nil
+ (lambda ()
+ (setq kitty-gfx--render-timer nil)
+ (when kitty-gfx--refresh-pending
+ (setq kitty-gfx--refresh-pending 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--log "on-scroll: win=%s buf=%s" win (buffer-name (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."
- (kitty-gfx--sync-begin)
- (unwind-protect
- (progn
- (ignore-errors
- (send-string-to-terminal "\e_Ga=d,d=a,q=2\e\\"))
- ;; Reset position cache so images get re-placed
- (dolist (buf (buffer-list))
- (with-current-buffer buf
- (dolist (ov kitty-gfx--overlays)
- (when (overlay-buffer ov)
- (overlay-put ov 'kitty-gfx-last-row nil)
- (overlay-put ov 'kitty-gfx-last-col nil))))))
- (kitty-gfx--sync-end))
- (kitty-gfx--schedule-refresh))
+Deletes placements for buffers no longer visible in any window,
+then invalidates position caches and schedules a refresh."
+ (kitty-gfx--log "on-buffer-change: cleaning up non-visible placements")
+ ;; Find which buffers are currently visible
+ (let ((visible-bufs nil))
+ (walk-windows (lambda (w) (push (window-buffer w) visible-bufs))
+ nil 'visible)
+ ;; Delete placements for buffers that are no longer in any window
+ (dolist (buf (buffer-list))
+ (with-current-buffer buf
+ (when (and kitty-gfx--overlays
+ (not (memq buf visible-bufs)))
+ (kitty-gfx--log "on-buffer-change: deleting placements for hidden buf=%s"
+ (buffer-name))
+ (dolist (ov kitty-gfx--overlays)
+ (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)))
+ (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.
+ (dolist (buf (buffer-list))
+ (with-current-buffer buf
+ (dolist (ov kitty-gfx--overlays)
+ (when (overlay-buffer ov)
+ (overlay-put ov 'kitty-gfx-last-row nil)
+ (overlay-put ov 'kitty-gfx-last-col nil)))))
+ ;; Longer debounce: cancel any fast leading-edge cooldown and
+ ;; schedule a 0.1s delayed refresh to let buffer switch settle.
+ (when kitty-gfx--render-timer
+ (cancel-timer kitty-gfx--render-timer))
+ (setq kitty-gfx--refresh-pending nil
+ kitty-gfx--render-timer
+ (run-at-time 0.1 nil
+ (lambda ()
+ (setq kitty-gfx--render-timer nil)
+ (kitty-gfx--refresh)))))
(defun kitty-gfx--on-window-change (_frame)
"Handle window configuration change for image refresh.
-Clears visible placements first since window layout changed."
- (kitty-gfx--sync-begin)
- (unwind-protect
- (progn
- (ignore-errors
- (send-string-to-terminal "\e_Ga=d,d=a,q=2\e\\"))
- ;; Reset position cache so images get re-placed
- (dolist (buf (buffer-list))
- (with-current-buffer buf
- (dolist (ov kitty-gfx--overlays)
- (when (overlay-buffer ov)
- (overlay-put ov 'kitty-gfx-last-row nil)
- (overlay-put ov 'kitty-gfx-last-col nil))))))
- (kitty-gfx--sync-end))
- (kitty-gfx--schedule-refresh))
+Invalidates position caches and cell pixel size so the refresh
+cycle re-places images correctly. Does NOT delete all placements
+— that causes visible flicker. Uses a longer debounce than normal
+refresh to let Emacs finish window layout transitions (e.g., when
+closing a split, Emacs briefly shows two windows for the same
+buffer before settling to one)."
+ (kitty-gfx--log "on-window-change: invalidating positions and cell size")
+ (setq kitty-gfx--cell-pixel-width nil
+ kitty-gfx--cell-pixel-height nil)
+ ;; Reset position cache so images get re-placed at correct positions.
+ (dolist (buf (buffer-list))
+ (with-current-buffer buf
+ (dolist (ov kitty-gfx--overlays)
+ (when (overlay-buffer ov)
+ (overlay-put ov 'kitty-gfx-last-row nil)
+ (overlay-put ov 'kitty-gfx-last-col nil)))))
+ ;; Longer debounce: cancel any fast leading-edge cooldown and
+ ;; schedule a 0.1s delayed refresh to let window layout settle.
+ (when kitty-gfx--render-timer
+ (cancel-timer kitty-gfx--render-timer))
+ (setq kitty-gfx--refresh-pending nil
+ kitty-gfx--render-timer
+ (run-at-time 0.1 nil
+ (lambda ()
+ (setq kitty-gfx--render-timer nil)
+ (kitty-gfx--refresh)))))
(defun kitty-gfx--on-redisplay ()
"Post-command hook to schedule image refresh."
@@ -433,11 +563,15 @@ Clears visible placements first since window layout changed."
(defun kitty-gfx--read-file-base64 (file)
"Read FILE and return base64-encoded string."
+ (kitty-gfx--log "read-file-base64: %s size=%s"
+ file (ignore-errors (file-attribute-size (file-attributes file))))
(with-temp-buffer
(set-buffer-multibyte nil)
(insert-file-contents-literally file)
(base64-encode-region (point-min) (point-max) t)
- (buffer-string)))
+ (let ((result (buffer-string)))
+ (kitty-gfx--log "read-file-base64: done b64-len=%d" (length result))
+ result)))
(defun kitty-gfx--image-pixel-size (file)
"Return (WIDTH . HEIGHT) in pixels for image FILE, or nil."
@@ -462,23 +596,38 @@ Clears visible placements first since window layout changed."
(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."
+Returns FILE unchanged if it is already PNG.
+Returns nil if FILE is not PNG and ImageMagick is unavailable or
+conversion fails — callers must handle nil gracefully."
(if (string-suffix-p ".png" file t)
- file
+ (progn
+ (kitty-gfx--log "convert-to-png: %s already PNG" file)
+ file)
(let ((convert (or (executable-find "magick")
(executable-find "convert"))))
(if (not convert)
- file ; no converter available, try sending as-is
+ (progn
+ (kitty-gfx--log "convert-to-png: no ImageMagick, cannot convert %s" file)
+ (message "kitty-gfx: %s requires ImageMagick for display"
+ (file-name-nondirectory file))
+ nil)
(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))))))
+ (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))))
+ (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)
+ (> (file-attribute-size (file-attributes out)) 0))
+ (progn
+ (kitty-gfx--log "convert-to-png: success out-size=%d"
+ (file-attribute-size (file-attributes out)))
+ out)
+ (kitty-gfx--log "convert-to-png: FAILED (empty or missing output)")
+ (ignore-errors (delete-file out))
+ nil)))))))
(defun kitty-gfx--compute-cell-dims (pixel-w pixel-h max-cols max-rows)
"Compute (COLS . ROWS) in terminal cells for image placement.
@@ -504,9 +653,54 @@ With direct placements, COLS and ROWS map directly to terminal columns/rows."
(let ((id kitty-gfx--next-id))
(setq kitty-gfx--next-id (1+ kitty-gfx--next-id))
(when (> kitty-gfx--next-id 4294967295)
+ (kitty-gfx--log "alloc-id: WRAP next-id reset to 1")
(setq kitty-gfx--next-id 1))
+ (kitty-gfx--log "alloc-id: %d" id)
id))
+(defun kitty-gfx--cache-touch (file)
+ "Move FILE to the front of the LRU list (most recently used)."
+ (setq kitty-gfx--cache-lru
+ (cons file (delete file kitty-gfx--cache-lru)))
+ (kitty-gfx--log "cache-touch: %s (lru-len=%d)" (file-name-nondirectory file)
+ (length kitty-gfx--cache-lru)))
+
+(defun kitty-gfx--cache-put (file image-id)
+ "Store IMAGE-ID for FILE in cache, evicting LRU entries if needed."
+ (kitty-gfx--log "cache-put: %s id=%d (cache-count=%d max=%d)"
+ (file-name-nondirectory file) image-id
+ (hash-table-count kitty-gfx--image-cache) kitty-gfx-cache-size)
+ ;; Evict oldest entries if cache is full
+ (while (and (> (hash-table-count kitty-gfx--image-cache)
+ (max 1 kitty-gfx-cache-size))
+ 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))
+ (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)"
+ (file-name-nondirectory victim) victim-id
+ (hash-table-count kitty-gfx--image-cache))))
+ (puthash file image-id kitty-gfx--image-cache)
+ (kitty-gfx--cache-touch file))
+
+(defun kitty-gfx--cache-get (file)
+ "Return cached image ID for FILE, or nil. Moves FILE to front of LRU."
+ (let ((id (gethash file kitty-gfx--image-cache)))
+ (kitty-gfx--log "cache-get: %s -> %s" (file-name-nondirectory file)
+ (if id (format "id=%d (hit)" id) "nil (miss)"))
+ (when id
+ (kitty-gfx--cache-touch file))
+ id))
+
+(defun kitty-gfx--cache-remove (file)
+ "Remove FILE from the cache and LRU list."
+ (kitty-gfx--log "cache-remove: %s" (file-name-nondirectory file))
+ (remhash file kitty-gfx--image-cache)
+ (setq kitty-gfx--cache-lru (delete file kitty-gfx--cache-lru)))
+
(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
@@ -531,27 +725,35 @@ fills with the image via direct placement."
;; Don't set evaporate — zero-width overlays (beg==end) would be
;; deleted immediately if evaporate is set.
(push ov kitty-gfx--overlays)
+ (kitty-gfx--log "make-overlay: id=%d pid=%d cols=%d rows=%d beg=%d end=%d buf=%s (total=%d)"
+ image-id pid cols rows beg end (buffer-name) (length 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."
+ (kitty-gfx--log "delete-placement: id=%d pid=%d" id pid)
(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)))
+ (let ((id (overlay-get ov 'kitty-gfx-id))
+ (pid (overlay-get ov 'kitty-gfx-pid)))
+ (kitty-gfx--log "remove-overlay: id=%s pid=%s buf=%s"
+ id pid (when (overlay-buffer ov) (buffer-name (overlay-buffer ov))))
+ (when (overlay-buffer ov)
+ (condition-case err
(when (and id pid)
- (kitty-gfx--delete-placement id pid)))
- (error nil))
- (delete-overlay ov))
- (setq kitty-gfx--overlays (delq ov kitty-gfx--overlays)))
+ (kitty-gfx--delete-placement id pid))
+ (error
+ (kitty-gfx--log "remove-overlay: error deleting placement: %s"
+ (error-message-string err))))
+ (delete-overlay ov))
+ (setq kitty-gfx--overlays (delq ov kitty-gfx--overlays))
+ (kitty-gfx--log "remove-overlay: done (remaining=%d)" (length kitty-gfx--overlays))))
;;;; Public API
@@ -565,34 +767,39 @@ BEG/END span the overlay region. MAX-COLS/MAX-ROWS limit size."
(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)))))))
+ (cached-id (kitty-gfx--cache-get abs-file))
+ (image-id (or cached-id (kitty-gfx--alloc-id)))
+ ;; Always compute dimensions fresh — they depend on max-cols/rows
+ ;; which vary by display context (org inline vs image-mode vs dired).
+ (dims (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"))
+ abs-file image-id cols rows start stop (if cached-id "yes" "no"))
;; Transmit image if not cached
- (unless cached
+ (unless cached-id
(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)))
+ (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))))))
+ ;; 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)
+ ;; Schedule initial render
+ (kitty-gfx--schedule-refresh))))
(defun kitty-gfx--display-image-centered (file max-cols max-rows
&optional win-cols win-rows
@@ -627,31 +834,40 @@ The buffer should be writable (caller handles `inhibit-read-only')."
(insert (make-string h-pad ?\s))
(let* ((img-start (point))
(_ (insert "\n"))
- ;; Ensure image is transmitted (use cache for data, not dims)
- (cached (gethash abs-file kitty-gfx--image-cache))
- (image-id (if cached (car cached) (kitty-gfx--alloc-id))))
- (unless cached
+ ;; Ensure image is transmitted (cache stores only the ID)
+ (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))
- (b64 (kitty-gfx--read-file-base64 png)))
- (kitty-gfx--transmit-image image-id b64)
- (puthash abs-file (cons image-id (cons img-cols img-rows))
- kitty-gfx--image-cache)
- (when (and png (not (string= png abs-file)))
- (delete-file png t))))
+ (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))))))
;; Create overlay at the scaled dimensions
- (kitty-gfx--make-overlay img-start (point) image-id img-cols img-rows)
- (kitty-gfx--schedule-refresh))))
+ (when (or cached-id (gethash abs-file kitty-gfx--image-cache))
+ (kitty-gfx--make-overlay img-start (point) image-id img-cols img-rows)
+ (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))))
+ (let ((count 0))
+ (dolist (ov (overlays-in (or beg (point-min)) (or end (point-max))))
+ (when (overlay-get ov 'kitty-gfx)
+ (cl-incf count)
+ (kitty-gfx--remove-overlay ov)))
+ (kitty-gfx--log "remove-images: removed %d overlays from %s" count (buffer-name))))
(defun kitty-gfx-clear-all ()
"Remove all images from all buffers and the terminal."
(interactive)
+ (kitty-gfx--log "clear-all: begin (cache=%d lru=%d)"
+ (hash-table-count kitty-gfx--image-cache) (length kitty-gfx--cache-lru))
;; Walk all buffers, not just current
(dolist (buf (buffer-list))
(with-current-buffer buf
@@ -659,8 +875,10 @@ The buffer should be writable (caller handles `inhibit-read-only')."
(kitty-gfx-remove-images))))
(kitty-gfx--delete-all-images)
(clrhash kitty-gfx--image-cache)
+ (setq kitty-gfx--cache-lru nil)
(setq kitty-gfx--next-id 1)
- (setq kitty-gfx--next-placement-id 1))
+ (setq kitty-gfx--next-placement-id 1)
+ (kitty-gfx--log "clear-all: done (reset IDs to 1)"))
;;;; Minor mode
@@ -672,49 +890,60 @@ The buffer should be writable (caller handles `inhibit-read-only')."
(if kitty-graphics-mode
(if (kitty-gfx--supported-p)
(progn
+ (kitty-gfx--log "mode: enabling")
(kitty-gfx--delete-all-images) ; 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--cell-pixel-width kitty-gfx--cell-pixel-height)
(message "Kitty graphics mode enabled"))
+ (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)))
+ (kitty-gfx--delete-all-images)
+ (when kitty-gfx--render-timer
+ (cancel-timer kitty-gfx--render-timer))
+ (setq kitty-gfx--render-timer nil
+ kitty-gfx--refresh-pending nil)
+ (kitty-gfx--log "mode: disabled")))
(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))
+ (add-hook 'post-command-hook #'kitty-gfx--on-redisplay)
+ (add-hook 'kill-buffer-hook #'kitty-gfx--kill-buffer-hook))
(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))
+ (remove-hook 'post-command-hook #'kitty-gfx--on-redisplay)
+ (remove-hook 'kill-buffer-hook #'kitty-gfx--kill-buffer-hook))
;;;; Org-mode integration
(defun kitty-gfx--on-org-cycle (&rest _args)
"Handle org visibility cycling.
-Deletes all current-buffer placements from the terminal, clears the
-position cache, then schedules a refresh that re-places only the
-overlays that are still visible (not inside a fold)."
+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)
(kitty-gfx--sync-begin)
(unwind-protect
(dolist (ov kitty-gfx--overlays)
(when (overlay-buffer ov)
- ;; Delete this overlay's terminal placement
(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)))
- ;; Clear position cache so visible images get re-placed
(overlay-put ov 'kitty-gfx-last-row nil)
(overlay-put ov 'kitty-gfx-last-col nil)))
(kitty-gfx--sync-end))
@@ -724,7 +953,7 @@ overlays that are still visible (not inside a fold)."
"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"
+ '("png" "jpg" "jpeg" "bmp" "svg"
"webp" "tiff" "tif")))))
(defun kitty-gfx--org-display-inline-images-tty (&optional _include-linked beg end)
@@ -733,6 +962,7 @@ 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))))
+ (kitty-gfx--log "org-display: scanning region %d..%d in %s" start stop (buffer-name))
(save-restriction
(widen)
(save-excursion
@@ -761,11 +991,15 @@ Scans for file:, attachment:, and relative path links."
(not (cl-some (lambda (ov)
(overlay-get ov 'kitty-gfx))
(overlays-in link-beg link-end))))
+ (kitty-gfx--log "org-display: found link %s at %d..%d"
+ file 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
+ (kitty-gfx--log "org-display: ERROR %s: %s"
+ file (error-message-string err))
(message "kitty-gfx: %s: %s"
file (error-message-string err))))))))))))))
@@ -773,22 +1007,27 @@ Scans for file:, attachment:, and relative path links."
(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)
+ (progn
+ (kitty-gfx--log "advice: org-display-inline-images (terminal path)")
+ (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--log "advice: org-remove-inline-images")
(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))
+ (let ((has-images (cl-some (lambda (ov) (overlay-get ov 'kitty-gfx))
+ (overlays-in (point-min) (point-max)))))
+ (kitty-gfx--log "advice: org-toggle has-images=%s" has-images)
+ (if has-images
+ (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
@@ -897,7 +1136,8 @@ Values > 1.0 zoom in, < 1.0 zoom out.")
(max-cols (min win-w kitty-gfx-max-width))
(max-rows (min win-h kitty-gfx-max-height)))
;; Remove existing images and clear buffer
- (kitty-gfx--log "image-mode-render: win-w=%d win-h=%d max-cols=%d max-rows=%d"
+ (kitty-gfx--log "image-mode-render: file=%s scale=%.2f win=%dx%d max=%dx%d"
+ (file-name-nondirectory file) kitty-gfx--image-scale
win-w win-h max-cols max-rows)
(kitty-gfx-remove-images)
(erase-buffer)
@@ -952,6 +1192,13 @@ Values > 1.0 zoom in, < 1.0 zoom out.")
(evil-local-set-key 'normal (kbd "0") #'kitty-gfx-image-reset-size)
(evil-local-set-key 'normal (kbd "q") #'kill-current-buffer))
(setq-local buffer-read-only t)
+ ;; Re-render when window size changes (e.g., split/unsplit)
+ ;; so centering and overflow checks use correct dimensions.
+ (add-hook 'window-size-change-functions
+ (lambda (_frame)
+ (when (eq major-mode 'kitty-gfx-image-mode)
+ (kitty-gfx--image-mode-render)))
+ nil t)
(kitty-gfx--image-mode-render)
(set-buffer-modified-p nil))
(apply orig-fn args)))
@@ -969,29 +1216,33 @@ We extract the :file or :data from the image properties."
(data (plist-get props :data))
(url (plist-get props :file))
(type (plist-get props :type)))
+ (kitty-gfx--log "shr-put-image: type=%s url=%s data-len=%s alt=%s"
+ type url (when data (length data)) alt)
(insert (or alt "[image]"))
(let ((end (point)))
- (condition-case err
- (let* ((suffix (cond
- ((eq type 'jpeg) ".jpg")
- ((eq type 'gif) ".gif")
- ((eq type 'webp) ".webp")
- ((eq type 'svg) ".svg")
- (t ".png")))
- (file (cond
- (url (when (file-exists-p url) url))
- (data
- (let ((tmp (make-temp-file "kitty-shr-" nil suffix)))
- (with-temp-file tmp
- (set-buffer-multibyte nil)
- (insert data))
- tmp))))
- (temp-p (and data file)))
- (when file
- (kitty-gfx-display-image file start end)
- (when temp-p (delete-file file t))))
- (error
- (kitty-gfx--log "shr-put-image error: %s" (error-message-string err))))))
+ (let* ((suffix (cond
+ ((eq type 'jpeg) ".jpg")
+ ((eq type 'gif) ".gif")
+ ((eq type 'webp) ".webp")
+ ((eq type 'svg) ".svg")
+ (t ".png")))
+ (file (cond
+ (url (when (file-exists-p url) url))
+ (data
+ (let ((tmp (make-temp-file "kitty-shr-" nil suffix)))
+ (with-temp-file tmp
+ (set-buffer-multibyte nil)
+ (insert data))
+ tmp))))
+ (temp-p (and data file)))
+ (unwind-protect
+ (condition-case err
+ (when file
+ (kitty-gfx-display-image file start end))
+ (error
+ (kitty-gfx--log "shr-put-image error: %s" (error-message-string err))))
+ (when temp-p
+ (ignore-errors (delete-file file)))))))
(apply orig-fn spec alt args)))
;;;; doc-view integration
@@ -1026,6 +1277,7 @@ Displays the page image via Kitty graphics instead of an Emacs
image spec. FILE is the path to the page PNG."
(if (and kitty-graphics-mode (not (display-graphic-p)))
(when (and file (file-exists-p file))
+ (kitty-gfx--log "doc-view-insert: file=%s scale=%.2f" file kitty-gfx--doc-view-scale)
;; Remember current file for zoom commands
(setq kitty-gfx--doc-view-current-file file)
;; Remove previous page image
@@ -1076,6 +1328,7 @@ Press `q' in the preview buffer to close it."
(unless (derived-mode-p 'dired-mode)
(user-error "Not in a dired buffer"))
(let ((file (dired-get-file-for-visit)))
+ (kitty-gfx--log "dired-preview: %s" file)
(unless (kitty-gfx--image-file-p file)
(user-error "Not an image file"))
(let* ((buf-name (format "*kitty-preview: %s*" (file-name-nondirectory file)))
@@ -1118,6 +1371,7 @@ 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--log "dirvish-preview: %s" file)
(let* ((buf-name (format " *kitty-dirvish: %s*" (file-name-nondirectory file)))
(buf (get-buffer-create buf-name))
(max-cols (min (- (window-width preview-window) 2) kitty-gfx-max-width))
@@ -1234,17 +1488,55 @@ Registers `kitty-image' dispatcher and prepends it to the dispatcher list."
;;;; Buffer cleanup
+(defun kitty-gfx--image-id-in-other-buffers-p (id &optional exclude-buf)
+ "Non-nil if image ID is used by overlays in buffers other than EXCLUDE-BUF.
+EXCLUDE-BUF defaults to the current buffer."
+ (let ((skip (or exclude-buf (current-buffer)))
+ (found nil))
+ (dolist (buf (buffer-list))
+ (unless (or found (eq buf skip))
+ (with-current-buffer buf
+ (dolist (ov kitty-gfx--overlays)
+ (when (and (not found)
+ (overlay-buffer ov)
+ (eql (overlay-get ov 'kitty-gfx-id) id))
+ (setq found t))))))
+ found))
+
(defun kitty-gfx--kill-buffer-hook ()
- "Clean up images when buffer is killed."
+ "Clean up images when buffer is killed.
+Deletes terminal-side placements for this buffer's overlays.
+Only deletes terminal-side image data (and cache entries) if no
+other buffer has overlays referencing the same image ID — this
+prevents breaking shared images (e.g., same file open in org-mode
+and image-mode simultaneously)."
(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)
+ (kitty-gfx--log "kill-buffer-hook: buf=%s overlays=%d" (buffer-name) (length kitty-gfx--overlays))
+ (let ((deleted-ids nil))
+ (dolist (ov kitty-gfx--overlays)
+ (condition-case nil
+ (let ((id (overlay-get ov 'kitty-gfx-id))
+ (pid (overlay-get ov 'kitty-gfx-pid)))
+ (when (and id pid)
+ ;; Always delete the placement (it's buffer-specific)
+ (kitty-gfx--delete-placement 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)
+ (maphash (lambda (file id)
+ (when (memq id deleted-ids)
+ (kitty-gfx--cache-remove file)))
+ (copy-hash-table kitty-gfx--image-cache)))
+ (setq kitty-gfx--overlays nil)
+ (kitty-gfx--log "kill-buffer-hook: done (cache-count=%d)"
+ (hash-table-count kitty-gfx--image-cache)))))
(provide 'kitty-graphics)
;; Local Variables: