summaryrefslogtreecommitdiff
path: root/kitty-graphics.el
diff options
context:
space:
mode:
Diffstat (limited to 'kitty-graphics.el')
-rw-r--r--kitty-graphics.el199
1 files changed, 173 insertions, 26 deletions
diff --git a/kitty-graphics.el b/kitty-graphics.el
index 84f5cce..223b436 100644
--- a/kitty-graphics.el
+++ b/kitty-graphics.el
@@ -103,6 +103,10 @@ This debounces rapid redisplay events. Default is ~1 frame at 60fps."
(defvar kitty-gfx--log-file "/tmp/kitty-gfx.log"
"File path for debug log output.")
+(defvar kitty-gfx--dry-run nil
+ "When non-nil, log escape sequences instead of sending to terminal.
+Useful for debugging and batch testing without a real terminal.")
+
(defun kitty-gfx--log (fmt &rest args)
"Log to `kitty-gfx--log-file' and *kitty-gfx-debug* buffer when debug is on."
(when kitty-gfx-debug
@@ -120,6 +124,13 @@ This debounces rapid redisplay events. Default is ~1 frame at 60fps."
(forward-line 500)
(delete-region (point-min) (point)))))))))
+(defun kitty-gfx--terminal-send (str)
+ "Send STR to terminal, or log it in dry-run mode.
+All terminal escape output should go through this function."
+ (if kitty-gfx--dry-run
+ (kitty-gfx--log "DRY-RUN-SEND: %S" str)
+ (ignore-errors (send-string-to-terminal str))))
+
;;;; Constants — kept for reference if switching back to Unicode placeholders
;; (defconst kitty-gfx--placeholder-char #x10EEEE)
;; (defconst kitty-gfx--diacritics [...])
@@ -163,6 +174,29 @@ Default is `auto'."
(const :tag "Sixel protocol" sixel))
:group 'kitty-graphics)
+(defcustom kitty-gfx-heading-scales '((1 . 2.0) (2 . 1.5) (3 . 1.2))
+ "Alist mapping org heading level to visual scale factor.
+Headings at levels not listed use scale 1.0 (normal size).
+Requires Kitty >= 0.40.0 (text sizing protocol, OSC 66).
+
+Scale values are floats: 2.0 = double size, 1.5 = 50% larger.
+Any scale > 1.0 occupies 2+ terminal rows (multicell block).
+Maximum scale is 7.0 (limited by protocol)."
+ :type '(alist :key-type (integer :tag "Heading level")
+ :value-type (choice (const :tag "2.0x (double)" 2.0)
+ (const :tag "1.5x" 1.5)
+ (const :tag "1.2x" 1.2)
+ (number :tag "Custom (1.0-7.0)")))
+ :group 'kitty-graphics)
+
+(defcustom kitty-gfx-heading-sizes-auto nil
+ "When non-nil, automatically apply heading sizes in org buffers.
+Heading sizes are applied when `org-mode' is activated and
+`kitty-graphics-mode' is enabled. Set to nil to require manual
+activation via `kitty-gfx-org-heading-sizes'."
+ :type 'boolean
+ :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
@@ -192,6 +226,13 @@ Most recently used at the front.")
Each overlay gets a unique PID so repeated placements replace
rather than accumulate.")
+(defvar kitty-gfx--text-sizing-support nil
+ "Cached text sizing protocol support level.
+nil means not yet queried. Possible values after query:
+ `scale' -- full support (s= and w= both work, Kitty >= 0.40.0)
+ `width' -- width-only support (w= works, s= does not)
+ `none' -- no support (terminal ignores OSC 66 entirely)")
+
;;;; Terminal detection
(defun kitty-gfx--backend-fn (op)
@@ -286,13 +327,13 @@ Falls back to reasonable defaults if query fails or times out."
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")))
+ (kitty-gfx--terminal-send "\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")))
+ (kitty-gfx--terminal-send "\e[?2026l"))
;;;; Protocol layer
@@ -315,8 +356,7 @@ 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))))
- (ignore-errors
- (send-string-to-terminal (format "\e_G%s;%s\e\\" ctrl chunk)))
+ (kitty-gfx--terminal-send (format "\e_G%s;%s\e\\" ctrl chunk))
(cl-incf chunk-count)
(setq offset end
first nil)))
@@ -325,14 +365,12 @@ with `kitty-gfx--place-image'."
(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))))
+ (kitty-gfx--terminal-send (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\\")))
+ (kitty-gfx--terminal-send "\e_Ga=d,d=A,q=2\e\\"))
;;;; Direct placement (the core rendering mechanism)
@@ -353,10 +391,9 @@ 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)
- (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))))
+ (kitty-gfx--terminal-send
+ (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
@@ -510,9 +547,8 @@ Encodes on-demand if not cached, then emits DCS sequence."
(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)))))))
+ (kitty-gfx--terminal-send
+ (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.
@@ -524,14 +560,13 @@ Sixel has no placement IDs — erase by writing spaces over the region."
(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))
- "")))))))
+ (kitty-gfx--terminal-send
+ (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."
@@ -570,6 +605,119 @@ Sixel has no placement IDs — erase by writing spaces over the region."
;; Cleanup temp files on exit
(add-hook 'kill-emacs-hook #'kitty-gfx--sixel-cleanup-all)
+;;;; Text sizing protocol (OSC 66)
+
+(defun kitty-gfx--decompose-scale (scale)
+ "Decompose SCALE (float) into OSC 66 parameters (s n d).
+Returns (CELL-S FRAC-N FRAC-D) where:
+ CELL-S is the integer cell scale (1-7, the s= parameter)
+ FRAC-N is the fractional numerator (0-15, the n= parameter)
+ FRAC-D is the fractional denominator (0-15, the d= parameter)
+
+Examples:
+ 1.0 -> (1 0 0) -- no scaling
+ 2.0 -> (2 0 0) -- double size, 2-row block
+ 1.5 -> (2 3 4) -- 2-row block, 3/4 fractional fill
+ 1.2 -> (2 3 5) -- 2-row block, 3/5 fractional fill
+ 3.0 -> (3 0 0) -- triple size, 3-row block"
+ (let* ((clamped (max 1.0 (min 7.0 (float scale))))
+ (s (max 1 (min 7 (ceiling clamped))))
+ (ratio (/ clamped s)))
+ (if (<= (abs (- ratio 1.0)) 0.01)
+ ;; Close enough to 1.0 -- no fractional part needed
+ (list s 0 0)
+ (let ((best-n 0)
+ (best-d 0)
+ (best-err 1.0))
+ (cl-loop for d from 2 to 15
+ for n = (round (* ratio d))
+ when (and (> n 0) (< n d) (<= n 15))
+ do (let ((err (abs (- (/ (float n) d) ratio))))
+ (when (< err best-err)
+ (setq best-n n best-d d best-err err))))
+ (list s best-n best-d)))))
+
+(defun kitty-gfx--validate-osc66 (s n d text)
+ "Return non-nil if OSC 66 parameters are valid per protocol spec.
+S is cell scale (1-7), N is fractional numerator (0-15),
+D is fractional denominator (0-15, must be > N when non-zero),
+TEXT is the string payload (max 4096 bytes UTF-8)."
+ (and (<= 1 s 7)
+ (<= 0 n 15)
+ (<= 0 d 15)
+ (or (zerop d) (> d n))
+ (<= (length (encode-coding-string text 'utf-8)) 4096)))
+
+(defun kitty-gfx--heading-sgr (level)
+ "Return SGR escape string for org heading at LEVEL.
+Applies bold + 24-bit foreground color from org-level-N face.
+Falls back to bold-only when color is unavailable."
+ (let* ((face (intern (format "org-level-%d" (min level 8))))
+ (fg (face-attribute face :foreground nil t))
+ (color (when (and (stringp fg)
+ (not (string-prefix-p "unspecified" fg)))
+ (color-values fg))))
+ (if color
+ (format "\e[1;38;2;%d;%d;%dm"
+ (/ (nth 0 color) 256)
+ (/ (nth 1 color) 256)
+ (/ (nth 2 color) 256))
+ "\e[1m")))
+
+(defun kitty-gfx-run-self-tests ()
+ "Run batch-safe self-tests for kitty-graphics.
+Tests pure logic functions that don't require a terminal.
+Signals error on failure, prints success message otherwise."
+ (interactive)
+ ;; decompose-scale: identity
+ (cl-assert (equal (kitty-gfx--decompose-scale 1.0) '(1 0 0))
+ nil "decompose 1.0 failed")
+ ;; decompose-scale: integer scales
+ (cl-assert (equal (kitty-gfx--decompose-scale 2.0) '(2 0 0))
+ nil "decompose 2.0 failed")
+ (cl-assert (equal (kitty-gfx--decompose-scale 3.0) '(3 0 0))
+ nil "decompose 3.0 failed")
+ ;; decompose-scale: fractional scales produce valid params
+ (let ((r15 (kitty-gfx--decompose-scale 1.5)))
+ (cl-assert (= (nth 0 r15) 2) nil "1.5 cell-s should be 2")
+ (cl-assert (> (nth 1 r15) 0) nil "1.5 should have fractional n")
+ (cl-assert (> (nth 2 r15) (nth 1 r15)) nil "1.5: d must be > n"))
+ (let ((r12 (kitty-gfx--decompose-scale 1.2)))
+ (cl-assert (= (nth 0 r12) 2) nil "1.2 cell-s should be 2")
+ (cl-assert (> (nth 1 r12) 0) nil "1.2 should have fractional n")
+ (cl-assert (> (nth 2 r12) (nth 1 r12)) nil "1.2: d must be > n"))
+ ;; decompose-scale: clamping
+ (cl-assert (= (nth 0 (kitty-gfx--decompose-scale 0.5)) 1)
+ nil "scale < 1.0 should clamp cell-s to 1")
+ (cl-assert (= (nth 0 (kitty-gfx--decompose-scale 10.0)) 7)
+ nil "scale > 7.0 should clamp cell-s to 7")
+ ;; validate-osc66: valid params
+ (cl-assert (kitty-gfx--validate-osc66 2 3 4 "hello")
+ nil "valid params should pass")
+ (cl-assert (kitty-gfx--validate-osc66 1 0 0 "test")
+ nil "no-fraction params should pass")
+ ;; validate-osc66: invalid params
+ (cl-assert (not (kitty-gfx--validate-osc66 0 0 0 "x"))
+ nil "s=0 should fail")
+ (cl-assert (not (kitty-gfx--validate-osc66 8 0 0 "x"))
+ nil "s=8 should fail")
+ (cl-assert (not (kitty-gfx--validate-osc66 2 5 3 "x"))
+ nil "n > d should fail")
+ (cl-assert (not (kitty-gfx--validate-osc66 2 5 5 "x"))
+ nil "n = d should fail")
+ ;; validate-osc66: text length
+ (cl-assert (kitty-gfx--validate-osc66 1 0 0 (make-string 4096 ?a))
+ nil "4096 bytes should pass")
+ (cl-assert (not (kitty-gfx--validate-osc66 1 0 0 (make-string 4097 ?a)))
+ nil "4097 bytes should fail")
+ ;; All decomposed scales should validate
+ (dolist (scale '(1.0 1.2 1.5 2.0 2.5 3.0 4.0 5.0 6.0 7.0))
+ (let ((params (kitty-gfx--decompose-scale scale)))
+ (cl-assert (kitty-gfx--validate-osc66
+ (nth 0 params) (nth 1 params) (nth 2 params) "test")
+ nil (format "decomposed scale %.1f should validate" scale))))
+ (message "kitty-gfx: all self-tests passed"))
+
;;;; Position mapping
(defun kitty-gfx--in-folded-region-p (pos)
@@ -1020,9 +1168,8 @@ delete step, avoiding visual glitches in some terminals."
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))))
+ (kitty-gfx--terminal-send
+ (format "\e_Ga=d,d=i,i=%d,p=%d,q=2\e\\" id pid)))
(defun kitty-gfx--remove-overlay (ov &optional keep-placement)
"Remove overlay OV and delete its placement from terminal.