diff options
| author | cashmere <cashmere@autistici.org> | 2026-04-02 23:37:13 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-04-02 23:37:13 +0200 |
| commit | dd1e18c9b1a06e6eb0f1c8aef94871f9c9cf2889 (patch) | |
| tree | ceae6e83bef0ad0e46aec2ef483bc20f0dfb8d4f | |
| parent | 7691a94d26190324d3f7b6ff22068fa4fc57c121 (diff) | |
| download | kitty-graphics-dd1e18c9b1a06e6eb0f1c8aef94871f9c9cf2889.tar.gz | |
fix: text sizing rendering — column guard, CPU optimization, preview mode
Remove hardcoded column-1 guard that broke heading placement with
display-line-numbers-mode and org-indent-mode. Headings were never
placed (placed=0 hidden=37) because posn-at-point returns terminal
columns offset by body-left, not buffer column 1.
CPU optimizations reduce position queries by 86% and heading
placements by 76%:
- Early window-range exit skips posn-at-point for off-screen overlays
- Emitted flag prevents redundant OSC 66 re-emission
- Single fold-check instead of duplicate calls
- Strip text properties to prevent org-modern payload corruption
Add preview mode that temporarily disables conflicting minor modes
(org-modern, org-appear, org-indent, visual-line, olivetti) and
unfolds all headings for clean OSC 66 rendering. Modes are restored
when heading sizes are toggled off.
Add row-based collision detection to prevent overlapping multicell
blocks when adjacent headings occupy the same terminal rows.
Deferred display property shows heading text as fallback until first
successful OSC 66 emission, preventing invisible headings.
| -rw-r--r-- | kitty-graphics.el | 184 |
1 files changed, 132 insertions, 52 deletions
diff --git a/kitty-graphics.el b/kitty-graphics.el index f5006fb..f14ed9a 100644 --- a/kitty-graphics.el +++ b/kitty-graphics.el @@ -782,7 +782,10 @@ Pre-erases the target area using ECH before emitting, preventing artifacts from partial overwrites (adapted from mdfried's pattern). Sequence: save-cursor, erase-area, move-to-position, SGR-color, OSC-66-payload, SGR-reset, restore-cursor." - (let* ((text (overlay-get ov 'kitty-gfx-heading-text)) + (let* ((raw-text (overlay-get ov 'kitty-gfx-heading-text)) + ;; Strip text properties — org-modern, font-lock, etc. can + ;; attach display/face properties that corrupt OSC 66 payload. + (text (substring-no-properties raw-text)) (cell-s (overlay-get ov 'kitty-gfx-heading-cell-s)) (frac-n (overlay-get ov 'kitty-gfx-heading-frac-n)) (frac-d (overlay-get ov 'kitty-gfx-heading-frac-d)) @@ -805,7 +808,18 @@ OSC-66-payload, SGR-reset, restore-cursor." ;; Emit OSC 66 at the target position (kitty-gfx--terminal-send (format "\e7\e[%d;%dH%s\e]66;%s;%s\a\e[0m\e8" - row col sgr meta text)))) + row col sgr meta text)) + ;; After successful emission, switch display to spaces so Emacs + ;; incremental redraws don't overwrite the multicell block. + ;; Before first emission the overlay shows the plain heading text + ;; as a visible fallback (set in kitty-gfx--make-heading-overlay). + ;; Mark as emitted so subsequent refresh cycles skip re-emission. + ;; Cleared when heading moves, is erased, or becomes hidden. + (overlay-put ov 'kitty-gfx-heading-emitted t) + (let ((beg (overlay-start ov)) + (end (overlay-end ov))) + (when (and beg end) + (overlay-put ov 'display (make-string (- end beg) ?\s)))))) (defun kitty-gfx--erase-heading-at (row col cols rows) "Erase a heading multicell block at ROW, COL spanning COLS x ROWS cells. @@ -828,7 +842,9 @@ Adapted from mdfried's erase-character dance." ""))))) (defun kitty-gfx--erase-heading (ov) - "Erase the multicell block of heading overlay OV at its cached position." + "Erase the multicell block of heading overlay OV at its cached position. +Also restores the display property to the plain heading text so the +heading is visible again (instead of spaces that hid it for OSC 66)." (let ((row (overlay-get ov 'kitty-gfx-last-row)) (col (overlay-get ov 'kitty-gfx-last-col)) (cols (overlay-get ov 'kitty-gfx-cols)) @@ -837,7 +853,17 @@ Adapted from mdfried's erase-character dance." (kitty-gfx--log "erase-heading: L%d row=%d col=%d cols=%d rows=%d" (overlay-get ov 'kitty-gfx-heading-level) row col (or cols 0) (or rows 0)) - (kitty-gfx--erase-heading-at row col (or cols 0) (or rows 1))))) + (kitty-gfx--erase-heading-at row col (or cols 0) (or rows 1)) + ;; Restore display to plain text so the heading is readable + ;; until the next OSC 66 emission switches it back to spaces. + ;; Clear emitted flag so next refresh re-emits if heading + ;; becomes visible again. + (overlay-put ov 'kitty-gfx-heading-emitted nil) + ;; Restore display to plain text so the heading is readable + ;; until the next OSC 66 emission switches it back to spaces. + (let ((text (overlay-get ov 'kitty-gfx-heading-text))) + (when text + (overlay-put ov 'display (substring-no-properties text))))))) (defun kitty-gfx-run-self-tests () "Run batch-safe self-tests for kitty-graphics. @@ -984,13 +1010,15 @@ Does NOT emit any OSC 66 -- that happens during refresh." (overlay-put ov 'kitty-gfx-heading-level level) (overlay-put ov 'kitty-gfx-cols cols) (overlay-put ov 'kitty-gfx-rows rows) - ;; Replace the heading text with spaces via `display' property. - ;; Emacs draws these spaces on the heading's terminal cells. - ;; On incremental redraws, Emacs sees "still spaces" and skips - ;; the redraw, so our OSC 66 multicell blocks survive. - ;; Without this, Emacs redraws the heading markup which - ;; destroys multicell blocks via overwrite Rule 3. - (overlay-put ov 'display (make-string (- end beg) ?\s)) + ;; Show the plain heading text initially — no spaces yet. + ;; The `display' property strips org markup (stars, links) so + ;; the heading is readable even before OSC 66 renders. + ;; After the first successful OSC 66 emission, + ;; `kitty-gfx--place-heading' switches this to spaces so that + ;; Emacs incremental redraws don't destroy the multicell block + ;; (overwrite Rule 3). This deferred approach prevents the + ;; "invisible heading" failure mode where OSC 66 never fires. + (overlay-put ov 'display text) ;; Reserve vertical space: the cell block is `cell-s' rows tall, ;; so we add (cell-s - 1) lines after the heading line. ;; Each line is filled with spaces so Emacs actively draws them @@ -1076,10 +1104,43 @@ a kitty-gfx heading overlay." (cl-incf count))) (kitty-gfx--log "remove-heading-sizes: removed %d" count))) +(defvar-local kitty-gfx--heading-saved-modes nil + "Alist of (MODE . WAS-ACTIVE) saved when heading sizes are enabled. +Used to restore conflicting minor modes when heading sizes are disabled.") + +(defun kitty-gfx--heading-disable-conflicting () + "Disable minor modes that conflict with OSC 66 heading rendering. +Saves their state for restoration by `kitty-gfx--heading-restore-modes'. +Conflicts: org-modern (overlay display props), org-appear (emphasis toggling), +org-indent (virtual indentation), visual-line-mode (line wrapping), +olivetti-mode (window margins shift position calculations)." + (setq kitty-gfx--heading-saved-modes nil) + (dolist (mode '(org-modern-mode org-appear-mode org-indent-mode visual-line-mode olivetti-mode)) + (when (and (boundp mode) (symbol-value mode)) + (push (cons mode t) kitty-gfx--heading-saved-modes) + (funcall mode -1) + (kitty-gfx--log "heading-preview: disabled %s" mode))) + ;; Unfold all headings so positions are stable + (when (fboundp 'org-fold-show-all) + (org-fold-show-all)) + (when kitty-gfx--heading-saved-modes + (kitty-gfx--log "heading-preview: saved modes=%S" kitty-gfx--heading-saved-modes))) + +(defun kitty-gfx--heading-restore-modes () + "Restore minor modes that were disabled for heading rendering." + (dolist (entry kitty-gfx--heading-saved-modes) + (when (cdr entry) + (funcall (car entry) 1) + (kitty-gfx--log "heading-preview: restored %s" (car entry)))) + (setq kitty-gfx--heading-saved-modes nil)) + ;;;###autoload (defun kitty-gfx-org-heading-sizes (&optional arg) "Toggle scaled heading sizes in the current org buffer. -With prefix ARG, remove heading sizes." +Enters a clean preview mode: conflicting minor modes (org-modern, +org-appear, org-indent, visual-line-mode) are temporarily disabled +and headings are unfolded. Toggling off restores previous state. +With prefix ARG, force remove heading sizes." (interactive "P") (unless (derived-mode-p 'org-mode) (user-error "Not an org-mode buffer")) @@ -1089,9 +1150,11 @@ With prefix ARG, remove heading sizes." (overlays-in (point-min) (point-max)))) (progn (kitty-gfx--org-remove-heading-sizes) - (message "Heading sizes removed")) + (kitty-gfx--heading-restore-modes) + (message "Heading sizes removed, modes restored")) + (kitty-gfx--heading-disable-conflicting) (kitty-gfx--org-apply-heading-sizes) - (message "Heading sizes applied"))) + (message "Heading sizes applied (preview mode — conflicting modes disabled)"))) ;;;; Position mapping @@ -1117,25 +1180,20 @@ Ignores cosmetic invisibility like hidden link brackets (`org-link')." (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. -Returns nil when the overlay position is inside a folded region -\(e.g., a collapsed org heading), even if the position is within -the window's scroll range." +Returns nil when the overlay position is outside the visible window +range, inside a folded region, or not visible on screen." (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)))) + ;; Fast path: skip entirely if no window, no position, or + ;; buffer position is outside the visible window range. + ;; This avoids expensive posn-at-point and fold checks. (when (and win pos + (<= (window-start win) pos) + (<= pos (window-end win t)) (pos-visible-in-window-p pos win) - ;; Check that the text isn't hidden by structural folding - ;; (outline, org-fold, etc.) but allow cosmetic invisibility - ;; like org-link bracket hiding. + ;; Check structural folding (outline, org-fold). + ;; Single check — result used for both log and gate. (not (kitty-gfx--in-folded-region-p pos))) ;; posn-col-row returns coordinates relative to the window BODY ;; (text area). Use window-body-edges to convert to frame coords. @@ -1162,22 +1220,43 @@ the window's scroll range." ;;;; Refresh cycle (defun kitty-gfx--emit-heading-overlays () - "Phase 2: emit OSC 66 for all visible heading overlays. -Walks all buffers that have overlays and emits `kitty-gfx--place-heading' -for each heading overlay with a cached position. Called after phase 1 -\(position computation) to avoid posn-at-point triggering redraws that -destroy freshly-placed multicell blocks. - -Headings are stateless — the terminal has no memory of them, so we -must re-emit on every refresh cycle, unlike images which use -placement IDs for idempotent re-placement." + "Phase 2: emit OSC 66 for visible heading overlays that need it. +Skips headings already emitted at their current position and +detects row collisions — if two headings would occupy overlapping +terminal rows, the later one is skipped to prevent multicell +block corruption." (dolist (buf (buffer-list)) (when (buffer-live-p buf) (with-current-buffer buf - (dolist (ov kitty-gfx--overlays) - (when (and (overlay-get ov 'kitty-gfx-heading) - (overlay-get ov 'kitty-gfx-last-row)) - (kitty-gfx--place-heading ov))))))) + (let ((occupied (make-hash-table :test 'eql))) + ;; First pass: mark rows occupied by already-emitted headings + (dolist (ov kitty-gfx--overlays) + (when (and (overlay-get ov 'kitty-gfx-heading) + (overlay-get ov 'kitty-gfx-heading-emitted) + (overlay-get ov 'kitty-gfx-last-row)) + (let ((row (overlay-get ov 'kitty-gfx-last-row)) + (rows (or (overlay-get ov 'kitty-gfx-rows) 1))) + (dotimes (r rows) + (puthash (+ row r) t occupied))))) + ;; Second pass: emit new headings, checking for row conflicts + (dolist (ov kitty-gfx--overlays) + (when (and (overlay-get ov 'kitty-gfx-heading) + (overlay-get ov 'kitty-gfx-last-row) + (not (overlay-get ov 'kitty-gfx-heading-emitted))) + (let* ((row (overlay-get ov 'kitty-gfx-last-row)) + (rows (or (overlay-get ov 'kitty-gfx-rows) 1)) + (conflict nil)) + ;; Check if any target row is already occupied + (dotimes (r rows) + (when (gethash (+ row r) occupied) + (setq conflict t))) + (if conflict + (kitty-gfx--log "emit-heading: SKIP L%d row=%d (row conflict)" + (overlay-get ov 'kitty-gfx-heading-level) row) + ;; No conflict — place and mark rows + (dotimes (r rows) + (puthash (+ row r) t occupied)) + (kitty-gfx--place-heading ov)))))))))) (defun kitty-gfx--refresh () "Re-place all visible images after redisplay using direct placements. @@ -1292,15 +1371,8 @@ avoid posn-at-point redraws destroying freshly-placed blocks." (last-col (overlay-get ov 'kitty-gfx-last-col))) (if (and pos (<= (car pos) win-bottom) - (<= (+ (car pos) rows -1) win-bottom) - ;; Guard: org headings always start at column 1 (buffer - ;; line beginning). If posn-at-point reports col > 1, - ;; the heading is visually mid-line due to org fold - ;; collapsing newlines between headings (S-TAB overview - ;; mode). Skip rendering — placing a scaled heading - ;; mid-line overlaps with fold ellipsis and other headings. - (= (cdr pos) 1)) - ;; Visible at column 1 — erase old position if moved, cache new + (<= (+ (car pos) rows -1) win-bottom)) + ;; Visible — erase old position if moved, cache new (let ((new-row (car pos)) (new-col (cdr pos))) (when (and last-row @@ -1953,11 +2025,19 @@ The buffer should be writable (caller handles `inhibit-read-only')." (defun kitty-gfx--org-mode-heading-hook () "Org-mode hook to auto-apply heading sizes. Only activates when `kitty-gfx-heading-sizes-auto' is set and -the terminal supports text sizing." +the terminal supports text sizing. Enters preview mode by +disabling conflicting minor modes." (when (and kitty-graphics-mode (eq kitty-gfx--text-sizing-support 'scale) (not (display-graphic-p))) - (kitty-gfx--org-apply-heading-sizes))) + ;; Use run-at-time 0 so conflicting modes have finished + ;; their own org-mode-hook setup before we disable them. + (run-at-time 0 nil + (lambda () + (when (buffer-live-p (current-buffer)) + (with-current-buffer (current-buffer) + (kitty-gfx--heading-disable-conflicting) + (kitty-gfx--org-apply-heading-sizes))))))) (defun kitty-gfx--nuke-headings () "Erase all heading multicell blocks in the current buffer. |
