diff options
| -rw-r--r-- | modules/org-capture-config.el | 47 | ||||
| -rw-r--r-- | tests/test-org-capture-config-popup-window.el | 35 |
2 files changed, 58 insertions, 24 deletions
diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el index 9f5bfbe7f..14fb8e582 100644 --- a/modules/org-capture-config.el +++ b/modules/org-capture-config.el @@ -345,22 +345,43 @@ Captured On: %U" :prepend t) ) ;; end use-package org-protocol ;; ---------------------- Popup Capture Frame Auto-Close ---------------------- -;; The quick-capture script (Hyprland Super+Shift+N) opens an emacsclient +;; The quick-capture script (Hyprland Super+N) opens an emacsclient ;; frame named "org-capture"; Hyprland window rules float and center it by ;; that name. These hooks close the frame when the capture finalizes or ;; 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 (cj/org-capture--popup-frame-p) - (delete-frame))) - -(add-hook 'org-capture-after-finalize-hook #'cj/org-capture--delete-popup-frame) +(defun cj/org-capture--frame-reapable-p (frame-name buffer-names) + "Non-nil when a frame named FRAME-NAME showing BUFFER-NAMES is a reapable popup. +Reapable means the quick-capture popup (FRAME-NAME equal to \"org-capture\") with +no capture UI left in any window — no *Org Select* menu and no CAPTURE-* buffer. +A popup still mid-capture has capture UI and is not reapable, so it is spared." + (and (equal frame-name "org-capture") + (not (seq-some (lambda (b) + (cj/org-capture--popup-sole-window-p frame-name b)) + buffer-names)))) + +(defun cj/org-capture-reap-popup-frames () + "Delete every quick-capture popup frame that no longer shows capture UI. +Reaps across ALL frames, not just the selected one: a capture that finalizes, +aborts, or errors while the daemon's selected frame is something else (the common +multi-frame case) still cleans up its \"org-capture\" popup, while a popup +mid-capture is spared. Never deletes the last remaining frame. Safe to call +anytime — bound to nothing, run via M-x when a stray popup needs clearing." + (interactive) + (dolist (f (frame-list)) + (when (and (frame-live-p f) + (cdr (frame-list)) ; never delete the last frame + (cj/org-capture--frame-reapable-p + (frame-parameter f 'name) + (mapcar (lambda (w) (buffer-name (window-buffer w))) + (window-list f 'no-minibuf)))) + (delete-frame f)))) + +;; Reap on every capture exit. `remove-hook' first so a live module reload swaps +;; the retired narrow (selected-frame) handler for this one without leaving both. +(remove-hook 'org-capture-after-finalize-hook #'cj/org-capture--delete-popup-frame) +(add-hook 'org-capture-after-finalize-hook #'cj/org-capture-reap-popup-frames) ;; The popup opens a fresh emacsclient frame still showing the daemon's last ;; buffer. `org-mks' shows the *Org Select* menu via @@ -439,9 +460,9 @@ daemon's main frame and the capture would otherwise land there." (when frame (select-frame-set-input-focus frame)) (let ((org-capture-templates (cj/--quick-capture-template inbox-file))) (org-capture nil "t"))) - (quit (cj/org-capture--delete-popup-frame)) + (quit (cj/org-capture-reap-popup-frames)) (error (message "Quick-capture: %s" (error-message-string err)) - (cj/org-capture--delete-popup-frame))))) + (cj/org-capture-reap-popup-frames))))) (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 index 671d55ab9..af96ba012 100644 --- a/tests/test-org-capture-config-popup-window.el +++ b/tests/test-org-capture-config-popup-window.el @@ -110,11 +110,11 @@ 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)" +- cj/org-capture-reap-popup-frames (MOCKED — records the call)" (let ((deleted 0)) (cl-letf (((symbol-function 'org-capture) (lambda (&rest _) (user-error "Abort"))) - ((symbol-function 'cj/org-capture--delete-popup-frame) + ((symbol-function 'cj/org-capture-reap-popup-frames) (lambda () (cl-incf deleted)))) (cj/quick-capture)) (should (= deleted 1)))) @@ -124,7 +124,7 @@ Components integrated: (let ((deleted 0)) (cl-letf (((symbol-function 'org-capture) (lambda (&rest _) (signal 'quit nil))) - ((symbol-function 'cj/org-capture--delete-popup-frame) + ((symbol-function 'cj/org-capture-reap-popup-frames) (lambda () (cl-incf deleted)))) (cj/quick-capture)) (should (= deleted 1)))) @@ -134,19 +134,32 @@ Components integrated: the finalize hook owns that." (let ((deleted 0)) (cl-letf (((symbol-function 'org-capture) (lambda (&rest _) nil)) - ((symbol-function 'cj/org-capture--delete-popup-frame) + ((symbol-function 'cj/org-capture-reap-popup-frames) (lambda () (cl-incf deleted)))) (cj/quick-capture)) (should (= deleted 0)))) -;;; cj/org-capture--popup-frame-p +;;; cj/org-capture--frame-reapable-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)))) +(ert-deftest test-org-capture-config-frame-reapable-p-no-capture-ui () + "Normal: an \"org-capture\" frame showing only non-capture buffers is reapable." + (should (cj/org-capture--frame-reapable-p + "org-capture" '("agent [.emacs.d]" "*dashboard*")))) + +(ert-deftest test-org-capture-config-frame-reapable-p-capture-buffer-spares () + "Boundary: a CAPTURE-* buffer means the popup is mid-capture — not reapable." + (should-not (cj/org-capture--frame-reapable-p + "org-capture" '("CAPTURE-todo.org" "*dashboard*")))) + +(ert-deftest test-org-capture-config-frame-reapable-p-select-menu-spares () + "Boundary: the *Org Select* template menu means mid-capture — not reapable." + (should-not (cj/org-capture--frame-reapable-p + "org-capture" '("*Org Select*")))) + +(ert-deftest test-org-capture-config-frame-reapable-p-other-frame-never () + "Error: a frame not named \"org-capture\" is never reapable, even when empty." + (should-not (cj/org-capture--frame-reapable-p + "Emacs 30.2 : agent [.emacs.d]" '("agent [.emacs.d]")))) ;;; cj/org-capture--popup-frame (find the popup frame by name) |
