aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-15 12:37:48 -0500
committerCraig Jennings <c@cjennings.net>2026-06-15 12:37:48 -0500
commitf0bf56d1ee2cd48a4e0f835d4524c31ea4da13c7 (patch)
tree3acebe41d822e42bd05c4f69ebc6a384c1ef05bd
parent3367f238927a9c17c6429025bc913e913efb60ce (diff)
downloaddotemacs-f0bf56d1ee2cd48a4e0f835d4524c31ea4da13c7.tar.gz
dotemacs-f0bf56d1ee2cd48a4e0f835d4524c31ea4da13c7.zip
feat(face-diagnostic): Phase 4 render, command, and init wiring
Phase 4 completes the face/font diagnostic. cj/describe-face-at-point renders cj/--face-diagnosis-at into a read-only *Face Diagnosis* buffer (cj/face-diagnostic-mode), with a region-scan mode over distinct face-runs (capped at 20) and an out-of-scope banner. It is required in init.el. The render is split into small section formatters tested on captured plists, and the command is smoke-tested and live-verified in the daemon, where it already surfaces the active auto-dim remaps. The command name is settled as cj/describe-face-at-point. The keybinding stays Craig's pick, and face-name buttons plus the module-header allowlist entry are filed as a follow-up. The spec is marked implemented and renamed to its lifecycle filename. 35 ERT tests, byte-compile clean.
-rw-r--r--docs/specs/face-font-diagnostic-popup-spec-implemented.org (renamed from docs/specs/face-font-diagnostic-popup-spec.org)4
-rw-r--r--init.el1
-rw-r--r--modules/face-diagnostic.el160
-rw-r--r--tests/test-face-diagnostic.el51
-rw-r--r--todo.org11
5 files changed, 219 insertions, 8 deletions
diff --git a/docs/specs/face-font-diagnostic-popup-spec.org b/docs/specs/face-font-diagnostic-popup-spec-implemented.org
index e5ad4380e..3e8fadcd8 100644
--- a/docs/specs/face-font-diagnostic-popup-spec.org
+++ b/docs/specs/face-font-diagnostic-popup-spec-implemented.org
@@ -1,6 +1,6 @@
:PROPERTIES:
:ID: 98f065cf-8bd5-46a0-ac24-da94d66855ad
-:STATUS: not-started
+:STATUS: implemented
:END:
#+TITLE: Face and Font Diagnostic Popup — Spec
#+AUTHOR: Craig Jennings
@@ -9,7 +9,7 @@
* Metadata
-| Status | not-started |
+| Status | implemented |
|----------+---------------------------------------------------|
| Owner | Craig Jennings |
|----------+---------------------------------------------------|
diff --git a/init.el b/init.el
index d90185068..227b8396c 100644
--- a/init.el
+++ b/init.el
@@ -81,6 +81,7 @@
(require 'ai-term) ;; in-Emacs Claude launcher (vertical-split ghostel)
(require 'help-utils) ;; search: arch-wiki, devdoc, tldr, wikipedia
(require 'help-config) ;; info, man, help config
+(require 'face-diagnostic) ;; describe face/font at point (cj/describe-face-at-point)
(require 'tramp-config) ;; remote shell connections
;; ---------------------- Added Features And Integrations ----------------------
diff --git a/modules/face-diagnostic.el b/modules/face-diagnostic.el
index 22f80cd98..83c81d92c 100644
--- a/modules/face-diagnostic.el
+++ b/modules/face-diagnostic.el
@@ -5,13 +5,18 @@
;;
;; Layer: 4 (Added features).
;; Category: O (optional command).
-;; Load shape: command-loaded (no startup side effects; pure defuns).
+;; Load shape: eager.
+;; Eager reason: none; a diagnostic command, a command-loaded deferral candidate.
+;; Top-level side effects: defines cj/face-diagnostic-mode and the
+;; cj/describe-face-at-point command; binds no global key.
+;; Runtime requires: seq.
+;; Direct test load: yes (the pure core is tested by requiring this module).
;;
;; A read-only diagnostic for "why does the character at point paint this way?"
;; It separates the face stack by source (text properties, overlays, active
;; remaps, the default) and -- in later phases -- the merged attributes, the
;; real font versus the declared family, and per-face theme/config/inherit
-;; provenance. See docs/specs/face-font-diagnostic-popup-spec.org.
+;; provenance. See docs/specs/face-font-diagnostic-popup-spec-implemented.org.
;;
;; This file is Phase 1: the pure read model. `cj/--face-diagnosis-at' returns
;; a plist with the buffer classification, the character context, and the face
@@ -291,5 +296,156 @@ mutation."
:font (cj/--face-diag-real-font pos buffer)
:provenance (cj/--face-diag-provenance pos buffer)))
+;; ------------------------------- Rendering -----------------------------------
+
+(defun cj/--face-diag-render-banner (classification)
+ "Return a one-line banner for an out-of-scope CLASSIFICATION, or \"\"."
+ (pcase classification
+ ('terminal-ansi
+ "NOTE: terminal buffer -- colors come from the ANSI palette, not the theme.\n\n")
+ ('document-shr
+ "NOTE: document buffer -- colors come from the rendered document, not the theme.\n\n")
+ ('image-no-text
+ "NOTE: image/no-text buffer -- little face information applies here.\n\n")
+ (_ "")))
+
+(defun cj/--face-diag-render-char (char)
+ "Render the CHAR context plist as a line, or a no-character notice."
+ (if (null char)
+ "Character: none at point.\n\n"
+ (format "Character: %S (U+%04X %s, script: %s)\n\n"
+ (plist-get char :char)
+ (plist-get char :codepoint)
+ (or (plist-get char :name) "no name")
+ (or (plist-get char :script) "none"))))
+
+(defun cj/--face-diag-render-faces (faces)
+ "Render a list of FACES (symbols or specs) comma-separated, or \"(none)\"."
+ (if faces (mapconcat (lambda (f) (format "%s" f)) faces ", ") "(none)"))
+
+(defun cj/--face-diag-render-stack (stack)
+ "Render the STACK plist (faces by source) as a block."
+ (concat
+ "Face stack (highest priority first):\n"
+ (format " text properties: %s\n"
+ (cj/--face-diag-render-faces (plist-get stack :text-property)))
+ (format " overlays: %s\n"
+ (let ((ov (plist-get stack :overlays)))
+ (if ov
+ (mapconcat (lambda (e)
+ (format "%s (priority %s)"
+ (plist-get e :face)
+ (or (plist-get e :priority) "nil")))
+ ov ", ")
+ "(none)")))
+ (format " active remaps: %s\n"
+ (let ((rm (plist-get stack :remaps)))
+ (if rm (mapconcat (lambda (e) (format "%s" (car e))) rm ", ") "(none)")))
+ " default: default\n\n"))
+
+(defun cj/--face-diag-render-attributes (attrs)
+ "Render the merged ATTRS plist as a block."
+ (concat
+ "Effective attributes (computed):\n"
+ (mapconcat (lambda (attr) (format " %s: %s" attr (plist-get attrs attr)))
+ cj/--face-diag-attributes "\n")
+ "\n\n"))
+
+(defun cj/--face-diag-render-font (font attrs)
+ "Render the real FONT plist beside the merged ATTRS declared :family."
+ (let ((real (plist-get font :font))
+ (declared (plist-get attrs :family))
+ (real-family (plist-get font :family)))
+ (concat
+ (format "Real font: %s\n" real)
+ (format "Declared family: %s\n" declared)
+ (if (and (stringp real-family) (stringp declared)
+ (not (string-equal-ignore-case real-family declared)))
+ (format " (substituted: real family %s differs from declared %s)\n\n"
+ real-family declared)
+ "\n"))))
+
+(defun cj/--face-diag-render-provenance (prov)
+ "Render the per-face PROV list as a block."
+ (concat
+ "Provenance:\n"
+ (if prov
+ (mapconcat
+ (lambda (p)
+ (format (concat " %s\n themes: %s\n config: %s\n"
+ " inherits: %s\n unspecified (-> default): %s")
+ (plist-get p :face)
+ (or (plist-get p :themes) "(none)")
+ (or (plist-get p :config) "(none)")
+ (or (plist-get p :inherit-chain) "(none)")
+ (or (plist-get p :unspecified) "(none)")))
+ prov "\n")
+ " (no named faces)")
+ "\n"))
+
+(defun cj/--face-diag-render (diag)
+ "Render the face-diagnosis DIAG plist into a report string."
+ (concat
+ (cj/--face-diag-render-banner (plist-get diag :classification))
+ (cj/--face-diag-render-char (plist-get diag :char))
+ (cj/--face-diag-render-stack (plist-get diag :stack))
+ (cj/--face-diag-render-attributes (plist-get diag :attributes))
+ (cj/--face-diag-render-font (plist-get diag :font) (plist-get diag :attributes))
+ (cj/--face-diag-render-provenance (plist-get diag :provenance))))
+
+;; ------------------------------- Region mode ---------------------------------
+
+(defun cj/--face-diag-run-starts (beg end)
+ "Return the positions in [BEG, END) where the `face' property run begins."
+ (let ((pos beg) (starts (list beg)))
+ (while (and (setq pos (next-single-property-change pos 'face nil end))
+ (< pos end))
+ (push pos starts))
+ (nreverse starts)))
+
+(defun cj/--face-diag-render-region (beg end)
+ "Render a diagnosis for each distinct face-run in [BEG, END), capped at 20."
+ (let* ((starts (cj/--face-diag-run-starts beg end))
+ (cap 20)
+ (shown (seq-take starts cap)))
+ (concat
+ (mapconcat (lambda (pos)
+ (concat (format "=== position %d ===\n" pos)
+ (cj/--face-diag-render (cj/--face-diagnosis-at pos))))
+ shown "\n")
+ (when (> (length starts) cap)
+ (format "\n... %d more face-runs not shown (cap %d).\n"
+ (- (length starts) cap) cap)))))
+
+;; ------------------------------- Command -------------------------------------
+
+(define-derived-mode cj/face-diagnostic-mode special-mode "Face-Diag"
+ "Major mode for the read-only face/font diagnosis report.")
+
+(defun cj/--face-diag-display (report)
+ "Show REPORT in the read-only *Face Diagnosis* buffer; return the buffer."
+ (let ((buf (get-buffer-create "*Face Diagnosis*")))
+ (with-current-buffer buf
+ (cj/face-diagnostic-mode)
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert report)
+ (goto-char (point-min))))
+ (display-buffer buf)
+ buf))
+
+(defun cj/describe-face-at-point ()
+ "Pop up a read-only diagnosis of the face and font at point.
+With an active region, diagnose each distinct face-run in the region. The
+report separates the face stack by source, shows the computed merged
+attributes, the real font versus the declared family, and per-face
+theme/config/inherit provenance. Read-only; never mutates buffer or frame.
+See docs/specs/face-font-diagnostic-popup-spec-implemented.org."
+ (interactive)
+ (cj/--face-diag-display
+ (if (use-region-p)
+ (cj/--face-diag-render-region (region-beginning) (region-end))
+ (cj/--face-diag-render (cj/--face-diagnosis-at (point))))))
+
(provide 'face-diagnostic)
;;; face-diagnostic.el ends here
diff --git a/tests/test-face-diagnostic.el b/tests/test-face-diagnostic.el
index 874893fb6..f75d5b679 100644
--- a/tests/test-face-diagnostic.el
+++ b/tests/test-face-diagnostic.el
@@ -271,5 +271,56 @@
(let ((prov (plist-get (cj/--face-diagnosis-at (point-min)) :provenance)))
(should (cl-some (lambda (p) (eq (plist-get p :face) 'bold)) prov)))))
+;;; cj/--face-diag-render
+
+(ert-deftest test-face-diag-render-has-all-groups ()
+ "Normal: the rendered report names every group and the stack's face."
+ (with-temp-buffer
+ (fundamental-mode)
+ (insert (propertize "A" 'face 'bold))
+ (let ((report (cj/--face-diag-render (cj/--face-diagnosis-at (point-min)))))
+ (should (string-match-p "Character:" report))
+ (should (string-match-p "Face stack" report))
+ (should (string-match-p "bold" report))
+ (should (string-match-p "Effective attributes" report))
+ (should (string-match-p "Real font" report))
+ (should (string-match-p "Provenance" report)))))
+
+(ert-deftest test-face-diag-render-banner-out-of-scope ()
+ "Boundary: a terminal classification renders a banner naming the ANSI source."
+ (should (string-match-p "terminal" (cj/--face-diag-render-banner 'terminal-ansi)))
+ (should (equal (cj/--face-diag-render-banner 'theme-faced) "")))
+
+(ert-deftest test-face-diag-render-no-char ()
+ "Boundary: a nil char group renders the no-character notice."
+ (should (string-match-p "none at point" (cj/--face-diag-render-char nil))))
+
+(ert-deftest test-face-diag-render-region-covers-runs ()
+ "Normal: region rendering emits a position header per distinct face-run."
+ (with-temp-buffer
+ (insert (propertize "aa" 'face 'bold))
+ (insert (propertize "bb" 'face 'italic))
+ (let ((report (cj/--face-diag-render-region (point-min) (point-max))))
+ (should (string-match-p "=== position 1 ===" report))
+ (should (string-match-p "=== position 3 ===" report)))))
+
+;;; cj/describe-face-at-point (smoke)
+
+(ert-deftest test-face-diag-command-creates-buffer ()
+ "Normal: the command renders into the read-only *Face Diagnosis* buffer."
+ (with-temp-buffer
+ (insert (propertize "A" 'face 'bold))
+ (goto-char (point-min))
+ (cj/describe-face-at-point)
+ (let ((buf (get-buffer "*Face Diagnosis*")))
+ (unwind-protect
+ (progn
+ (should buf)
+ (with-current-buffer buf
+ (should (eq major-mode 'cj/face-diagnostic-mode))
+ (should buffer-read-only)
+ (should (string-match-p "Face stack" (buffer-string)))))
+ (when (buffer-live-p buf) (kill-buffer buf))))))
+
(provide 'test-face-diagnostic)
;;; test-face-diagnostic.el ends here
diff --git a/todo.org b/todo.org
index 01f450951..7495f0ec5 100644
--- a/todo.org
+++ b/todo.org
@@ -44,16 +44,19 @@ Tags are additive. For example, a small wrong-behavior fix can be
=:bug:quick:=, and a feature that requires internal restructuring can be
=:feature:refactor:=.
* Emacs Open Work
-** PROJECT [#A] Face and font diagnostic popup at point :feature:
-Read-only popup diagnosing why text at point paints as it does (face stack by source, merged attributes, real font vs declared family, theme/config/inherit provenance). Spec: [[id:98f065cf-8bd5-46a0-ac24-da94d66855ad][face-font-diagnostic-popup-spec.org]]. Building in modules/face-diagnostic.el: pure core cj/--face-diagnosis-at returns the report plist; cj/describe-face-at-point renders it into a read-only help buffer. From the roam inbox — "do this one first."
+** DONE [#A] Face and font diagnostic popup at point :feature:
+CLOSED: [2026-06-15 Mon]
+Read-only popup diagnosing why text at point paints as it does (face stack by source, merged attributes, real font vs declared family, theme/config/inherit provenance). Spec: [[id:98f065cf-8bd5-46a0-ac24-da94d66855ad][face-font-diagnostic-popup-spec-implemented.org]]. Building in modules/face-diagnostic.el: pure core cj/--face-diagnosis-at returns the report plist; cj/describe-face-at-point renders it into a read-only help buffer. From the roam inbox — "do this one first."
*** 2026-06-15 Mon @ 12:19:41 -0500 Phase 1 — core read model + buffer classifier landed
modules/face-diagnostic.el: cj/--face-diagnosis-at returns groups 0-2 (buffer classification, character context, face stack by source) via small pure helpers. 17 ERT tests (tests/test-face-diagnostic.el), byte-compile clean. Not yet wired into init.el; the interactive command and keybinding land in Phase 4.
*** 2026-06-15 Mon @ 12:26:52 -0500 Phase 2 — merged attributes + real font landed
cj/--face-diag-merged-attributes folds the ordered, remap-expanded spec stack ("computed"); cj/--face-diag-real-font reports font-at or "unavailable" under batch. Settles spec decision #7 (hand-fold, tested on overlay-over-text-prop, default-remap, and face-symbol fixtures). 23 ERT tests total, byte-compile clean.
*** 2026-06-15 Mon @ 12:30:30 -0500 Phase 3 — provenance trace landed
cj/--face-diag-provenance returns per-face provenance: themes from theme-face, config from saved/customized-face, the :inherit chain, and the attributes still unspecified that fall to the default. Version-sensitive internals sit behind small tolerant accessors. 30 ERT tests total, byte-compile clean.
-*** TODO Phase 4 — render + popup wiring
-cj/describe-face-at-point, the read-only mode with face buttons, region-scan mode, and placement/dismissal via the unified-popup rules. Settle the command name and keybinding here. Render function tested on a captured plist; live smoke test.
+*** 2026-06-15 Mon @ 12:37:16 -0500 Phase 4 — render + popup wiring landed
+cj/describe-face-at-point renders the diagnosis into the read-only *Face Diagnosis* buffer (cj/face-diagnostic-mode), with region-scan mode and an out-of-scope banner; required in init.el; live-verified in the daemon (it already surfaces the auto-dim remaps). Command name settled as cj/describe-face-at-point. Deferred to follow-up: clickable face-name buttons (plain text for now) and the module-header allowlist entry; the keybinding is Craig's to pick.
+** TODO [#C] face-diagnostic: face-name buttons + header allowlist :feature:
+Two v1 follow-ups on the shipped face/font diagnostic: render the face names in the report as buttons that call describe-face (the spec's "For the user" buttons; v1 shows them as plain text), and add face-diagnostic to the module-header allowlist in tests/test-init-module-headers.el now that it's required in init.el. Spec: [[id:98f065cf-8bd5-46a0-ac24-da94d66855ad][face-font-diagnostic-popup-spec-implemented.org]].
** TODO [#D] Face diagnostic popup — theme-studio bridge (vNext) :feature:
vNext for the face/font diagnostic tool: interactivity — "send this face to theme-studio", jump-to-theme-spec, any write path. Deferred per [[id:98f065cf-8bd5-46a0-ac24-da94d66855ad][the spec]]'s scope tiers.
** TODO [#C] Gold text in auto-dimmed buffers :bug: