From fab07b7519d6eadf86d8dae0b92bf490fc6b67cc Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 13 Jun 2026 00:51:13 -0500 Subject: fix(org-capture): make the desktop quick-capture popup behave The Hyprland Super+Shift+N popup opens a floating emacsclient frame named "org-capture" and runs a capture in it. Three things were wrong. The *Org Select* menu and the CAPTURE buffer split the small frame instead of filling it, so the daemon's last buffer leaked in beside two tmux-like modelines. The menu offered every template, including ones that can't work off the desktop: the pdf templates error outright, and the link and mu4e templates pull a link to whatever file the daemon last had open. And the frame only closed on a completed capture, so aborting at the menu or hitting an erroring template orphaned it. I added cj/quick-capture as the launcher's entry point instead of org-capture. It offers only Task, Bug, and Event, with Task and Bug retargeted to the global inbox since a desktop capture has no real project context, and it closes the popup frame on every exit path: finalize, abort, or error. A frame-scoped display-buffer-alist entry forces the menu and capture buffer into the frame's sole window, and an org-mks advice drops the Customize entry. Both gate on the "org-capture" frame name, so in-Emacs captures keep their windows. cj/quick-capture selects the "org-capture" frame by name before capturing rather than trusting the selected frame. The launcher runs before Hyprland settles focus on the new float, so the selected frame is still the main one and the capture would otherwise land there. Raised from the archsetup project. --- modules/org-capture-config.el | 123 ++++++++++- tests/test-org-capture-config-popup-window.el | 281 ++++++++++++++++++++++++++ todo.org | 8 + 3 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 tests/test-org-capture-config-popup-window.el 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 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 -- cgit v1.2.3