aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-30 07:27:44 -0500
committerCraig Jennings <c@cjennings.net>2026-04-30 07:27:44 -0500
commit9e90517a98785c450cd13cd940bd1787a4771529 (patch)
tree5c20f077bd3dcc28661711c3fdcad5ab90a5a3fa
parent5c7bb207081278e41122e62d8f6c282a18574665 (diff)
downloadgloss-9e90517a98785c450cd13cd940bd1787a4771529.tar.gz
gloss-9e90517a98785c450cd13cd940bd1787a4771529.zip
refactor: rework gloss-add UX to single side-window buffer
The previous shape opened a regular (non-side) buffer for body input and showed the saved entry in the side window after C-c C-c. That left an extra window split during the typing phase and a side popup the user didn't ask for. New shape, modeled on `org-capture': - `gloss-add' renders the term and underline as a read-only header in *gloss-add: TERM*, leaves the body region beneath it editable, and pops the buffer in the side-window slot. Point lands at the body start so the user can type immediately. - `gloss-add-finish' reads the body via the `gloss-add--body-start' marker, saves with source `manual', kills the buffer, closes the side window, and echoes `gloss-add: saved TERM' for confirmation. - `gloss-add-abort' kills the buffer and closes the side window. - The shared `gloss--add-cleanup' helper handles kill + window-close for both finish and abort. Read-only header uses text properties (`read-only', `front-sticky', `rear-nonsticky') rather than narrowing, so the user can't escape the restriction with `C-x n w'. `gloss--add-finish-internal' no longer calls show-entry — the save is its only responsibility. The display decision (show or not) is the caller's, which lets `gloss-add-finish' choose "save and close" while `gloss-lookup' still chooses "save and show." The previous saved-window-config approach is dropped — the side-slot takeover means there's nothing to restore. Layout returns to its pre-add state on either C-c C-c or C-c C-k. Adds `tests/test-gloss--add-flow-smoke.el' covering the four interactive moments (open, finish, abort, empty-term guard) plus the read-only-header invariant. Updates the `gloss--add-finish-internal' tests to drop the show-entry assertion. 129 tests pass in 0.27s.
-rw-r--r--gloss.el73
-rw-r--r--tests/test-gloss--add-finish-internal.el47
-rw-r--r--tests/test-gloss--add-flow-smoke.el115
3 files changed, 186 insertions, 49 deletions
diff --git a/gloss.el b/gloss.el
index 62ecba4..46ce78e 100644
--- a/gloss.el
+++ b/gloss.el
@@ -140,8 +140,13 @@ Returns a symbol naming the action taken: :show, :auto-save, :pick,
(defvar-local gloss-add--term nil
"Term being added in this `gloss-add-mode' buffer.")
+(defvar-local gloss-add--body-start nil
+ "Marker pointing at the first editable position in `gloss-add-mode'.
+Everything before this marker (the rendered header — term + underline
++ blank line) is text-property read-only.")
+
(defun gloss--add-finish-internal (term body)
- "Save TERM with BODY as a manual entry, then show it.
+ "Validate and save TERM with BODY as a manual entry.
Returns the saved entry plist, or nil if `gloss-core-save' returned nil
(e.g. user cancelled at the collision prompt). Trims surrounding
whitespace from BODY before saving."
@@ -150,43 +155,69 @@ whitespace from BODY before saving."
(let ((trimmed (string-trim (or body ""))))
(when (string-empty-p trimmed)
(user-error "gloss-add: body cannot be empty"))
- (let ((saved (gloss-core-save term trimmed 'manual)))
- (when saved
- (gloss-display-show-entry term trimmed))
- saved)))
+ (gloss-core-save term trimmed 'manual)))
+
+(defun gloss--add-cleanup (buf win)
+ "Kill BUF and close WIN if it's still live.
+Used by both `gloss-add-finish' and `gloss-add-abort' so the layout
+returns to its pre-add state."
+ (let ((kill-buffer-query-functions nil))
+ (kill-buffer buf))
+ (when (window-live-p win)
+ (delete-window win)))
(defun gloss-add-finish ()
- "Save the current `gloss-add-mode' buffer's body for the recorded term."
+ "Save the current `gloss-add-mode' buffer's body for the recorded term.
+Body is everything after the read-only header. After saving, kills the
+add buffer and closes the side window — the user is returned to the
+pre-add window layout, with a confirmation message in the echo area."
(interactive)
(unless gloss-add--term
(user-error "gloss-add: no term recorded for this buffer"))
- (let ((term gloss-add--term)
- (body (buffer-string))
- (buf (current-buffer)))
- (gloss--add-finish-internal term body)
- (let ((kill-buffer-query-functions nil))
- (kill-buffer buf))))
+ (let* ((term gloss-add--term)
+ (body (buffer-substring-no-properties
+ (or gloss-add--body-start (point-min))
+ (point-max)))
+ (buf (current-buffer))
+ (win (get-buffer-window (current-buffer)))
+ (saved (gloss--add-finish-internal term body)))
+ (gloss--add-cleanup buf win)
+ (when saved
+ (message "gloss-add: saved %s" term))))
(defun gloss-add-abort ()
- "Abandon the current `gloss-add-mode' buffer without saving."
+ "Abandon the current `gloss-add-mode' buffer without saving.
+Kills the add buffer and closes the side window."
(interactive)
- (let ((kill-buffer-query-functions nil))
- (kill-buffer (current-buffer))))
+ (gloss--add-cleanup (current-buffer)
+ (get-buffer-window (current-buffer))))
;;;###autoload
(defun gloss-add (term)
"Add TERM to the glossary manually.
-Opens a side buffer for the body; \\[gloss-add-finish] saves,
-\\[gloss-add-abort] cancels."
+Opens a side-window buffer with TERM rendered as a read-only header
+and an editable body region beneath it. \\[gloss-add-finish] saves
+and shows the saved entry; \\[gloss-add-abort] cancels and closes
+the side window."
(interactive (list (read-string "Add term: ")))
(when (string-empty-p (string-trim (or term "")))
(user-error "gloss-add: term cannot be empty"))
(let ((buf (get-buffer-create (format "*gloss-add: %s*" term))))
(with-current-buffer buf
- (erase-buffer)
- (gloss-add-mode)
- (setq gloss-add--term term))
- (pop-to-buffer buf)))
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert (gloss-display--render-entry term ""))
+ (let ((body-start (point-max-marker)))
+ (set-marker-insertion-type body-start nil)
+ (add-text-properties (point-min) body-start
+ '(read-only "Header is not editable"
+ front-sticky t
+ rear-nonsticky t))
+ (gloss-add-mode)
+ (setq gloss-add--term term)
+ (setq gloss-add--body-start body-start)))
+ (goto-char (point-max)))
+ (pop-to-buffer buf gloss-display--side-window-alist)))
(defun gloss--after-save-refresh-cache ()
"Buffer-local `after-save-hook' that clears the gloss cache."
diff --git a/tests/test-gloss--add-finish-internal.el b/tests/test-gloss--add-finish-internal.el
index 688d6f2..bb7e4ee 100644
--- a/tests/test-gloss--add-finish-internal.el
+++ b/tests/test-gloss--add-finish-internal.el
@@ -15,47 +15,38 @@
(require 'gloss)
(require 'testutil-gloss)
-(ert-deftest test-gloss-add-finish-internal-saves-and-shows ()
- "Normal: a fresh term + body is saved with source `manual' and shown."
+(ert-deftest test-gloss-add-finish-internal-saves-with-manual-source ()
+ "Normal: a fresh term + body is saved with source `manual'.
+Display is the caller's responsibility, not this function's."
(gloss-test--with-missing-glossary
- (let (shown)
- (cl-letf (((symbol-function 'gloss-display-show-entry)
- (lambda (term body) (setq shown (list term body)))))
- (gloss--add-finish-internal "newterm" "A new definition.")
- (let ((saved (gloss-core-lookup "newterm")))
- (should saved)
- (should (equal (plist-get saved :body) "A new definition."))
- (should (eq (plist-get saved :source) 'manual)))
- (should (equal shown '("newterm" "A new definition.")))))))
+ (let ((saved (gloss--add-finish-internal "newterm" "A new definition.")))
+ (should saved)
+ (should (equal (plist-get saved :body) "A new definition."))
+ (should (eq (plist-get saved :source) 'manual))
+ (should (equal (gloss-core-lookup "newterm") saved)))))
(ert-deftest test-gloss-add-finish-internal-empty-term-raises ()
"Error: empty TERM raises `user-error'."
(gloss-test--with-missing-glossary
- (cl-letf (((symbol-function 'gloss-display-show-entry)
- (lambda (_ _) nil)))
- (should-error (gloss--add-finish-internal "" "Body.")
- :type 'user-error)
- (should-error (gloss--add-finish-internal " " "Body.")
- :type 'user-error))))
+ (should-error (gloss--add-finish-internal "" "Body.")
+ :type 'user-error)
+ (should-error (gloss--add-finish-internal " " "Body.")
+ :type 'user-error)))
(ert-deftest test-gloss-add-finish-internal-empty-body-raises ()
"Error: empty BODY raises `user-error'."
(gloss-test--with-missing-glossary
- (cl-letf (((symbol-function 'gloss-display-show-entry)
- (lambda (_ _) nil)))
- (should-error (gloss--add-finish-internal "term" "")
- :type 'user-error)
- (should-error (gloss--add-finish-internal "term" " \n ")
- :type 'user-error))))
+ (should-error (gloss--add-finish-internal "term" "")
+ :type 'user-error)
+ (should-error (gloss--add-finish-internal "term" " \n ")
+ :type 'user-error)))
(ert-deftest test-gloss-add-finish-internal-trims-body-whitespace ()
"Boundary: leading/trailing whitespace in BODY is trimmed before save."
(gloss-test--with-missing-glossary
- (cl-letf (((symbol-function 'gloss-display-show-entry)
- (lambda (_ _) nil)))
- (gloss--add-finish-internal "term" " Body content.\n\n")
- (let ((saved (gloss-core-lookup "term")))
- (should (equal (plist-get saved :body) "Body content."))))))
+ (gloss--add-finish-internal "term" " Body content.\n\n")
+ (let ((saved (gloss-core-lookup "term")))
+ (should (equal (plist-get saved :body) "Body content.")))))
(provide 'test-gloss--add-finish-internal)
;;; test-gloss--add-finish-internal.el ends here
diff --git a/tests/test-gloss--add-flow-smoke.el b/tests/test-gloss--add-flow-smoke.el
new file mode 100644
index 0000000..a472ebb
--- /dev/null
+++ b/tests/test-gloss--add-flow-smoke.el
@@ -0,0 +1,115 @@
+;;; test-gloss--add-flow-smoke.el --- Smoke tests for the gloss-add interactive flow -*- lexical-binding: t -*-
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+;; Smoke tests for the `gloss-add' interactive flow. The buffer pops
+;; up in the side-window slot with a read-only header (term +
+;; underline + blank line) and an editable body region beneath it.
+;; `gloss-add-finish' reads the body region, saves, and shows the
+;; rendered entry in the same side slot. `gloss-add-abort' kills the
+;; buffer and closes the side window.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'gloss)
+(require 'testutil-gloss)
+
+(defmacro gloss-test--with-add-display-mocks (&rest body)
+ "Run BODY with `pop-to-buffer' replaced by a no-op that keeps the
+buffer current, and `gloss-display-show-entry' / `delete-window' as
+capturing no-ops."
+ (declare (indent 0) (debug t))
+ `(cl-letf (((symbol-function 'pop-to-buffer)
+ (lambda (buf &rest _) (set-buffer buf) (current-buffer)))
+ ((symbol-function 'get-buffer-window)
+ (lambda (&rest _) nil))
+ ((symbol-function 'gloss-display-show-entry)
+ (lambda (_ _) nil)))
+ ,@body))
+
+(ert-deftest test-gloss-add-opens-buffer-with-read-only-header ()
+ "Smoke: gloss-add buffer renders the header read-only and exposes a
+live `gloss-add--body-start' marker pointing past the header."
+ (gloss-test--with-missing-glossary
+ (gloss-test--with-add-display-mocks
+ (gloss-add "newterm")
+ (let ((buf (get-buffer "*gloss-add: newterm*")))
+ (unwind-protect
+ (with-current-buffer buf
+ (should (eq major-mode 'gloss-add-mode))
+ (should (equal gloss-add--term "newterm"))
+ (should (markerp gloss-add--body-start))
+ ;; Header content is the rendered entry with empty body.
+ (let ((header (buffer-substring-no-properties
+ (point-min) gloss-add--body-start)))
+ (should (string-match-p "^newterm\n=+\n\n" header)))
+ ;; Header range is read-only.
+ (should (get-text-property (point-min) 'read-only))
+ ;; Body-start position is NOT read-only (rear-nonsticky).
+ (should-not (get-text-property gloss-add--body-start 'read-only))
+ ;; Editing the header raises text-read-only.
+ (should-error (let ((inhibit-read-only nil))
+ (goto-char (point-min))
+ (insert "x"))
+ :type 'text-read-only))
+ (when (buffer-live-p buf)
+ (let ((kill-buffer-query-functions nil))
+ (kill-buffer buf))))))))
+
+(ert-deftest test-gloss-add-empty-term-raises-and-opens-no-buffer ()
+ "Error: empty term raises `user-error' and never opens an add buffer."
+ (gloss-test--with-missing-glossary
+ (gloss-test--with-add-display-mocks
+ (should-error (gloss-add "") :type 'user-error)
+ (should-error (gloss-add " ") :type 'user-error)
+ (should-not (get-buffer "*gloss-add: *"))
+ (should-not (get-buffer "*gloss-add: *")))))
+
+(ert-deftest test-gloss-add-finish-reads-body-saves-without-showing ()
+ "Smoke: finish reads body from after the header, saves, kills the
+buffer, and does NOT pop a side display. Add is a save-and-return
+flow (org-capture style); only `gloss-lookup' shows the side entry."
+ (gloss-test--with-missing-glossary
+ (let (show-called)
+ (cl-letf (((symbol-function 'pop-to-buffer)
+ (lambda (buf &rest _) (set-buffer buf)))
+ ((symbol-function 'gloss-display-show-entry)
+ (lambda (_ _) (setq show-called t))))
+ (gloss-add "term")
+ (with-current-buffer (get-buffer "*gloss-add: term*")
+ (goto-char (point-max))
+ (let ((inhibit-read-only nil))
+ (insert "Definition body."))
+ (gloss-add-finish))
+ (should-not (get-buffer "*gloss-add: term*"))
+ (let ((saved (gloss-core-lookup "term")))
+ (should saved)
+ (should (equal (plist-get saved :body) "Definition body.")))
+ (should-not show-called)))))
+
+(ert-deftest test-gloss-add-abort-kills-buffer-without-save ()
+ "Smoke: abort kills the buffer with no save and no show.
+The window-cleanup path (`delete-window' on the side window) only runs
+when the buffer is actually displayed; in batch mode it isn't, so we
+verify the save and show side effects rather than the window state."
+ (gloss-test--with-missing-glossary
+ (let (show-called)
+ (cl-letf (((symbol-function 'pop-to-buffer)
+ (lambda (buf &rest _) (set-buffer buf)))
+ ((symbol-function 'gloss-display-show-entry)
+ (lambda (_ _) (setq show-called t))))
+ (gloss-add "term")
+ (with-current-buffer (get-buffer "*gloss-add: term*")
+ (goto-char (point-max))
+ (let ((inhibit-read-only nil))
+ (insert "Body the user typed but abandoned."))
+ (gloss-add-abort))
+ (should-not (get-buffer "*gloss-add: term*"))
+ (should-not (gloss-core-lookup "term"))
+ (should-not show-called)))))
+
+(provide 'test-gloss--add-flow-smoke)
+;;; test-gloss--add-flow-smoke.el ends here