aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/org-capture-config.el123
-rw-r--r--tests/test-org-capture-config-popup-window.el281
-rw-r--r--todo.org8
3 files changed, 411 insertions, 1 deletions
diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el
index b4030479..393f1d97 100644
--- a/modules/org-capture-config.el
+++ b/modules/org-capture-config.el
@@ -351,12 +351,133 @@ Captured On: %U" :prepend t)
;; aborts, so the popup never lingers. Frames not named "org-capture" are
;; untouched — normal in-Emacs captures keep their windows.
+(defun cj/org-capture--popup-frame-p ()
+ "Return non-nil when the selected frame is the quick-capture popup."
+ (equal (frame-parameter nil 'name) "org-capture"))
+
(defun cj/org-capture--delete-popup-frame ()
"Delete the current frame when it is the quick-capture popup."
- (when (equal (frame-parameter nil 'name) "org-capture")
+ (when (cj/org-capture--popup-frame-p)
(delete-frame)))
(add-hook 'org-capture-after-finalize-hook #'cj/org-capture--delete-popup-frame)
+;; The popup opens a fresh emacsclient frame still showing the daemon's last
+;; buffer. `org-mks' shows the *Org Select* menu via
+;; `switch-to-buffer-other-window', and `org-capture-place-template' shows the
+;; CAPTURE-* buffer via `pop-to-buffer' with a split action — both split the
+;; small floating frame, so two reverse-video modelines read like tmux bars and
+;; the working buffer leaks into a popup that should only show capture UI. A
+;; frame-scoped `display-buffer-alist' entry forces both into the frame's sole
+;; window. Gated on the "org-capture" frame name, so normal in-Emacs captures
+;; keep their windows.
+
+(defun cj/org-capture--popup-sole-window-p (frame-name buffer-name)
+ "Return non-nil when BUFFER-NAME in a frame named FRAME-NAME is capture popup UI.
+Capture popup UI is the *Org Select* template menu or a CAPTURE-* buffer
+shown in the quick-capture frame (FRAME-NAME equal to \"org-capture\")."
+ (and (equal frame-name "org-capture")
+ (stringp buffer-name)
+ (or (equal buffer-name "*Org Select*")
+ (string-prefix-p "CAPTURE-" buffer-name))))
+
+(defun cj/org-capture--popup-display-condition (buffer-name &optional _action)
+ "`display-buffer' CONDITION matching capture UI in the quick-capture popup.
+BUFFER-NAME is the buffer's name; the selected frame supplies the frame name."
+ (cj/org-capture--popup-sole-window-p (frame-parameter nil 'name) buffer-name))
+
+(defun cj/org-capture--display-sole-window (buffer _alist)
+ "`display-buffer' ACTION showing BUFFER as the only window of the frame.
+Used for the quick-capture popup so the template menu and capture buffer
+never split the small floating frame."
+ (let ((window (frame-root-window)))
+ (delete-other-windows window)
+ (set-window-buffer window buffer)
+ window))
+
+(add-to-list 'display-buffer-alist
+ '(cj/org-capture--popup-display-condition
+ cj/org-capture--display-sole-window))
+
+;; The desktop quick-capture popup is launched globally (no browser selection,
+;; no mu4e message, no pdf/epub buffer), so most templates make no sense there:
+;; the context fields (%:link, %i) come up empty or point at the daemon's last
+;; buffer, and the pdf templates error outright. `cj/quick-capture' offers only
+;; Task, Bug, and Event; Task and Bug file to the global inbox rather than a
+;; project todo.org, since a desktop capture has no meaningful project context.
+;; It also closes the popup frame on every exit path (abort, error, finalize) —
+;; `org-capture' only runs `org-capture-after-finalize-hook' on a completed
+;; capture, so a q/C-g at the template menu or an erroring template would
+;; otherwise orphan the frame. The Hyprland script calls this instead of
+;; `org-capture'.
+
+(defun cj/--org-capture-popup-templates (templates inbox)
+ "Return the desktop-popup subset of TEMPLATES: Task, Bug, Event.
+Task (\"t\") and Bug (\"b\") are retargeted to INBOX's \"Inbox\" headline;
+Event (\"e\") passes through unchanged. All other templates are dropped.
+Template bodies and properties are preserved."
+ (delq nil
+ (mapcar
+ (lambda (entry)
+ (pcase (car-safe entry)
+ ((or "t" "b")
+ ;; (KEY DESC TYPE TARGET TEMPLATE . PROPS) -> retarget TARGET
+ (append (list (nth 0 entry) (nth 1 entry) (nth 2 entry)
+ (list 'file+headline inbox "Inbox"))
+ (nthcdr 4 entry)))
+ ("e" entry)
+ (_ nil)))
+ templates)))
+
+(defun cj/org-capture--popup-frame ()
+ "Return a live frame named \"org-capture\" (the quick-capture popup), or nil."
+ (seq-find (lambda (f)
+ (and (frame-live-p f)
+ (equal (frame-parameter f 'name) "org-capture")))
+ (frame-list)))
+
+(defun cj/quick-capture ()
+ "Org-capture entry point for the Hyprland desktop popup (frame \"org-capture\").
+Offers only Task, Bug, and Event; Task and Bug file to the global inbox.
+Closes the popup frame on abort or error so a stray selection never orphans it.
+
+Selects the \"org-capture\" frame by name before capturing rather than trusting
+the ambient selected frame: the launching =emacsclient -c -e= runs before
+Hyprland settles focus on the new float, so =(selected-frame)= is still the
+daemon's main frame and the capture would otherwise land there."
+ (interactive)
+ (let ((frame (cj/org-capture--popup-frame)))
+ (condition-case err
+ (progn
+ (when frame (select-frame-set-input-focus frame))
+ (let ((org-capture-templates
+ (cj/--org-capture-popup-templates org-capture-templates inbox-file)))
+ (org-capture)))
+ (quit (cj/org-capture--delete-popup-frame))
+ (error (message "Quick-capture: %s" (error-message-string err))
+ (cj/org-capture--delete-popup-frame)))))
+
+;; The template menu's "C — Customize org-capture-templates" special makes no
+;; sense in the desktop popup (it would open a Customize buffer in the floating
+;; frame). Strip it from the menu when the selection runs in the popup frame,
+;; keeping "q — Abort". `org-mks' is the menu primitive; advising it (gated on
+;; the frame name) catches the capture template selection without touching
+;; org-mks's other callers.
+
+(defun cj/--org-capture-popup-strip-specials (specials)
+ "Remove the \"C\" Customize entry from org-mks SPECIALS, keeping the rest.
+SPECIALS is the org-mks specials alist (e.g. the Customize and Abort entries)."
+ (delq nil (mapcar (lambda (s) (unless (equal (car-safe s) "C") s)) specials)))
+
+(defun cj/org-capture--popup-mks-advice (orig table title &optional prompt specials)
+ "Around-advice for `org-mks': hide the Customize special in the quick-capture popup.
+ORIG is the real `org-mks'; TABLE TITLE PROMPT SPECIALS are its arguments."
+ (funcall orig table title prompt
+ (if (cj/org-capture--popup-frame-p)
+ (cj/--org-capture-popup-strip-specials specials)
+ specials)))
+
+(advice-add 'org-mks :around #'cj/org-capture--popup-mks-advice)
+
(provide 'org-capture-config)
;;; org-capture-config.el ends here.
diff --git a/tests/test-org-capture-config-popup-window.el b/tests/test-org-capture-config-popup-window.el
new file mode 100644
index 00000000..34f67b36
--- /dev/null
+++ b/tests/test-org-capture-config-popup-window.el
@@ -0,0 +1,281 @@
+;;; test-org-capture-config-popup-window.el --- Quick-capture popup single-window tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the pure predicate behind the quick-capture popup single-window
+;; fix. The Hyprland Super+Shift+N popup opens an emacsclient frame named
+;; "org-capture"; in that frame the *Org Select* template menu and the
+;; CAPTURE-* buffer must fill the frame's sole window instead of splitting it.
+;; `cj/org-capture--popup-sole-window-p' is the frame+buffer decision; the
+;; display-buffer action that acts on it is exercised by hand (window ops),
+;; not here.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'org)
+(require 'org-capture) ; makes `org-capture-templates' a real special var
+(require 'user-constants)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'org-capture-config)
+
+(defconst test-org-capture-popup--sample-templates
+ '(("t" "Task" entry (function cj/--org-capture-project-location)
+ "* TODO %?" :prepend t)
+ ("b" "Bug" entry (function cj/--org-capture-project-location)
+ "* TODO [#C] %?" :prepend t)
+ ("e" "Event" entry (file+headline schedule-file "Scheduled Events")
+ "* %?" :prepend t :prepare-finalize cj/org-capture-format-event-headline)
+ ("m" "Mu4e Email" entry (file+headline inbox-file "Inbox") "* TODO %?" :prepend t)
+ ("L" "Link" entry (file+headline inbox-file "Inbox") "* %?" :immediate-finish t)
+ ("d" "Drill Question" entry (file ignore) "* Item :drill:\n%?" :prepend t))
+ "A representative org-capture-templates list for popup-subset tests.")
+
+;;; cj/org-capture--popup-sole-window-p
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-select-menu ()
+ "Normal: the *Org Select* menu in the popup frame wants the sole window."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "*Org Select*")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-capture-buffer ()
+ "Normal: a CAPTURE-* buffer in the popup frame wants the sole window."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "CAPTURE-todo.org")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-capture-prefix-only ()
+ "Boundary: the bare \"CAPTURE-\" prefix still matches."
+ (should (cj/org-capture--popup-sole-window-p "org-capture" "CAPTURE-")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-other-frame ()
+ "Boundary: the same menu in a normal frame is left alone."
+ (should-not (cj/org-capture--popup-sole-window-p "emacs" "*Org Select*"))
+ (should-not (cj/org-capture--popup-sole-window-p nil "CAPTURE-todo.org")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-other-buffer ()
+ "Boundary: an unrelated buffer in the popup frame is left alone."
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" "todo.org"))
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" "*scratch*")))
+
+(ert-deftest test-org-capture-config-popup-sole-window-p-nil-buffer ()
+ "Error: a nil or non-string buffer name returns nil without raising."
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" nil))
+ (should-not (cj/org-capture--popup-sole-window-p "org-capture" 42)))
+
+;;; Integration: the display-buffer-alist entry routes to a sole window
+
+(ert-deftest test-integration-org-capture-popup-display-sole-window ()
+ "Integration: in an \"org-capture\"-named frame, displaying a CAPTURE-*
+buffer fills the frame's sole window via the registered display-buffer-alist
+entry, instead of splitting.
+
+Components integrated:
+- cj/org-capture--popup-display-condition (real)
+- cj/org-capture--display-sole-window (real)
+- display-buffer / display-buffer-alist (real)
+
+Validates the popup frame ends with one window showing the CAPTURE buffer."
+ ;; The batch frame is auto-named (\"F1\"), which cannot be restored by name
+ ;; (\"F<num> usurped by Emacs\"); reset to nil to return it to auto-naming,
+ ;; keeping the test independent of execution order.
+ (let ((buf (get-buffer-create "CAPTURE-itest")))
+ (unwind-protect
+ (progn
+ (set-frame-parameter nil 'name "org-capture")
+ (delete-other-windows)
+ (display-buffer buf)
+ (should (= (length (window-list)) 1))
+ (should (eq (window-buffer (selected-window)) buf)))
+ (set-frame-parameter nil 'name nil)
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+;;; cj/--org-capture-popup-templates (pure subset/retarget)
+
+(ert-deftest test-org-capture-config-popup-templates-keeps-tbe ()
+ "Normal: only Task, Bug, Event survive, preserving order."
+ (should (equal (mapcar #'car (cj/--org-capture-popup-templates
+ test-org-capture-popup--sample-templates "/inbox.org"))
+ '("t" "b" "e"))))
+
+(ert-deftest test-org-capture-config-popup-templates-retargets-task-bug ()
+ "Normal: Task and Bug retarget to the inbox \"Inbox\" headline; body + props kept."
+ (let* ((result (cj/--org-capture-popup-templates
+ test-org-capture-popup--sample-templates "/inbox.org"))
+ (task (assoc "t" result))
+ (bug (assoc "b" result)))
+ (should (equal (nth 3 task) '(file+headline "/inbox.org" "Inbox")))
+ (should (equal (nth 3 bug) '(file+headline "/inbox.org" "Inbox")))
+ (should (equal (nth 4 task) "* TODO %?"))
+ (should (equal (nth 4 bug) "* TODO [#C] %?"))
+ (should (memq :prepend task))))
+
+(ert-deftest test-org-capture-config-popup-templates-event-unchanged ()
+ "Boundary: Event passes through untouched, schedule-file target and props intact."
+ (let ((event (assoc "e" (cj/--org-capture-popup-templates
+ test-org-capture-popup--sample-templates "/inbox.org"))))
+ (should (equal (nth 3 event) '(file+headline schedule-file "Scheduled Events")))
+ (should (memq :prepare-finalize event))))
+
+(ert-deftest test-org-capture-config-popup-templates-drops-context-templates ()
+ "Boundary: context-dependent templates (mu4e, link, drill) are dropped."
+ (let ((result (cj/--org-capture-popup-templates
+ test-org-capture-popup--sample-templates "/inbox.org")))
+ (should-not (assoc "m" result))
+ (should-not (assoc "L" result))
+ (should-not (assoc "d" result))))
+
+(ert-deftest test-org-capture-config-popup-templates-empty ()
+ "Error/Boundary: empty or all-dropped input yields nil without raising."
+ (should-not (cj/--org-capture-popup-templates nil "/inbox.org"))
+ (should-not (cj/--org-capture-popup-templates
+ '(("L" "Link" entry (file+headline f "Inbox") "* %?")) "/inbox.org")))
+
+;;; cj/quick-capture (binds the subset; integration with a stubbed org-capture)
+
+(ert-deftest test-integration-org-capture-quick-capture-binds-subset ()
+ "Integration: cj/quick-capture runs org-capture with only Task/Bug/Event,
+Task and Bug retargeted to the inbox.
+
+Components integrated:
+- cj/quick-capture (real)
+- cj/--org-capture-popup-templates (real)
+- org-capture (MOCKED — records the dynamically-bound templates)"
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ captured)
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&rest _) (setq captured org-capture-templates))))
+ (cj/quick-capture))
+ (should (equal (mapcar #'car captured) '("t" "b" "e")))
+ (should (equal (nth 3 (assoc "t" captured)) (list 'file+headline inbox-file "Inbox")))
+ (should (equal (nth 3 (assoc "b" captured)) (list 'file+headline inbox-file "Inbox")))))
+
+(ert-deftest test-integration-org-capture-quick-capture-closes-frame-on-abort ()
+ "Integration: when selection aborts (org-capture signals), cj/quick-capture
+deletes the popup frame instead of leaving it orphaned.
+
+Components integrated:
+- cj/quick-capture (real)
+- org-capture (MOCKED — signals user-error \"Abort\")
+- cj/org-capture--delete-popup-frame (MOCKED — records the call)"
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (deleted 0))
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&rest _) (user-error "Abort")))
+ ((symbol-function 'cj/org-capture--delete-popup-frame)
+ (lambda () (cl-incf deleted))))
+ (cj/quick-capture))
+ (should (= deleted 1))))
+
+(ert-deftest test-integration-org-capture-quick-capture-closes-frame-on-quit ()
+ "Integration: a C-g (quit) during capture also closes the popup frame."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (deleted 0))
+ (cl-letf (((symbol-function 'org-capture)
+ (lambda (&rest _) (signal 'quit nil)))
+ ((symbol-function 'cj/org-capture--delete-popup-frame)
+ (lambda () (cl-incf deleted))))
+ (cj/quick-capture))
+ (should (= deleted 1))))
+
+(ert-deftest test-integration-org-capture-quick-capture-keeps-frame-on-success ()
+ "Integration: a successful capture (no signal) does NOT delete the frame —
+the finalize hook owns that."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (deleted 0))
+ (cl-letf (((symbol-function 'org-capture) (lambda (&rest _) nil))
+ ((symbol-function 'cj/org-capture--delete-popup-frame)
+ (lambda () (cl-incf deleted))))
+ (cj/quick-capture))
+ (should (= deleted 0))))
+
+;;; cj/--org-capture-popup-strip-specials (drop the Customize menu entry)
+
+(ert-deftest test-org-capture-config-popup-strip-specials-removes-customize ()
+ "Normal: the \"C\" Customize entry is removed, \"q\" Abort kept, order intact."
+ (should (equal (cj/--org-capture-popup-strip-specials
+ '(("C" "Customize org-capture-templates") ("q" "Abort")))
+ '(("q" "Abort")))))
+
+(ert-deftest test-org-capture-config-popup-strip-specials-no-customize ()
+ "Boundary: specials without a \"C\" entry pass through unchanged."
+ (should (equal (cj/--org-capture-popup-strip-specials '(("q" "Abort")))
+ '(("q" "Abort")))))
+
+(ert-deftest test-org-capture-config-popup-strip-specials-empty ()
+ "Error/Boundary: nil specials yields nil without raising."
+ (should-not (cj/--org-capture-popup-strip-specials nil)))
+
+;;; cj/org-capture--popup-frame-p
+
+(ert-deftest test-org-capture-config-popup-frame-p ()
+ "Normal/Boundary: true only when the selected frame is named \"org-capture\"."
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (&rest _) "org-capture")))
+ (should (cj/org-capture--popup-frame-p)))
+ (cl-letf (((symbol-function 'frame-parameter) (lambda (&rest _) "emacs")))
+ (should-not (cj/org-capture--popup-frame-p))))
+
+;;; cj/org-capture--popup-mks-advice (frame-gated specials stripping)
+
+(ert-deftest test-org-capture-config-popup-mks-advice-strips-in-popup ()
+ "Integration: in the popup frame, org-mks receives specials without \"C\"."
+ (let (seen)
+ (cl-letf (((symbol-function 'cj/org-capture--popup-frame-p) (lambda () t)))
+ (cj/org-capture--popup-mks-advice
+ (lambda (_table _title _prompt specials) (setq seen specials))
+ nil nil nil '(("C" "Customize org-capture-templates") ("q" "Abort"))))
+ (should (equal seen '(("q" "Abort"))))))
+
+(ert-deftest test-org-capture-config-popup-mks-advice-keeps-elsewhere ()
+ "Integration: in a normal frame, org-mks receives the specials untouched."
+ (let (seen)
+ (cl-letf (((symbol-function 'cj/org-capture--popup-frame-p) (lambda () nil)))
+ (cj/org-capture--popup-mks-advice
+ (lambda (_table _title _prompt specials) (setq seen specials))
+ nil nil nil '(("C" "Customize org-capture-templates") ("q" "Abort"))))
+ (should (equal seen '(("C" "Customize org-capture-templates") ("q" "Abort"))))))
+
+;;; cj/org-capture--popup-frame (find the popup frame by name)
+
+(ert-deftest test-org-capture-config-popup-frame-found ()
+ "Normal: returns the live frame whose name is \"org-capture\"."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fb fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter)
+ (lambda (f _p) (if (eq f 'fb) "org-capture" "other"))))
+ (should (eq (cj/org-capture--popup-frame) 'fb))))
+
+(ert-deftest test-org-capture-config-popup-frame-none ()
+ "Boundary: no popup frame present yields nil."
+ (cl-letf (((symbol-function 'frame-list) (lambda () '(fa fc)))
+ ((symbol-function 'frame-live-p) (lambda (_f) t))
+ ((symbol-function 'frame-parameter) (lambda (_f _p) "other")))
+ (should-not (cj/org-capture--popup-frame))))
+
+;;; cj/quick-capture targets the popup frame
+
+(ert-deftest test-integration-org-capture-quick-capture-selects-named-frame ()
+ "Integration: cj/quick-capture selects the \"org-capture\" frame found by name,
+not whatever frame happens to be selected (the emacsclient -c focus race)."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (focused nil))
+ (cl-letf (((symbol-function 'cj/org-capture--popup-frame) (lambda () 'popup-frame))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f) (setq focused f)))
+ ((symbol-function 'org-capture) (lambda (&rest _) nil)))
+ (cj/quick-capture))
+ (should (eq focused 'popup-frame))))
+
+(ert-deftest test-integration-org-capture-quick-capture-no-frame-still-captures ()
+ "Integration: when no popup frame is found, cj/quick-capture skips the focus
+call and still runs the capture (no error)."
+ (let ((org-capture-templates test-org-capture-popup--sample-templates)
+ (focused 'unset)
+ (captured nil))
+ (cl-letf (((symbol-function 'cj/org-capture--popup-frame) (lambda () nil))
+ ((symbol-function 'select-frame-set-input-focus)
+ (lambda (f) (setq focused f)))
+ ((symbol-function 'org-capture) (lambda (&rest _) (setq captured t))))
+ (cj/quick-capture))
+ (should (eq focused 'unset))
+ (should captured)))
+
+(provide 'test-org-capture-config-popup-window)
+;;; test-org-capture-config-popup-window.el ends here
diff --git a/todo.org b/todo.org
index 33dcc373..8c18109a 100644
--- a/todo.org
+++ b/todo.org
@@ -4327,6 +4327,14 @@ From the 2026-06-11 messenger-unification brainstorm. Google Voice has no offici
** TODO Manual testing and validation
Exercised once the phases above land.
+*** TODO org-capture quick-capture popup behaves correctly
+What we're verifying: the Hyprland Super+Shift+N popup is single-window, offers only the sensible templates, files to the inbox, and never orphans its frame (archsetup request, 2026-06-12; fix in modules/org-capture-config.el, live in the daemon). The menu-subset / inbox-target / abort-close parts need archsetup's one-line script change to call cj/quick-capture (note sent 2026-06-12); the single-window part is live regardless.
+- Press Super+Shift+N to open the quick-capture popup
+- The *Org Select* menu should fill the frame as one window (no top sliver of your last-visited buffer, one modeline) and list only Task / Bug / Event
+- Pick Task (t): the CAPTURE buffer also fills the frame as one window; finishing with C-c C-c files it to the global inbox under "Inbox" (not a project's todo.org)
+- Re-open and pick Event (e): it prompts for a date and files to the schedule
+- Re-open and hit q (or C-g) at the menu: the popup frame closes (no orphan)
+Expected: single window at every step; menu limited to Task/Bug/Event; Task/Bug land in the inbox; aborting at the menu closes the frame; the frame still closes on normal finalize and C-c C-k.
*** 2026-06-11 Thu @ 18:29:39 -0500 Verified UI-face preview and contrast survive a ground bg change
Craig walked the repro: mode-line with its own fg/bg kept its preview bg and ratio through a ground change; ground-dependent rows re-rated; package-faces contrast column updated. Pass. Closed the [#A] contrast-cell and [#B] preview-bg parents.
*** 2026-06-11 Thu @ 18:29:39 -0500 Verified seeded package-face defaults, with steel tuning