Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions markdown-overlays-tables-tests.el
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,75 @@
(should (memq 'bold (ensure-list face)))
(should (member '(:strike-through t) (ensure-list face))))))

;;; Grapheme width and modern TTY detection

(ert-deftest markdown-overlays-tables-test-grapheme-width-ascii ()
"ASCII characters are one cell."
(should (= (markdown-overlays--grapheme-width "a") 1)))

(ert-deftest markdown-overlays-tables-test-grapheme-width-zwj ()
"ZWJ-joined sequences are forced to two cells."
(should (= (markdown-overlays--grapheme-width "\N{WOMAN}\N{ZERO WIDTH JOINER}\N{ROCKET}") 2)))

(ert-deftest markdown-overlays-tables-test-grapheme-width-vs16 ()
"Sequences containing VS-16 (emoji presentation) are forced to two cells."
(should (= (markdown-overlays--grapheme-width "\N{HEAVY BLACK HEART}\N{VARIATION SELECTOR-16}") 2)))

(ert-deftest markdown-overlays-tables-test-grapheme-width-skin-tone ()
"Skin-tone modifiers are forced to two cells."
(should (= (markdown-overlays--grapheme-width "\N{THUMBS UP SIGN}\N{EMOJI MODIFIER FITZPATRICK TYPE-4}") 2)))

(ert-deftest markdown-overlays-tables-test-grapheme-width-capped-at-two ()
"Wide characters without modifiers are still capped at two cells."
(should (= (markdown-overlays--grapheme-width "\N{GRINNING FACE}") 2)))

(defmacro markdown-overlays-tables-tests--with-term-program (value &rest body)
"Run BODY with TERM_PROGRAM set to VALUE and the modern-tty frame cache cleared."
(declare (indent 1))
`(let ((process-environment (cons (concat "TERM_PROGRAM=" (or ,value ""))
process-environment))
(frame (selected-frame)))
(unwind-protect
(progn
(set-frame-parameter frame 'markdown-overlays-modern-tty nil)
,@body)
(set-frame-parameter frame 'markdown-overlays-modern-tty nil))))

(ert-deftest markdown-overlays-tables-test-modern-tty-allowlist ()
"Known modern terminals are detected, with case-insensitive matching."
(dolist (term '("ghostty" "WezTerm" "kitty" "iTerm.app"))
(markdown-overlays-tables-tests--with-term-program term
(should (markdown-overlays--modern-tty-p)))))

(ert-deftest markdown-overlays-tables-test-modern-tty-denies-apple-terminal ()
"macOS Terminal is not classified as modern."
(markdown-overlays-tables-tests--with-term-program "Apple_Terminal"
(should-not (markdown-overlays--modern-tty-p))))

(ert-deftest markdown-overlays-tables-test-modern-tty-denies-unset ()
"Missing TERM_PROGRAM is not classified as modern."
(let ((process-environment (seq-remove (lambda (e) (string-prefix-p "TERM_PROGRAM=" e))
process-environment))
(frame (selected-frame)))
(unwind-protect
(progn
(set-frame-parameter frame 'markdown-overlays-modern-tty nil)
(should-not (markdown-overlays--modern-tty-p)))
(set-frame-parameter frame 'markdown-overlays-modern-tty nil))))

(ert-deftest markdown-overlays-tables-test-modern-tty-frame-override ()
"Frame parameter override wins over environment."
(let ((frame (selected-frame)))
(unwind-protect
(progn
(let ((process-environment (cons "TERM_PROGRAM=Apple_Terminal" process-environment)))
(set-frame-parameter frame 'markdown-overlays-modern-tty 'yes)
(should (markdown-overlays--modern-tty-p)))
(let ((process-environment (cons "TERM_PROGRAM=ghostty" process-environment)))
(set-frame-parameter frame 'markdown-overlays-modern-tty 'no)
(should-not (markdown-overlays--modern-tty-p))))
(set-frame-parameter frame 'markdown-overlays-modern-tty nil))))

(provide 'markdown-overlays-tables-tests)

;;; markdown-overlays-tables-tests.el ends here
46 changes: 41 additions & 5 deletions markdown-overlays-tables.el
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,22 @@ and any other characters that render taller than the default."
(defvar-local markdown-overlays--table-char-pixel-width-cache nil
"Cons cell (FONT-WIDTH . SPACE-WIDTH) caching real pixel width of a space.")

(defun markdown-overlays--modern-tty-p (&optional frame)
"Return non-nil if FRAME is a modern TTY (Ghostty, WezTerm, Kitty).
These terminals shape ZWJ sequences into single cells, whereas Emacs
defaults assume standard legacy behavior (e.g. macOS Terminal)."
(let* ((f (or frame (selected-frame)))
(val (frame-parameter f 'markdown-overlays-modern-tty)))
(if (memq val '(yes no))
(eq val 'yes)
(let* ((term-prog (or (getenv "TERM_PROGRAM" f)
(getenv "TERM_PROGRAM")))
(is-modern (and term-prog
(member (downcase term-prog)
'("ghostty" "wezterm" "kitty" "iterm.app")))))
(set-frame-parameter f 'markdown-overlays-modern-tty (if is-modern 'yes 'no))
is-modern))))

(defun markdown-overlays--table-measure-string (str window)
"Return real pixel width of STR if rendered at point-max of WINDOW's buffer.
Briefly inserts STR, measures with `window-text-pixel-size', and
Expand Down Expand Up @@ -491,18 +507,37 @@ Invalidates the cache if the default font width changes (e.g. text scaling)."
(setq markdown-overlays--table-char-pixel-width-cache (cons fw sw))
sw))))

(defun markdown-overlays--grapheme-width (g)
"Calculate width of a single grapheme cluster G in a modern terminal."
(if (string-match-p (rx (or "\xfe0f" "\x200d" (any "\x1f3fb-\x1f3ff"))) g)
2
(min (string-width g) 2)))

(defun markdown-overlays--string-width (str)
"Calculate string width, adjusting for complex emojis in TTY mode.
Modern terminals render ZWJ sequences and skin tone modifiers as a
single 2-cell glyph, but Emacs `string-width' sums their parts."
(cond
((display-graphic-p)
(string-width str))
((and (markdown-overlays--modern-tty-p) (fboundp 'string-glyph-split))
(seq-reduce #'+ (seq-map #'markdown-overlays--grapheme-width (string-glyph-split str)) 0))
(t
(string-width str))))

(defun markdown-overlays--table-display-width (str)
"Return display width of STR in character units.
Uses `window-text-pixel-size' for non-ASCII strings to ensure accurate
measurements of emoji, CJK, and flags in the destination buffer."
(if (and (fboundp 'window-text-pixel-size)
(if (and (display-graphic-p)
(fboundp 'window-text-pixel-size)
(not (string-match-p (rx bos (* ascii) eos) str)))
(let* ((win (or (get-buffer-window (current-buffer))
(selected-window)))
(char-px (markdown-overlays--table-char-pixel-width win))
(real-px (markdown-overlays--table-measure-string str win)))
(ceiling (/ (float real-px) char-px)))
(string-width str)))
(markdown-overlays--string-width str)))

(defun markdown-overlays--preprocess-table (table)
"Parse and process all cells in TABLE in a single pass.
Expand Down Expand Up @@ -547,7 +582,7 @@ where each processed-cell is the propertized string from `process-cell-content'.
(let ((words (split-string str "[ \t\n]+" t)))
(if words
(apply #'max (mapcar (if (string-match-p (rx bos (* ascii) eos) str)
#'string-width
#'markdown-overlays--string-width
#'markdown-overlays--table-display-width)
words))
0))))
Expand Down Expand Up @@ -621,7 +656,8 @@ Preserves text properties across wrapped lines."
"Pad STR with spaces to reach WIDTH columns.
Non-ASCII content uses space characters for full columns and a fractional
pixel-width space for exact sub-pixel alignment."
(if (and (fboundp 'window-text-pixel-size)
(if (and (display-graphic-p)
(fboundp 'window-text-pixel-size)
(not (string-match-p (rx bos (* ascii) eos) str)))
(let* ((win (or (get-buffer-window (current-buffer))
(selected-window)))
Expand All @@ -638,7 +674,7 @@ pixel-width space for exact sub-pixel alignment."
(if (> remaining-px 0)
(propertize " " 'display `(space :width (,remaining-px)))
"")))))
(let ((current-width (string-width str)))
(let ((current-width (markdown-overlays--string-width str)))
(if (>= current-width width)
str
(concat str (make-string (- width current-width) ?\s))))))
Expand Down