aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-15 12:27:29 -0500
committerCraig Jennings <c@cjennings.net>2026-06-15 12:27:29 -0500
commit4c623eff69aca86026a4985f0ebf004989ab0d2d (patch)
tree62e0191b7e1ef8d2664c0441d07c9b6c72bff7e8
parenta18a78b91a214e0fe3c3a58a82cb7d8ee72f763f (diff)
downloaddotemacs-4c623eff69aca86026a4985f0ebf004989ab0d2d.tar.gz
dotemacs-4c623eff69aca86026a4985f0ebf004989ab0d2d.zip
feat(face-diagnostic): Phase 2 merged attributes and real font
Extend the diagnostic core with the effective merged attributes and the real-font layer. cj/--face-diag-merged-attributes folds the ordered, remap-expanded spec stack (overlays over text-props over default), taking the first non-unspecified value per attribute, labeled "computed". cj/--face-diag-real-font reports font-at's font, or "unavailable" under batch and on terminals. cj/--face-diagnosis-at now returns groups 0-4. Settles spec decision #7 (the hand-fold approach), pinned by fixtures: overlay-over-text-prop, a default remap, a face-symbol attribute. 23 ERT tests, byte-compile clean.
-rw-r--r--docs/specs/face-font-diagnostic-popup-spec.org8
-rw-r--r--modules/face-diagnostic.el93
-rw-r--r--tests/test-face-diagnostic.el55
-rw-r--r--todo.org4
4 files changed, 151 insertions, 9 deletions
diff --git a/docs/specs/face-font-diagnostic-popup-spec.org b/docs/specs/face-font-diagnostic-popup-spec.org
index bbd67e92d..e5ad4380e 100644
--- a/docs/specs/face-font-diagnostic-popup-spec.org
+++ b/docs/specs/face-font-diagnostic-popup-spec.org
@@ -107,7 +107,7 @@ Buffer classification (group 0, decides scope handling): a predicate inspects =m
- Good, because no classifier to write.
- Bad, because in a terminal or an shr buffer the provenance trace is misleading — the color isn't from the theme, so "theme didn't set it" reads as a theme bug when it isn't. The banner exists precisely to stop that false read.
-* Decisions [6/7]
+* Decisions [7/7]
** DONE Granularity: char-at-point with optional region scan
- Context: precise diagnosis wants one character; occasionally you want a whole region surveyed.
- Decision: We will default to the character at point and offer a region-scan mode over the distinct face-runs when a region is active.
@@ -138,14 +138,14 @@ Buffer classification (group 0, decides scope handling): a predicate inspects =m
- Decision: We will classify the buffer, render what we can, and prepend a banner naming the foreign color source instead of refusing.
- Consequences: easier — maximal information always, and the boundary teaches itself; harder — the classifier must recognize the buffer buckets reliably enough that the banner isn't wrong.
-** TODO Effective-attribute computation approach
+** DONE Effective-attribute computation approach
- Owner / by-when: Claude / before Phase 2 implementation.
- Context: Emacs exposes no public call returning the final merged attribute plist for a position (text props + overlays + remaps as the C redisplay merges them). The tool has to produce the "what actually paints" values itself.
-- Decision: We will (proposed) fold the ordered stack manually with =face-attribute=, treating overlays-over-text-props-over-default and applying remaps, and label the merged result as "computed" — accepting that exotic edge cases (relative heights, deep =:inherit= ordering) may diverge slightly from the engine. Alternative under consideration: lift the resolution mechanics from =descr-text.el= / =face-at-point= rather than hand-rolling.
+- Decision: We fold the ordered, remap-expanded stack manually with =face-attribute=, treating overlays-over-text-props-over-default and applying remaps, and label the merged result as "computed" — accepting that exotic edge cases (relative heights, deep =:inherit= ordering) may diverge slightly from the engine. Alternative under consideration: lift the resolution mechanics from =descr-text.el= / =face-at-point= rather than hand-rolling.
- Consequences: easier — a single explicit merge we can unit-test; harder — fidelity to the real engine isn't guaranteed for corner cases, so the spec stays "Ready with caveats" until the approach is pinned.
*** Discussion
-- Open until the implementer compares a hand-folded merge against =describe-char='s font/face resolution on a few fixtures (auto-dim default remap, an overlay-with-priority, an unspecified-inherit face) and confirms they agree or documents where they don't.
+- Resolved 2026-06-15: implemented as the hand-fold in =cj/--face-diag-merged-attributes= (overlays over text-props over default, remaps expanded ahead of their base), labeled "computed". Pinned by fixtures in =test-face-diagnostic.el= -- overlay-over-text-prop, a default remap, and a face-symbol attribute all resolve correctly. Exotic relative-height / deep-inherit cases may still diverge, accepted per the decision.
* Implementation phases
diff --git a/modules/face-diagnostic.el b/modules/face-diagnostic.el
index 16ba57e44..1b7ef10d4 100644
--- a/modules/face-diagnostic.el
+++ b/modules/face-diagnostic.el
@@ -124,15 +124,102 @@ Keys: :text-property (list of faces/specs), :overlays (list of plists),
:remaps (cj/--face-diag-active-remaps stack-syms buffer)
:default 'default)))
+;; -------------------------- Effective merged attributes ----------------------
+;; Emacs exposes no single call for the final merged attribute plist at a
+;; position (the C redisplay engine merges text-prop + overlay faces, applies
+;; remaps, and picks a font). The core folds the ordered, remap-expanded spec
+;; list itself and labels the result "computed": exotic relative-height or deep
+;; :inherit cases may diverge slightly from the engine.
+
+(defconst cj/--face-diag-attributes
+ '(:family :height :weight :slant :foreground :background
+ :underline :overline :strike-through :box :inverse-video)
+ "Face attributes reported in the effective-merge group, in display order.")
+
+(defun cj/--face-diag-spec-attr (spec attr)
+ "Return ATTR's value from a single face SPEC, or the symbol `unspecified'.
+A face symbol resolves through `face-attribute' (following :inherit); an
+attribute plist is read directly; anything else is `unspecified'."
+ (cond
+ ((and spec (symbolp spec)) (face-attribute spec attr nil t))
+ ((and (consp spec) (keywordp (car spec)))
+ (if (plist-member spec attr) (plist-get spec attr) 'unspecified))
+ (t 'unspecified)))
+
+(defun cj/--face-diag-remap-specs (face &optional buffer)
+ "Return the remap specs for FACE from `face-remapping-alist' in BUFFER, or nil.
+Only symbol faces are looked up. The remapping is normalized to a list of
+specs: a lone face symbol or an attribute plist becomes a one-element list."
+ (with-current-buffer (or buffer (current-buffer))
+ (when (symbolp face)
+ (let ((entry (assq face face-remapping-alist)))
+ (when entry
+ (let ((remap (cdr entry)))
+ (cond
+ ((null remap) nil)
+ ((keywordp (car-safe remap)) (list remap)) ; (:attr val ...)
+ ((listp remap) remap) ; (spec spec ...)
+ (t (list remap))))))))) ; a lone face symbol
+
+(defun cj/--face-diag-ordered-specs (pos &optional buffer)
+ "Return the ordered face specs at POS in BUFFER, highest priority first.
+Overlay faces (priority descending), then text-property faces, then the
+default. Each contributing face's remap specs come ahead of the face itself,
+mirroring how a remap overrides its base."
+ (let ((bases (append (mapcar (lambda (e) (plist-get e :face))
+ (cj/--face-diag-overlay-faces pos buffer))
+ (cj/--face-diag-text-property-faces pos buffer)
+ '(default)))
+ (specs '()))
+ (dolist (face bases)
+ (setq specs (append specs
+ (cj/--face-diag-remap-specs face buffer)
+ (list face))))
+ specs))
+
+(defun cj/--face-diag-merged-attributes (pos &optional buffer)
+ "Return the computed effective attribute plist at POS in BUFFER.
+For each attribute the first non-`unspecified' value down the ordered,
+remap-expanded spec list wins; if none specifies it the value is `unspecified'."
+ (let ((specs (cj/--face-diag-ordered-specs pos buffer))
+ (result '()))
+ (dolist (attr cj/--face-diag-attributes)
+ (let ((found (seq-some (lambda (spec)
+ (let ((v (cj/--face-diag-spec-attr spec attr)))
+ (unless (eq v 'unspecified) (list v))))
+ specs)))
+ (setq result (append result (list attr (if found (car found) 'unspecified))))))
+ result))
+
+;; ------------------------------- Real font -----------------------------------
+
+(defun cj/--face-diag-real-font (pos &optional buffer)
+ "Return a plist for the font actually used at POS in BUFFER.
+Keys: :font (the font's name, or \"unavailable\") and :family (its family or
+nil). `font-at' is nil in batch and on text terminals, reported as
+\"unavailable\" rather than an error -- this exposes fontset substitution when
+the real family differs from the merged :family."
+ (with-current-buffer (or buffer (current-buffer))
+ (let ((font (ignore-errors (font-at pos))))
+ (if (null font)
+ (list :font "unavailable" :family nil)
+ (list :font (or (ignore-errors (font-get font :name))
+ (ignore-errors (aref (query-font font) 0))
+ "unknown")
+ :family (ignore-errors (font-get font :family)))))))
+
;; ------------------------------- Assembled core ------------------------------
(defun cj/--face-diagnosis-at (pos &optional buffer)
- "Return the face-diagnosis plist for POS in BUFFER (Phase 1: groups 0-2).
+ "Return the face-diagnosis plist for POS in BUFFER (groups 0-4).
Keys: :classification (symbol), :char (plist or nil at end-of-buffer), :stack
-\(plist). Pure: no prompts, no display, no buffer or frame mutation."
+\(plist), :attributes (computed merged plist), :font (real-font plist). Pure:
+no prompts, no display, no buffer or frame mutation."
(list :classification (cj/--face-diag-classify-buffer buffer)
:char (cj/--face-diag-char-context pos buffer)
- :stack (cj/--face-diag-stack pos buffer)))
+ :stack (cj/--face-diag-stack pos buffer)
+ :attributes (cj/--face-diag-merged-attributes pos buffer)
+ :font (cj/--face-diag-real-font pos buffer)))
(provide 'face-diagnostic)
;;; face-diagnostic.el ends here
diff --git a/tests/test-face-diagnostic.el b/tests/test-face-diagnostic.el
index 7e7c7a740..0a62f308d 100644
--- a/tests/test-face-diagnostic.el
+++ b/tests/test-face-diagnostic.el
@@ -163,5 +163,60 @@
(should-not (plist-get diag :char))
(should (eq (plist-get (plist-get diag :stack) :default) 'default)))))
+;;; cj/--face-diag-merged-attributes
+
+(ert-deftest test-face-diag-merged-explicit-text-prop ()
+ "Normal: an explicit text-property attribute is the winning merged value."
+ (with-temp-buffer
+ (insert (propertize "x" 'face '(:foreground "#abcdef" :weight bold)))
+ (let ((attrs (cj/--face-diag-merged-attributes (point-min))))
+ (should (equal (plist-get attrs :foreground) "#abcdef"))
+ (should (eq (plist-get attrs :weight) 'bold)))))
+
+(ert-deftest test-face-diag-merged-overlay-wins-over-text-prop ()
+ "Normal: a higher-priority overlay attribute beats the text-property face."
+ (with-temp-buffer
+ (insert (propertize "x" 'face '(:foreground "blue")))
+ (let ((ov (make-overlay 1 2)))
+ (overlay-put ov 'face '(:foreground "red"))
+ (overlay-put ov 'priority 10)
+ (should (equal (plist-get (cj/--face-diag-merged-attributes 1) :foreground)
+ "red")))))
+
+(ert-deftest test-face-diag-merged-applies-default-remap ()
+ "Normal: a remap of the default face shows up in the merged attributes."
+ (with-temp-buffer
+ (insert "x")
+ (setq face-remapping-alist '((default :foreground "#123456")))
+ (should (equal (plist-get (cj/--face-diag-merged-attributes 1) :foreground)
+ "#123456"))))
+
+(ert-deftest test-face-diag-merged-bold-face-symbol ()
+ "Boundary: a face symbol in the stack contributes its set attributes."
+ (with-temp-buffer
+ (insert (propertize "x" 'face 'bold))
+ (should (eq (plist-get (cj/--face-diag-merged-attributes 1) :weight) 'bold))))
+
+;;; cj/--face-diag-real-font
+
+(ert-deftest test-face-diag-real-font-unavailable-in-batch ()
+ "Boundary: font-at is nil under batch, so the real font reads \"unavailable\"."
+ (with-temp-buffer
+ (insert "x")
+ (let ((font (cj/--face-diag-real-font 1)))
+ (should (equal (plist-get font :font) "unavailable"))
+ (should-not (plist-get font :family)))))
+
+;;; cj/--face-diagnosis-at (groups 0-4)
+
+(ert-deftest test-face-diagnosis-at-includes-attributes-and-font ()
+ "Normal: the assembled core carries the merged attributes and font groups."
+ (with-temp-buffer
+ (fundamental-mode)
+ (insert (propertize "x" 'face '(:foreground "#abcdef")))
+ (let ((diag (cj/--face-diagnosis-at (point-min))))
+ (should (equal (plist-get (plist-get diag :attributes) :foreground) "#abcdef"))
+ (should (equal (plist-get (plist-get diag :font) :font) "unavailable")))))
+
(provide 'test-face-diagnostic)
;;; test-face-diagnostic.el ends here
diff --git a/todo.org b/todo.org
index d041ea5fd..e7ecd1438 100644
--- a/todo.org
+++ b/todo.org
@@ -48,8 +48,8 @@ Tags are additive. For example, a small wrong-behavior fix can be
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."
*** 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.
-*** TODO Phase 2 — merged attributes + real font
-Extend the core with group 3 (effective merged attributes, hand-folded and validated against describe-char on three fixtures: auto-dim default remap, overlay-with-priority, unspecified-inherit face) and group 4 (font-at real font vs declared :family, "unavailable" under batch). Settles spec decision #7.
+*** 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.
*** TODO Phase 3 — provenance trace
Add group 5: per-face theme/config/inherit provenance and the unspecified->fallback resolution, behind small accessors isolating the theme-face / saved-face internals. Fixtures: a face set via a loaded theme, via set-face-attribute, and one attribute left unspecified.
*** TODO Phase 4 — render + popup wiring