diff --git a/markdown-overlays-tables-tests.el b/markdown-overlays-tables-tests.el index 03037ed..96eab9d 100644 --- a/markdown-overlays-tables-tests.el +++ b/markdown-overlays-tables-tests.el @@ -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 diff --git a/markdown-overlays-tables.el b/markdown-overlays-tables.el index bc6324f..48cc46c 100644 --- a/markdown-overlays-tables.el +++ b/markdown-overlays-tables.el @@ -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 @@ -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. @@ -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)))) @@ -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))) @@ -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))))))