diff options
Diffstat (limited to 'modules/org-capture-config.el')
| -rw-r--r-- | modules/org-capture-config.el | 224 |
1 files changed, 223 insertions, 1 deletions
diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el index 43b42b5e..393f1d97 100644 --- a/modules/org-capture-config.el +++ b/modules/org-capture-config.el @@ -42,6 +42,8 @@ (declare-function org-get-heading "org") (declare-function org-parse-time-string "org") (declare-function pdf-view-active-region-text "pdf-view") +(declare-function projectile-project-root "projectile" (&optional dir)) +(defvar inbox-file) (defvar cj/org-capture--file-headline-target-cache (make-hash-table :test #'equal) "Cache Org capture file+headline target markers by expanded file and headline.") @@ -132,6 +134,88 @@ re-scanning large target files after the first successful lookup." (advice-add 'org-capture-set-target-location :around #'cj/org-capture--set-target-location-advice)) +;; ----------------------- Project-Aware Capture Target ------------------------ +;; C-c c t (Task) and C-c c b (Bug) file into the current projectile project's +;; todo.org under its "... Open Work" heading. Outside a project they fall back +;; to the global inbox; in a project with no todo.org they fall back to the +;; inbox with a warning (they never create a project's todo.org). + +(defconst cj/--org-open-work-heading-regexp + "^\\*[ \t]+.*Open Work\\(?:[ \t]+:[^\n]*:\\)?[ \t]*$" + "Regexp matching a top-level \"... Open Work\" Org heading line.") + +(defun cj/--org-capture-project-name (root) + "Return a display project name for ROOT directory, or nil. +The basename of ROOT with a single leading dot stripped and the first +letter upcased: \"~/.emacs.d/\" -> \"Emacs.d\", \"~/code/duet/\" -> \"Duet\"." + (when (and (stringp root) (not (string-empty-p root))) + (let* ((base (file-name-nondirectory (directory-file-name root))) + (clean (if (and (> (length base) 1) (eq ?. (aref base 0))) + (substring base 1) + base))) + (and (not (string-empty-p clean)) + (concat (upcase (substring clean 0 1)) (substring clean 1)))))) + +(defun cj/--org-capture-project-target (root inbox) + "Pure capture-target decision for project-aware capture. +ROOT is the projectile project root (or nil); INBOX is the global inbox +file path. Return a plist (:file F :open-work BOOL :project NAME :warn MSG): +- ROOT with a todo.org -> F is that todo.org, :open-work t. +- ROOT without a todo.org -> F is INBOX, :open-work nil, :warn names the project. +- ROOT nil -> F is INBOX, :open-work nil, :warn nil." + (if (and (stringp root) (not (string-empty-p root))) + (let ((todo (expand-file-name "todo.org" root)) + (name (cj/--org-capture-project-name root))) + (if (file-exists-p todo) + (list :file todo :open-work t :project name :warn nil) + (list :file inbox :open-work nil :project name + :warn (format "No todo.org in project \"%s\"; captured to the inbox instead" + name)))) + (list :file inbox :open-work nil :project nil :warn nil))) + +(defun cj/--org-capture-goto-open-work (project-name) + "Move point to a top-level \"... Open Work\" heading in the current buffer. +Create \"* PROJECT-NAME Open Work\" at end of buffer when none exists. +Leave point at the start of the heading line." + (goto-char (point-min)) + (if (re-search-forward cj/--org-open-work-heading-regexp nil t) + (forward-line 0) + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert (format "* %s Open Work\n" project-name)) + (forward-line -1))) + +(defun cj/--org-capture-goto-exact-headline (headline) + "Move point to the top-level HEADLINE in the current buffer. +Create \"* HEADLINE\" at end of buffer when absent. Leave point at the +start of the heading line." + (goto-char (point-min)) + (if (re-search-forward (format org-complex-heading-regexp-format + (regexp-quote headline)) + nil t) + (forward-line 0) + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert "* " headline "\n") + (forward-line -1))) + +(defun cj/--org-capture-project-location () + "Org-capture `function' target for project-aware Task/Bug capture. +File into the current projectile project's todo.org under its \"... Open +Work\" heading, else the global inbox (`inbox-file') under \"Inbox\"." + (let* ((root (and (fboundp 'projectile-project-root) + (ignore-errors (projectile-project-root)))) + (plan (cj/--org-capture-project-target root inbox-file))) + (when (plist-get plan :warn) + (message "%s" (plist-get plan :warn))) + (set-buffer (org-capture-target-buffer (plist-get plan :file))) + (unless (derived-mode-p 'org-mode) (org-mode)) + (org-capture-put-target-region-and-position) + (widen) + (if (plist-get plan :open-work) + (cj/--org-capture-goto-open-work (plist-get plan :project)) + (cj/--org-capture-goto-exact-headline "Inbox")))) + ;; --------------------------- Org-Capture Templates --------------------------- ;; you can bring up the org capture menu with C-c c @@ -201,9 +285,12 @@ Intended to be called within an org capture template." ;; ORG-CAPTURE TEMPLATES (setq org-protocol-default-template-key "L") (setq org-capture-templates - '(("t" "Task" entry (file+headline inbox-file "Inbox") + '(("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") "* %?%:description SCHEDULED: %^t%(cj/org-capture-event-content) @@ -257,5 +344,140 @@ Captured On: %U" :prepend t) )) ;; end setq ) ;; end use-package org-protocol +;; ---------------------- Popup Capture Frame Auto-Close ---------------------- +;; The quick-capture script (Hyprland Super+Shift+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) + +;; 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. |
