diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-18 16:56:22 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-18 16:56:22 -0500 |
| commit | 11de26eb9ac5d79ac1303ff89dfd995fd18cd0ed (patch) | |
| tree | 4b5c5b3da9cefe6b570f86fad0106f5d153c6a11 | |
| parent | 4e63f665bf153203377f58161aa782a25ffd5e9f (diff) | |
| download | dotemacs-11de26eb9ac5d79ac1303ff89dfd995fd18cd0ed.tar.gz dotemacs-11de26eb9ac5d79ac1303ff89dfd995fd18cd0ed.zip | |
feat(completion): right-align custom category annotations
marginalia-align has been right for months, but annotations from the custom completion categories (radio stations, music files, and everything built through the system-lib table helpers) never went through marginalia. They rendered unaligned. A registration helper adds a builtin registry entry per category at table construction, which routes the table's own annotation function through marginalia's aligned field. The radio table converts from an affixation function with hand-rolled padding to a plain annotation, since marginalia now owns the alignment. Self-maintaining: any future custom category built through the helpers registers itself.
| -rw-r--r-- | modules/music-config.el | 36 | ||||
| -rw-r--r-- | modules/system-lib.el | 20 | ||||
| -rw-r--r-- | tests/test-music-config--completion-table.el | 14 | ||||
| -rw-r--r-- | tests/test-music-config--radio.el | 52 | ||||
| -rw-r--r-- | tests/test-system-lib--ensure-marginalia-align.el | 57 |
5 files changed, 135 insertions, 44 deletions
diff --git a/modules/music-config.el b/modules/music-config.el index e9095838..a70516e2 100644 --- a/modules/music-config.el +++ b/modules/music-config.el @@ -306,7 +306,9 @@ Directories are suffixed with /; files are plain. Hidden dirs/files skipped." "Completion table for CANDIDATES preserving order and case-insensitive match. Tags the `cj-music-file' category and annotates each candidate (a path relative to `cj/music-root', with a trailing slash for directories) with its size and -modification date so marginalia can show them." +modification date so marginalia can show them. The category is registered +with marginalia (builtin) so the annotations render right-aligned." + (cj/completion-ensure-marginalia-align 'cj-music-file) (let ((annotate (cj/completion-file-annotator (lambda (c) (expand-file-name @@ -1790,32 +1792,22 @@ to one station. Pure helper." (puthash disp t seen) (push (cons disp st) out))))) -(defun cj/music-radio--affixate (cands candidates) - "Affixation triples for CANDS with annotations aligned to one column. -CANDIDATES is the (DISPLAY . STATION) alist. Station names vary in width, -so each suffix pads out to the widest candidate plus a gutter; with the -fixed-width fields in `cj/music-radio--format-candidate' the listing reads -as aligned columns. The \"[done]\" sentinel has no station and gets no -annotation rather than a bogus zero row." - (let ((width (apply #'max 0 (mapcar #'string-width cands)))) - (mapcar (lambda (c) - (let ((st (cdr (assoc c candidates)))) - (list c "" - (if st - (concat (make-string (+ 3 (- width (string-width c))) ?\s) - (propertize (cj/music-radio--format-candidate st) - 'face 'completions-annotations)) - "")))) - cands))) - (defun cj/music-radio--completion-table (candidates) - "Completion table over CANDIDATES carrying the Variant-B marginalia affix." + "Completion table over CANDIDATES carrying the Variant-B annotation. +Tagged `cj-radio-station' and registered with marginalia (builtin), so the +codec/bitrate/country/votes/tags annotation renders right-aligned like the +stock categories. The \"[done]\" sentinel has no station and annotates as +nil rather than a bogus zero row." + (cj/completion-ensure-marginalia-align 'cj-radio-station) (lambda (string pred action) (if (eq action 'metadata) `(metadata (category . cj-radio-station) - (affixation-function - . ,(lambda (cands) (cj/music-radio--affixate cands candidates)))) + (annotation-function + . ,(lambda (c) + (when-let ((st (cdr (assoc c candidates)))) + (concat " " (propertize (cj/music-radio--format-candidate st) + 'face 'completions-annotations)))))) (complete-with-action action (mapcar #'car candidates) string pred)))) (defun cj/music-radio--pick-loop (candidates) diff --git a/modules/system-lib.el b/modules/system-lib.el index f1049c02..54e20b74 100644 --- a/modules/system-lib.el +++ b/modules/system-lib.el @@ -164,6 +164,22 @@ contributes its own modes regardless of load order." (setq font-lock-global-modes (cj/--font-lock-global-modes-excluding font-lock-global-modes mode)))) +;; Declared special here for the compiler; marginalia owns the defvar. +(defvar marginalia-annotator-registry) + +(defun cj/completion-ensure-marginalia-align (category) + "Register CATEGORY with marginalia as builtin-annotated, once. +A custom completion category bypasses marginalia entirely, so the table's +own annotation function renders unaligned even with `marginalia-align' +set. A builtin registry entry tells marginalia to use the table's +annotation function inside its aligned field, so custom annotations line +up like every stock category. A category that already has an entry is +left alone (someone chose its annotators deliberately). Silent no-op +when marginalia isn't loaded." + (when (and (boundp 'marginalia-annotator-registry) + (not (assq category marginalia-annotator-registry))) + (push (list category 'builtin 'none) marginalia-annotator-registry))) + (defun cj/completion-table (category collection) "Return a completion table over COLLECTION tagged with completion CATEGORY. COLLECTION is anything `completing-read' accepts (list, alist, obarray, hash @@ -180,7 +196,9 @@ the candidates match one; marginalia then annotates them with no further work." "Like `cj/completion-table' but also attach ANNOTATE as the annotation function. ANNOTATE is called with a candidate string and returns its annotation suffix, or nil. Use this for a custom CATEGORY that marginalia has no built-in annotator -for: marginalia falls back to the table's own annotation function." +for; the category is registered with marginalia (builtin) so ANNOTATE's output +renders right-aligned like stock annotations." + (cj/completion-ensure-marginalia-align category) (lambda (string predicate action) (if (eq action 'metadata) `(metadata (category . ,category) diff --git a/tests/test-music-config--completion-table.el b/tests/test-music-config--completion-table.el index 5e33e655..6b3da442 100644 --- a/tests/test-music-config--completion-table.el +++ b/tests/test-music-config--completion-table.el @@ -19,6 +19,10 @@ (defvar-keymap cj/custom-keymap :doc "Stub keymap for testing") +;; Declare special here too (the module's bare defvar is file-local) so the +;; registration test's `let' binds dynamically. +(defvar marginalia-annotator-registry) + ;; Load production code (require 'music-config) @@ -131,5 +135,15 @@ ;; Should not crash, returns empty (should (null result)))) +;;; Marginalia registration + +(ert-deftest test-music-config--completion-table-registers-with-marginalia () + "Normal: building the table registers cj-music-file so marginalia +right-aligns the size/date annotations." + (let ((marginalia-annotator-registry '())) + (cj/music--completion-table '("a.mp3")) + (should (equal (assq 'cj-music-file marginalia-annotator-registry) + '(cj-music-file builtin none))))) + (provide 'test-music-config--completion-table) ;;; test-music-config--completion-table.el ends here diff --git a/tests/test-music-config--radio.el b/tests/test-music-config--radio.el index db917a03..a91612bf 100644 --- a/tests/test-music-config--radio.el +++ b/tests/test-music-config--radio.el @@ -19,6 +19,10 @@ (defvar cj/custom-keymap (make-sparse-keymap) "Stub keymap for testing.") +;; Declare special here too (the module's bare defvar is file-local) so the +;; registration test's `let' binds dynamically. +(defvar marginalia-annotator-registry) + (require 'music-config) (declare-function cj/music-radio--parse-search "music-config" (json-text)) @@ -169,29 +173,35 @@ across stations with different vote counts." '(:codec "MP3" :bitrate 128 :countrycode "US" :votes 174208 :tags "jazz")))) (should (= (string-match "jazz" low) (string-match "jazz" high))))) -(ert-deftest test-music-radio-affixate-aligns-annotation-column () - "Normal: every annotation starts at the same display column regardless of -how long the station name is." - (let* ((candidates '(("Short" . (:codec "MP3" :bitrate 128 :countrycode "US" - :votes 5 :tags "a")) - ("A Much Longer Station Name" . (:codec "AAC" :bitrate 64 - :countrycode "FR" :votes 9 :tags "b")))) - (triples (cj/music-radio--affixate (mapcar #'car candidates) candidates)) - (cols (mapcar (lambda (triple) - (let ((cand (nth 0 triple)) - (suffix (nth 2 triple))) - (string-match "[^ ]" suffix) - (+ (string-width cand) (match-beginning 0)))) - triples))) - (should (= (length triples) 2)) - (should (apply #'= cols)))) - -(ert-deftest test-music-radio-affixate-done-sentinel-no-annotation () - "Boundary: the [done] sentinel has no station and gets an empty suffix." +(ert-deftest test-music-radio-completion-table-annotates-station () + "Normal: the table's annotation function returns the Variant-B string for +a station candidate (marginalia handles the right-alignment)." + (let* ((candidates '(("Jazz FM" . (:codec "MP3" :bitrate 128 :countrycode "US" + :votes 5 :tags "jazz")))) + (table (cj/music-radio--completion-table candidates)) + (meta (funcall table "" nil 'metadata)) + (annotate (alist-get 'annotation-function (cdr meta)))) + (should (functionp annotate)) + (should-not (alist-get 'affixation-function (cdr meta))) + (should (string-match-p "MP3" (funcall annotate "Jazz FM"))) + (should (string-match-p "jazz" (funcall annotate "Jazz FM"))))) + +(ert-deftest test-music-radio-completion-table-done-sentinel-no-annotation () + "Boundary: the [done] sentinel has no station and annotates as nil." (let* ((candidates '(("[done]") ("Station" . (:codec "MP3" :bitrate 128 :countrycode "US" :votes 1 :tags "x")))) - (triples (cj/music-radio--affixate (mapcar #'car candidates) candidates))) - (should (equal (nth 2 (car triples)) "")))) + (table (cj/music-radio--completion-table candidates)) + (annotate (alist-get 'annotation-function + (cdr (funcall table "" nil 'metadata))))) + (should-not (funcall annotate "[done]")))) + +(ert-deftest test-music-radio-completion-table-registers-with-marginalia () + "Normal: building the table registers cj-radio-station so marginalia +right-aligns the table's own annotations." + (let ((marginalia-annotator-registry '())) + (cj/music-radio--completion-table '(("X" . (:codec "MP3")))) + (should (equal (assq 'cj-radio-station marginalia-annotator-registry) + '(cj-radio-station builtin none))))) (provide 'test-music-config--radio) ;;; test-music-config--radio.el ends here diff --git a/tests/test-system-lib--ensure-marginalia-align.el b/tests/test-system-lib--ensure-marginalia-align.el new file mode 100644 index 00000000..33ff2ba2 --- /dev/null +++ b/tests/test-system-lib--ensure-marginalia-align.el @@ -0,0 +1,57 @@ +;;; test-system-lib--ensure-marginalia-align.el --- Tests for marginalia category registration -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Custom completion categories (cj-music-file, cj-radio-station, and every +;; category passed to the system-lib table helpers) bypass marginalia, so +;; their annotations never get its right-alignment even with marginalia-align +;; set. The registration helper adds a builtin entry per category so the +;; table's own annotation function renders through marginalia's aligned field. + +;;; Code: + +(require 'ert) + +;; The module's bare defvar marks this special only file-locally; declare it +;; here too so `let' binds dynamically (the scope-shadowing trap). +(defvar marginalia-annotator-registry) + +(require 'system-lib) + +;;; Normal Cases + +(ert-deftest test-system-lib-ensure-marginalia-align-registers-category () + "Normal: an unregistered category gains a builtin registry entry." + (let ((marginalia-annotator-registry '((file some-annotator builtin none)))) + (cj/completion-ensure-marginalia-align 'cj-test-category) + (should (equal (assq 'cj-test-category marginalia-annotator-registry) + '(cj-test-category builtin none))))) + +(ert-deftest test-system-lib-ensure-marginalia-align-idempotent () + "Normal: registering the same category twice leaves one entry." + (let ((marginalia-annotator-registry '())) + (cj/completion-ensure-marginalia-align 'cj-test-category) + (cj/completion-ensure-marginalia-align 'cj-test-category) + (should (= 1 (length marginalia-annotator-registry))))) + +;;; Boundary Cases + +(ert-deftest test-system-lib-ensure-marginalia-align-preserves-existing-entry () + "Boundary: a category with an existing (possibly custom) entry is untouched." + (let ((marginalia-annotator-registry '((cj-test-category my-custom-annotator)))) + (cj/completion-ensure-marginalia-align 'cj-test-category) + (should (equal (assq 'cj-test-category marginalia-annotator-registry) + '(cj-test-category my-custom-annotator))))) + +;;; Error Cases + +(ert-deftest test-system-lib-ensure-marginalia-align-marginalia-absent-noop () + "Error: without marginalia loaded (registry void) the helper is a silent +no-op -- annotations just stay unaligned, nothing breaks." + ;; marginalia is not loadable in the batch environment, so the global + ;; registry is genuinely void outside the `let's above. + (should-not (cj/completion-ensure-marginalia-align 'cj-test-category))) + +(provide 'test-system-lib--ensure-marginalia-align) +;;; test-system-lib--ensure-marginalia-align.el ends here |
