From 6102ef81e7cc8af7cf847906cbc1bb27fc8a75e5 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 30 Apr 2026 00:02:27 -0500 Subject: feat: implement gloss-display UI layer Side-buffer display for gloss entries plus the picker shown when an online fetch returns multiple candidates. The major mode `gloss-mode' derives from `special-mode' so `q' quits the window for free. Two pure helpers handle the formatting: `gloss-display--format-candidate' renders a definition plist as a single-line "[source] text" row for `completing-read', truncating with an ellipsis when the row would exceed 80 chars. `gloss-display--render-entry' produces the term, an underline matching the term length, a blank line, then the body. The picker maps the user's choice back to the original plist via an alist, returning nil on `C-g' rather than letting the quit signal propagate. `gloss-display-show-entry' opens the buffer in a 40%-width side window on the right, idempotent on repeat calls for the same term. All 88 tests pass, including 23 new tests across format-candidate (full N/B/E), render-entry (N/B/E), pick-definition (mocking completing-read at the boundary), and a show-entry smoke test that mocks display-buffer to keep batch runs windowless. --- gloss-display.el | 75 ++++++++++++++++++++++++++- tests/test-gloss-display--show-entry-smoke.el | 1 + 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/gloss-display.el b/gloss-display.el index c028698..1e2b92b 100644 --- a/gloss-display.el +++ b/gloss-display.el @@ -14,8 +14,9 @@ ;; `gloss-display-show-entry' TERM BODY ;; `gloss-display-pick-definition' TERM DEFINITIONS -> chosen plist ;; -;; Pure helper (full N/B/E test coverage): +;; Pure helpers (full N/B/E test coverage): ;; `gloss-display--format-candidate' PLIST -> "[source] text..." +;; `gloss-display--render-entry' TERM BODY -> rendered string ;; ;; `gloss-mode' is derived from `special-mode': `q' quits the window. ;; @@ -23,7 +24,77 @@ ;;; Code: -;; Implementation pending. Track via todo.org. +(defconst gloss-display--candidate-max-length 80 + "Maximum total length of a formatted candidate row. +Rows longer than this are truncated and end in an ellipsis.") + +(defconst gloss-display--buffer-name-format "*gloss: %s*" + "Format string for the side buffer name. Single %s receives the term.") + +(defconst gloss-display--side-window-alist + '((display-buffer-in-side-window) + (side . right) + (window-width . 0.4)) + "Display action used by `gloss-display-show-entry'.") + +(define-derived-mode gloss-mode special-mode "Gloss" + "Major mode for the gloss side buffer. + +\\{gloss-mode-map}" + (setq-local truncate-lines nil)) + +(defun gloss-display--format-candidate (plist) + "Format definition PLIST as a single-line `completing-read' row. +Return \"[source] text\". Embedded newlines and runs of whitespace +collapse to a single space. When the row would exceed +`gloss-display--candidate-max-length', the text portion is truncated +and the row ends in \"...\". A missing :source falls back to the +literal tag \"unknown\"." + (unless (listp plist) + (signal 'wrong-type-argument (list 'listp plist))) + (let* ((source (or (plist-get plist :source) 'unknown)) + (text (or (plist-get plist :text) "")) + (single-line (replace-regexp-in-string "[ \t\n]+" " " text)) + (prefix (format "[%s] " source)) + (room (- gloss-display--candidate-max-length (length prefix)))) + (if (<= (length single-line) room) + (concat prefix single-line) + (concat prefix (substring single-line 0 (max 0 (- room 3))) "...")))) + +(defun gloss-display--render-entry (term body) + "Return the rendered string for TERM and BODY. +The format is the term, an underline of `=' matching the term length, +a blank line, then the body verbatim." + (format "%s\n%s\n\n%s" term (make-string (length term) ?=) body)) + +(defun gloss-display-pick-definition (term definitions) + "Prompt the user to pick one of DEFINITIONS for TERM. +DEFINITIONS is a list of plists shaped (:source SYM :text STRING). +Return the chosen plist, or nil if DEFINITIONS is empty or the user +cancelled with \\[keyboard-quit]." + (when definitions + (let* ((alist (mapcar (lambda (def) + (cons (gloss-display--format-candidate def) def)) + definitions)) + (prompt (format "Pick definition for %s: " term)) + (choice (condition-case nil + (completing-read prompt alist nil t) + (quit nil)))) + (and choice (cdr (assoc choice alist)))))) + +(defun gloss-display-show-entry (term body) + "Display TERM's BODY in the gloss side buffer and return the buffer." + (let ((buf (get-buffer-create + (format gloss-display--buffer-name-format term)))) + (with-current-buffer buf + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (gloss-display--render-entry term body)) + (goto-char (point-min))) + (unless (derived-mode-p 'gloss-mode) + (gloss-mode))) + (display-buffer buf gloss-display--side-window-alist) + buf)) (provide 'gloss-display) ;;; gloss-display.el ends here diff --git a/tests/test-gloss-display--show-entry-smoke.el b/tests/test-gloss-display--show-entry-smoke.el index 07d7a97..cbadf2f 100644 --- a/tests/test-gloss-display--show-entry-smoke.el +++ b/tests/test-gloss-display--show-entry-smoke.el @@ -12,6 +12,7 @@ ;;; Code: (require 'ert) +(require 'cl-lib) (require 'gloss-display) (defmacro gloss-test--with-display-buffer-mocked (&rest body) -- cgit v1.2.3