diff options
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/ai-term.el | 21 | ||||
| -rw-r--r-- | modules/calibredb-epub-config.el | 133 | ||||
| -rw-r--r-- | modules/custom-buffer-file.el | 10 | ||||
| -rw-r--r-- | modules/dashboard-config.el | 9 | ||||
| -rw-r--r-- | modules/dirvish-config.el | 20 | ||||
| -rw-r--r-- | modules/duet-config.el | 19 | ||||
| -rw-r--r-- | modules/dwim-shell-config.el | 23 | ||||
| -rw-r--r-- | modules/help-config.el | 49 | ||||
| -rw-r--r-- | modules/linear-config.el | 58 | ||||
| -rw-r--r-- | modules/mail-config.el | 34 | ||||
| -rw-r--r-- | modules/markdown-config.el | 10 | ||||
| -rw-r--r-- | modules/music-config.el | 5 | ||||
| -rw-r--r-- | modules/org-capture-config.el | 224 | ||||
| -rw-r--r-- | modules/org-drill-config.el | 9 | ||||
| -rw-r--r-- | modules/org-roam-config.el | 10 | ||||
| -rw-r--r-- | modules/pearl-config.el | 66 | ||||
| -rw-r--r-- | modules/prog-general.el | 16 | ||||
| -rw-r--r-- | modules/reconcile-open-repos.el | 5 | ||||
| -rw-r--r-- | modules/selection-framework.el | 10 | ||||
| -rw-r--r-- | modules/signal-config.el | 61 | ||||
| -rw-r--r-- | modules/system-commands.el | 22 | ||||
| -rw-r--r-- | modules/system-defaults.el | 5 | ||||
| -rw-r--r-- | modules/system-lib.el | 11 | ||||
| -rw-r--r-- | modules/term-config.el | 31 | ||||
| -rw-r--r-- | modules/ui-navigation.el | 12 | ||||
| -rw-r--r-- | modules/user-constants.el | 8 |
26 files changed, 728 insertions, 153 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el index 1384f812..baf752fe 100644 --- a/modules/ai-term.el +++ b/modules/ai-term.el @@ -54,8 +54,10 @@ ;; instead of toggling the current one. ;; - M-F9 `cj/ai-term-close' -- gracefully close an agent: kill its ;; tmux session (stopping the agent process), then its terminal -;; buffer and window. Confirms first. Targets the current -;; agent, the sole live agent, or prompts among several. +;; buffer. Its window stays in the layout (swapped to the +;; working buffer), so closing never collapses a split. Confirms +;; first. Targets the current agent, the sole live agent, or +;; prompts among several. ;; - C-S-F9 `cj/ai-term-close' -- same close command, second binding. ;; (M-F9 is the primary; C-S-F9 may be swallowed by the ;; Wayland/PGTK layer on some machines.) @@ -859,12 +861,14 @@ down." (error nil))) (defun cj/--ai-term-close-buffer (buffer) - "Gracefully tear down AI-term BUFFER: tmux session, window, buffer. + "Gracefully tear down AI-term BUFFER: tmux session, then buffer. Derives the tmux session name from BUFFER's `default-directory' (the project dir the terminal was created in) and kills it so the agent -process stops. Deletes BUFFER's window when it's shown and isn't the -only window in its frame, then kills BUFFER (suppressing the +process stops. When BUFFER is shown, swaps its window to a non-agent +buffer (the working file) rather than deleting the window -- closing an +agent must not collapse the user's window layout; the F9 hide toggle is +what collapses the split. Then kills BUFFER (suppressing the process-still-running prompt -- the session is already down). No-op when BUFFER isn't an AI-term buffer." (when (cj/--ai-term-buffer-p buffer) @@ -872,8 +876,11 @@ when BUFFER isn't an AI-term buffer." (cj/--ai-term-tmux-session-name (buffer-local-value 'default-directory buffer))) (let ((win (get-buffer-window buffer))) - (when (and win (> (length (window-list (window-frame win) 'never)) 1)) - (delete-window win))) + (when (window-live-p win) + (with-selected-window win + (switch-to-buffer + (or (cj/--ai-term-most-recent-non-agent-buffer) + (other-buffer buffer t)))))) (let ((kill-buffer-query-functions nil)) (kill-buffer buffer)))) diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el index 4243e509..a17bf8c9 100644 --- a/modules/calibredb-epub-config.el +++ b/modules/calibredb-epub-config.el @@ -51,6 +51,7 @@ (require 'user-constants) ;; for books-dir (require 'subr-x) +(require 'transient) ;; cj/calibredb-menu is a transient prefix ;; Declare functions from lazy-loaded packages (declare-function calibredb-find-create-search-buffer "calibredb" ()) @@ -59,6 +60,24 @@ (declare-function nov-render-document "nov" ()) (defvar nov-text-width) ; from nov.el; set buffer-local here +;; calibredb commands the curated menu drives (all autoloaded by calibredb) +(declare-function calibredb-switch-library "calibredb" ()) +(declare-function calibredb-filter-by-book-format "calibredb" ()) +(declare-function calibredb-filter-by-author-sort "calibredb" ()) +(declare-function calibredb-search-clear-filter "calibredb" ()) +(declare-function calibredb-sort-by-author "calibredb" ()) +(declare-function calibredb-sort-by-title "calibredb" ()) +(declare-function calibredb-sort-by-pubdate "calibredb" ()) +(declare-function calibredb-sort-by-format "calibredb" ()) +(declare-function calibredb-find-file "calibredb" ()) +(declare-function calibredb-dispatch "calibredb" ()) +(declare-function calibredb-show-entry "calibredb" (entry &optional switch)) +(declare-function calibredb-find-candidate-at-point "calibredb" ()) +(declare-function calibredb-search-refresh-or-resume "calibredb" (&optional begin position)) +(defvar calibredb-show-entry-switch) ; from calibredb-show.el +(defvar calibredb-sort-by) ; from calibredb-core.el +(defvar calibredb-search-filter) ; from calibredb-search.el + ;; -------------------------- CalibreDB Ebook Manager -------------------------- (defun cj/calibredb-clear-filters () @@ -73,6 +92,23 @@ ;; empty string resets keyword filter and refreshes listing (calibredb-search-keyword-filter "")) +(defun cj/calibredb-describe-at-point () + "Show the book at point in the docked *calibredb-entry* buffer. +Displays the entry without switching focus back to the list, so it lands +in the bottom-docked window (see the `display-buffer-alist' entry below) +and q (`calibredb-entry-quit') dismisses it." + (interactive) + (calibredb-show-entry (car (calibredb-find-candidate-at-point)))) + +(defun cj/--calibredb-sort-preserving-filter (field) + "Set `calibredb-sort-by' to FIELD and refresh, keeping the active filter. +calibredb's own `calibredb-sort-by-*' commands refresh with +`calibredb-search-refresh-and-clear-filter', which drops the active filter +on every sort. This refreshes with `calibredb-search-refresh-or-resume', +which re-applies `calibredb-search-filter' instead." + (setq calibredb-sort-by field) + (calibredb-search-refresh-or-resume)) + (use-package calibredb :commands calibredb :bind @@ -80,7 +116,10 @@ ;; use built-in filter by tag, add clear-filters (:map calibredb-search-mode-map ("l" . calibredb-filter-by-tag) - ("L" . cj/calibredb-clear-filters)) + ("L" . cj/calibredb-clear-filters) + ;; "?" -> curated menu of frequent workflows; "H" -> the full dispatch + ("?" . cj/calibredb-menu) + ("H" . calibredb-dispatch)) :config ;; basic config (setq calibredb-root-dir books-dir) @@ -88,6 +127,50 @@ (setq calibredb-program "/usr/bin/calibredb") (setq calibredb-preferred-format "epub") (setq calibredb-search-page-max-rows 500) + ;; Dock the book-detail buffer to the bottom 30%; q dismisses it. + ;; `pop-to-buffer' honours `display-buffer-alist' (the default + ;; `switch-to-buffer-other-window' would not). + (setq calibredb-show-entry-switch #'pop-to-buffer) + (add-to-list 'display-buffer-alist + '("\\`\\*calibredb-entry\\*\\'" + (display-buffer-at-bottom) + (window-height . 0.3))) + ;; A curated menu of the frequent calibredb workflows, bound to `?' in the + ;; search buffer; calibredb's own full dispatch (the wall of every command) + ;; moves to `H'. Defined here in `:config' so it only builds once calibredb + ;; (and its matching transient) is loaded. This is the "? brings up a + ;; discoverable help menu" convention. + (transient-define-prefix cj/calibredb-menu () + "Frequent calibredb workflows." + [["Library" + ("l" "switch library" calibredb-switch-library)] + ["Filter" + ("f" "format" calibredb-filter-by-book-format) + ("a" "author" calibredb-filter-by-author-sort) + ("x" "reset filter" calibredb-search-clear-filter)] + ["Sort" + ("A" "author (last name)" calibredb-sort-by-author) + ("t" "title" calibredb-sort-by-title) + ("p" "pubdate" calibredb-sort-by-pubdate) + ("g" "group by format" calibredb-sort-by-format)] + ["Book" + ("o" "open" calibredb-find-file) + ("d" "describe" cj/calibredb-describe-at-point) + ("H" "full calibredb menu" calibredb-dispatch)]] + [("q" "quit" transient-quit-one)]) + + ;; Keep the active filter when sorting. calibredb's macro-generated + ;; `calibredb-sort-by-*' commands refresh-and-clear-filter, dropping the + ;; filter on every sort; override each to refresh-or-resume so the filter + ;; survives. Named advice keeps the override idempotent across reloads. + (dolist (field '(id title author format date pubdate tag size language)) + (let ((cmd (intern (format "calibredb-sort-by-%s" field))) + (adv (intern (format "cj/--calibredb-sort-keep-filter-%s" field))) + (f field)) + (defalias adv + (lambda (&rest _) (interactive) (cj/--calibredb-sort-preserving-filter f)) + (format "Sort by %s, keeping the active filter (override)." field)) + (advice-add cmd :override adv))) ;; search window display (setq calibredb-size-show nil) @@ -327,6 +410,54 @@ Try to use the Calibre book id from the parent folder name (for example, ("t" . nov-goto-toc) ("C-c C-b" . cj/nov-jump-to-calibredb))) +;; ------------------------- Nov bookmark naming ------------------------------- +;; In a nov buffer "m" is bound to `bookmark-set' (above). nov's +;; `nov-bookmark-make-record' names the record after `(buffer-name)' -- the EPUB +;; filename, extension and all. Rebuild it as "Author, Title" parsed from the +;; filename: under Calibre's "<Title> - <Author>.epub" naming the filename is +;; more complete than the EPUB's embedded metadata (which carries truncated +;; titles and author-sort "Last, First" forms). + +(defun cj/--nov-clean-title (s) + "Clean a title or author S parsed from an EPUB filename, or nil when blank. +Restores a colon where Calibre sanitized \":\" to \"_\" (\"Frege_ A Guide\" +-> \"Frege: A Guide\"), turns any leftover underscore into a space, and +collapses runs of whitespace." + (when (stringp s) + (let* ((colon (replace-regexp-in-string "_ " ": " s)) + (spaced (replace-regexp-in-string "_" " " colon)) + (out (string-trim (replace-regexp-in-string "[ \t]+" " " spaced)))) + (and (not (string-empty-p out)) out)))) + +(defun cj/--nov-bookmark-name-from-file (path) + "Return \"Author, Title\" derived from an EPUB PATH's filename, or nil. +Splits the filename (sans extension) on its last \" - \" into title and +author per Calibre's \"<Title> - <Author>\" convention, restoring colons and +reordering to \"Author, Title\". Falls back to the cleaned whole name when +there is no \" - \" separator." + (when (and (stringp path) (not (string-empty-p path))) + (let ((base (file-name-sans-extension (file-name-nondirectory path)))) + (if (string-match "\\`\\(.+\\) - \\(.+\\)\\'" base) + (let ((title (cj/--nov-clean-title (match-string 1 base))) + (author (cj/--nov-clean-title (match-string 2 base)))) + (cond ((and author title) (format "%s, %s" author title)) + (title title) + (author author) + (t nil))) + (cj/--nov-clean-title base))))) + +(defun cj/--nov-bookmark-rename-record (record) + "Replace RECORD's bookmark name with \"Author, Title\" from its EPUB filename. +Advice (:filter-return) on `nov-bookmark-make-record'. RECORD is +\(NAME . ALIST) carrying a `filename'; left unchanged when no name derives." + (let ((name (cj/--nov-bookmark-name-from-file + (alist-get 'filename (cdr record))))) + (if name (cons name (cdr record)) record))) + +(with-eval-after-load 'nov + (advice-add 'nov-bookmark-make-record :filter-return + #'cj/--nov-bookmark-rename-record)) + (defun cj/--nov-image-padding-cols (col-width img-px font-width-px) "Return left-padding columns to center an IMG-PX-wide image in COL-WIDTH cols. FONT-WIDTH-PX is the column width in pixels; clamped up to 1 so a zero or diff --git a/modules/custom-buffer-file.el b/modules/custom-buffer-file.el index 6c3e6c6e..84faf01d 100644 --- a/modules/custom-buffer-file.el +++ b/modules/custom-buffer-file.el @@ -48,6 +48,7 @@ ;; mm-decode for email viewing (mm-handle-type is a macro, needs early require) (require 'mm-decode) (require 'external-open) ;; for cj/xdg-open, cj/open-this-file-with +(require 'system-lib) ;; cj/confirm-strong (overwrite confirms), used below ;; cj/kill-buffer-and-window and cj/kill-other-window-buffer defined in undead-buffers.el (declare-function cj/kill-buffer-and-window "undead-buffers") @@ -156,7 +157,7 @@ When called interactively, prompts for confirmation if target file exists." (condition-case _ (cj/--move-buffer-and-file dir nil) (file-already-exists - (if (yes-or-no-p (format "File %s exists; overwrite? " target)) + (if (cj/confirm-strong (format "File %s exists; overwrite? " target)) (cj/--move-buffer-and-file dir t) (message "File not moved")))))) @@ -196,7 +197,7 @@ When called interactively, prompts for confirmation if target file exists." (condition-case err (cj/--rename-buffer-and-file new-name nil) (file-already-exists - (if (yes-or-no-p (format "File %s exists; overwrite? " new-name)) + (if (cj/confirm-strong (format "File %s exists; overwrite? " new-name)) (cj/--rename-buffer-and-file new-name t) (message "File not renamed"))) (error @@ -338,7 +339,6 @@ Do not save the deleted text in the kill ring." (kill-new (buffer-name)) (message "Copied: %s" (buffer-name))) -(require 'system-lib) (declare-function ansi-color-apply-on-region "ansi-color") (defun cj/--diff-with-difftastic (file1 file2 buffer) @@ -512,8 +512,8 @@ Signals an error if: "m" #'cj/move-buffer-and-file "r" #'cj/rename-buffer-and-file "p" #'cj/copy-buffer-source-as-kill - "d" #'cj/delete-buffer-and-file - "D" #'cj/diff-buffer-with-file + "d" #'cj/diff-buffer-with-file + "D" #'cj/delete-buffer-and-file "c" cj/copy-buffer-content-map "n" #'cj/copy-buffer-name "l" #'cj/copy-link-to-buffer-file diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el index b4e4545d..3b8a3c5c 100644 --- a/modules/dashboard-config.el +++ b/modules/dashboard-config.el @@ -145,6 +145,13 @@ window." ;; --------------------------------- Dashboard --------------------------------- ;; a useful startup screen for Emacs +(defun cj/--dashboard-exclude-emms-from-recentf () + "Exclude the EMMS history file from recentf. +Adds to `recentf-exclude' so entries set elsewhere (e.g. in +system-defaults) are preserved rather than overwritten." + (require 'recentf) + (add-to-list 'recentf-exclude "/emms/history")) + (use-package dashboard :demand t :hook (emacs-startup . cj/dashboard-only) @@ -196,7 +203,7 @@ window." (setq dashboard-bookmarks-show-path nil) ;; don't show paths in bookmarks (setq dashboard-recentf-show-base t) ;; show filename, not full path (setq dashboard-recentf-item-format "%s") - (setq recentf-exclude '("/emms/history")) ;; exclude EMMS history from recent files + (cj/--dashboard-exclude-emms-from-recentf) ;; exclude EMMS history from recent files (setq dashboard-set-footer nil) ;; don't show footer and quotes ;; == navigation diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el index d9286966..79d6ff41 100644 --- a/modules/dirvish-config.el +++ b/modules/dirvish-config.el @@ -27,7 +27,7 @@ ;; - p: Copy absolute file path ;; - P: Print the file at point via CUPS ;; - S: Study — start an org-drill session on the .org file at point -;; - M-S-d (Meta-Shift-d): DWIM shell commands menu +;; - M-D (Meta-Shift-d): DWIM shell commands menu ;; - TAB: Toggle subtree expansion ;; - F11: Toggle sidebar view @@ -403,16 +403,16 @@ Uses feh on X11, swww on Wayland." ("lx" "~/archive/lectures/" "lectures") ("mb" "/media/backup/" "backup directory") ("mx" "~/music/" "music") - ("pdx" "~/projects/documents/" "project documents") - ("pdl" "~/projects/danneel/" "project danneel") - ("pcl" "~/projects/clipper/" "project clipper") + ("pdx" "~/projects/home/documents/" "documents area") + ("pdl" "~/projects/home/danneel/" "project danneel") + ("pcl" "~/projects/home/clipper/" "clipper area") ("pwk" "~/projects/work/" "project work") - ("pl" "~/projects/elibrary/" "project elibrary") - ("pf" "~/projects/finances/" "project finances") - ("pjr" "~/projects/jr-estate/" "project jr-estate") - ("phx" "~/projects/health/" "project health") - ("phl" "~/projects/homelab/" "project homelab") - ("pk" "~/projects/kit/" "project kit") + ("pl" "~/projects/home/elibrary/" "elibrary area") + ("pf" "~/projects/home/finances/" "project finances") + ("pjr" "~/projects/home/jr-estate/" "project jr-estate") + ("phx" "~/projects/home/health/" "health area") + ("phl" "~/projects/home/" "project home") + ("pk" "~/projects/home/kit/" "kit area") ("pn" "~/projects/nextjob/" "project nextjob") ("ps" ,(concat pix-dir "/screenshots/") "pictures screenshots") ("px" ,pix-dir "pictures directory") diff --git a/modules/duet-config.el b/modules/duet-config.el new file mode 100644 index 00000000..2dc7ad2e --- /dev/null +++ b/modules/duet-config.el @@ -0,0 +1,19 @@ +;;; duet-config.el --- DUET dual-pane commander configuration -*- lexical-binding: t -*- + +;;; Commentary: +;; Personal configuration glue for the DUET package, developed locally at +;; ~/code/duet. Keybindings, defcustom values, and connection storage live +;; here; the package itself stays free of personal opinions. +;; +;; Not yet required from init.el — DUET is a pre-alpha skeleton. Wire it in +;; once Stage 1 provides usable commands. + +;;; Code: + +(use-package duet + :load-path "~/code/duet" + :ensure nil + :commands (duet)) + +(provide 'duet-config) +;;; duet-config.el ends here diff --git a/modules/dwim-shell-config.el b/modules/dwim-shell-config.el index 57eea706..ad17ea91 100644 --- a/modules/dwim-shell-config.el +++ b/modules/dwim-shell-config.el @@ -98,6 +98,7 @@ ;;; Code: (require 'cl-lib) +(require 'system-lib) ;; cj/confirm-strong (permanent file destruction confirm) ;; --------------------------- Password-file helpers --------------------------- @@ -197,6 +198,18 @@ file list." (replace-regexp-in-string "'" "'\\\\''" (expand-file-name f)))) files "\n")) +(defun cj/dwim-shell--zip-single-file-command () + "Return the zip command template for a single marked file. +The archive is named =<fne>.zip=, not a reconstruction of the input filename +\(which produced invalid archives, and a `foo.' name for a directory)." + "zip -r '<<fne>>.zip' '<<f>>'") + +(defun cj/dwim-shell--dated-backup-command () + "Return the cp command template for a timestamped backup of marked file(s). +The timestamp is interpolated here with `format-time-string' so it can't sit +dead inside the shell's single quotes the way a literal =$(date ...)= did." + (format "cp -p '<<f>>' '<<f>>.%s.bak'" (format-time-string "%Y%m%d_%H%M%S"))) + ;; ----------------------------- Dwim Shell Command ---------------------------- (use-package dwim-shell-command @@ -336,7 +349,7 @@ Otherwise, unzip it to an appropriately named subdirectory " (interactive) (dwim-shell-command-on-marked-files "Zip" (if (eq 1 (seq-length (dwim-shell-command--files))) - "zip -r '<<fne>>.<<e>>' '<<f>>'" + (cj/dwim-shell--zip-single-file-command) "zip -r '<<archive.zip(u)>>' '<<*>>'") :utils "zip")) @@ -546,8 +559,8 @@ clipboard contents cannot inject shell commands." (interactive) (dwim-shell-command-on-marked-files "Backup with date" - "cp -p '<<f>>' '<<f>>.$(date +%Y%m%d_%H%M%S).bak'" - :utils '("cp" "date"))) + (cj/dwim-shell--dated-backup-command) + :utils '("cp"))) (defun cj/dwim-shell-commands-optimize-image-for-web () "Optimize image(s) for web (reduce file size)." @@ -801,7 +814,7 @@ switching off the .7z format to gpg-wrapped tar." Uses =shred -u= so the file is unlinked after overwriting, matching the \"delete\" the command name and prompt promise." (interactive) - (when (yes-or-no-p "This will permanently destroy files. Continue? ") + (when (cj/confirm-strong "This will permanently destroy files. Continue? ") (dwim-shell-command-on-marked-files "Secure delete" "shred -vfzu -n 3 '<<f>>'" @@ -929,7 +942,7 @@ gpg: decryption failed: No pinentry" ;; Bind menu to keymaps after function is defined (with-eval-after-load 'dired - (keymap-set dired-mode-map "M-S-d" #'dwim-shell-commands-menu)) ;; was M-D, overrides kill-word + (keymap-set dired-mode-map "M-D" #'dwim-shell-commands-menu)) ;; Meta-Shift-d; matches the dirvish binding below (with-eval-after-load 'dirvish (keymap-set dirvish-mode-map "M-D" #'dwim-shell-commands-menu))) diff --git a/modules/help-config.el b/modules/help-config.el index ce9fd861..df27cbea 100644 --- a/modules/help-config.el +++ b/modules/help-config.el @@ -50,24 +50,34 @@ ;; ------------------------------------ Info ----------------------------------- - (defun cj/open-with-info-mode () - "Open the current buffer's file in Info mode if it's a valid info file. +(defun cj/--info-open-plan (modified-p save-confirmed-p) + "Decide how to open a buffer in Info given its MODIFIED-P state. +SAVE-CONFIRMED-P is the answer to the save prompt, meaningful only when +MODIFIED-P. Returns `open', `save-then-open', or `cancel'." + (cond ((not modified-p) 'open) + (save-confirmed-p 'save-then-open) + (t 'cancel))) + +(defun cj/open-with-info-mode () + "Open the current buffer's file in Info mode if it's a valid info file. Preserves any unsaved changes and checks if the file exists." - (interactive) - (let ((file-name (buffer-file-name))) - (when file-name - (if (and (file-exists-p file-name) - (string-match-p "\\.info\\'" file-name)) - (progn - (when (buffer-modified-p) - (if (y-or-n-p "Buffer has unsaved changes. Save before opening in Info? ") - (save-buffer) - (message "Operation canceled") - (cl-return-from cj/open-with-info-mode))) - (kill-buffer (current-buffer)) - (info file-name)) - (message "Not a valid info file: %s" file-name))))) + (interactive) + (let ((file-name (buffer-file-name))) + (when file-name + (if (and (file-exists-p file-name) + (string-match-p "\\.info\\'" file-name)) + (let ((modified (buffer-modified-p))) + (pcase (cj/--info-open-plan + modified + (and modified + (y-or-n-p "Buffer has unsaved changes. Save before opening in Info? "))) + ('cancel (message "Operation canceled")) + (plan + (when (eq plan 'save-then-open) (save-buffer)) + (kill-buffer (current-buffer)) + (info file-name)))) + (message "Not a valid info file: %s" file-name))))) (defun cj/browse-info-files () "Browse and open .info or .info.gz files from user-emacs-directory." @@ -96,7 +106,6 @@ Preserves any unsaved changes and checks if the file exists." (:map Info-mode-map ("m" . bookmark-set) ;; Rebind 'm' from Info-menu to bookmark-set ("M" . Info-menu)) ;; Move Info-menu to 'M' instead - :preface :init ;; Add personal info files BEFORE Info mode initializes ;; (let ((personal-info-dir (expand-file-name "assets/info" user-emacs-directory))) @@ -104,11 +113,7 @@ Preserves any unsaved changes and checks if the file exists." ;; (setq Info-directory-list (list personal-info-dir)))) ;; the above makes the directory the info list. the below adds it to the default list ;; (add-to-list 'Info-default-directory-list personal-info-dir))) - :hook - (info-mode . info-persist-history-mode) - :config - ;; Make .info files open with our custom function - (add-to-list 'auto-mode-alist '("\\.info\\'" . cj/open-with-info-mode))) + ) (provide 'help-config) ;;; help-config.el ends here. diff --git a/modules/linear-config.el b/modules/linear-config.el deleted file mode 100644 index 8fbae30c..00000000 --- a/modules/linear-config.el +++ /dev/null @@ -1,58 +0,0 @@ -;;; linear-config.el --- Linear.app integration -*- lexical-binding: t; -*- -;; author: Craig Jennings <c@cjennings.net> - -;;; Commentary: -;; -;; Layer: 3 (Domain Workflow). -;; Category: D/P. -;; Load shape: deferred (command-loaded). -;; Top-level side effects: package configuration via use-package. -;; Runtime requires: none. -;; Direct test load: no. -;; -;; Near-vanilla pearl setup: close to what pearl's README documents for a -;; first-time install (local checkout instead of a package archive), with two -;; deliberate tweaks layered on after dogfooding the out-of-box experience — a -;; global C-; L prefix (see below) and the shorter assignee @-tag. -;; -;; pearl owns its own keymap. `pearl-mode' turns on automatically in any buffer -;; pearl renders (it carries a `#+LINEAR-SOURCE' header) and binds the whole -;; command surface under `pearl-keymap-prefix' (default "C-; L"). This config -;; also binds that same `pearl-prefix-map' globally under C-; L (`:bind-keymap'), -;; so the full command surface is reachable from any buffer; the first press -;; autoloads pearl. `M-x pearl-menu' / `M-x pearl-list-issues' still work too. -;; -;; Authentication: the Linear personal API key is read from authinfo.gpg. Add: -;; machine api.linear.app login apikey password lin_api_YOURKEYHERE -;; Generate it in Linear: Settings -> Security & access -> Personal API keys. - -;;; Code: - -(use-package pearl - :ensure nil ;; local checkout, not from an archive - :load-path "~/code/pearl" - :commands (pearl-menu pearl-list-issues pearl-create-issue pearl-run-linear-view) - ;; Bind pearl's command map globally under C-; L, so the full surface is - ;; reachable from any buffer (not only inside a pearl-rendered one). The - ;; first press autoloads pearl; it's the same `pearl-prefix-map' that - ;; `pearl-mode' binds in-buffer, so behavior is identical everywhere. - :bind-keymap ("C-; L" . pearl-prefix-map) - :custom - (pearl-org-file-path (expand-file-name "gtd/linear.org" org-directory)) - ;; Shorten the assignee @-tag to the first name only (e.g. @first instead of - ;; @first_last), trading disambiguation for a tighter tag line. - (pearl-assignee-tag-short t) - ;; Optional defaults — uncomment and fill in to skip the prompts. Set them - ;; HERE, at init level, not via M-x pearl-set-default-view / - ;; pearl-set-default-team: those persist through `customize-save-variable', - ;; and this config redirects `custom-file' to a throwaway temp file - ;; (system-defaults.el), so a setter's value is discarded on the next - ;; restart. These :custom lines re-apply on every startup instead. - ;; (pearl-default-view "My active work") ;; the local view `C-; L l' opens - ;; (pearl-default-team-id "9fca2cf6-390c-4102-a9ff-f94a4ed823c5") ;; DeepSat SE; skips the team prompt on create / by-project - :config - (setq pearl-api-key - (auth-source-pick-first-password :host "api.linear.app"))) - -(provide 'linear-config) -;;; linear-config.el ends here diff --git a/modules/mail-config.el b/modules/mail-config.el index f71d6eeb..dfc0c4e0 100644 --- a/modules/mail-config.el +++ b/modules/mail-config.el @@ -48,6 +48,31 @@ (defvar message-send-mail-function nil) (defvar message-sendmail-envelope-from nil) +(declare-function mu4e-message-field "mu4e-message") + +;; Refile (archive) target dispatch. A per-context `mu4e-refile-folder' string +;; is unsafe: mu4e context :vars are sticky, so a value set when one context is +;; active leaks into a later context that doesn't set its own -- archiving one +;; account's mail into another's folder. A single function evaluated per +;; message at refile time avoids that. Only cmail has a real synced Archive +;; folder; the Gmail-backed accounts (gmail, dmail) sync no archive maildir, so +;; refiling them would move mail into an unsynced, server-invisible folder +;; (silent loss) -- signal instead. +(defun cj/mu4e--refile-folder-for-maildir (maildir) + "Return the refile (archive) folder for MAILDIR, or signal when none exists. +MAILDIR is a mu4e :maildir string such as \"/cmail/INBOX\"." + (cond + ((not (stringp maildir)) + (user-error "Cannot refile: message has no maildir")) + ((string-prefix-p "/cmail" maildir) "/cmail/Archive") + (t + (user-error "No archive folder syncs for this account; refile disabled to avoid moving mail into an unsynced folder")))) + +(defun cj/mu4e--refile-folder (msg) + "Refile-folder function for `mu4e-refile-folder'. +Dispatch on MSG's maildir via `cj/mu4e--refile-folder-for-maildir'." + (cj/mu4e--refile-folder-for-maildir (and msg (mu4e-message-field msg :maildir)))) + (defcustom cj/smtpmail-debug-enabled nil "Non-nil means enable verbose SMTP transport debug logging. @@ -217,7 +242,8 @@ Prompts user for the action when executing." :vars '((user-mail-address . "c@cjennings.net") (user-full-name . "Craig Jennings") (mu4e-drafts-folder . "/cmail/Drafts") - (mu4e-sent-folder . "/cmail/Sent"))) + (mu4e-sent-folder . "/cmail/Sent") + (mu4e-trash-folder . "/cmail/Trash"))) (make-mu4e-context :name "deepsat.com" @@ -232,6 +258,12 @@ Prompts user for the action when executing." (mu4e-starred-folder . "/dmail/Starred") (mu4e-trash-folder . "/dmail/Trash"))))) + ;; Refile target is computed per message (see `cj/mu4e--refile-folder'), not + ;; set per context, because mu4e context :vars are sticky and would leak one + ;; account's archive folder into another. cmail archives to /cmail/Archive; + ;; gmail/dmail signal rather than move mail into an unsynced folder. + (setq mu4e-refile-folder #'cj/mu4e--refile-folder) + (setq mu4e-maildir-shortcuts '(("/cmail/Inbox" . ?i) ("/cmail/Sent" . ?s) diff --git a/modules/markdown-config.el b/modules/markdown-config.el index 4faa4474..16935425 100644 --- a/modules/markdown-config.el +++ b/modules/markdown-config.el @@ -21,7 +21,7 @@ ("\\.md\\'" . markdown-mode) ("\\.markdown\\'" . markdown-mode)) :bind (:map markdown-mode-map - ("<f2>" . markdown-preview)) ;; use same key as compile for consistency + ("<f2>" . cj/markdown-preview)) ;; use same key as compile for consistency :init (setq markdown-command "multimarkdown")) ;; Register markdown as a known org-src-block language so `org-lint' @@ -36,9 +36,7 @@ ;; allows for live previews of your html ;; see: https://github.com/skeeto/impatient-mode (use-package impatient-mode - :defer t - :config - (setq imp-set-user-filter 'markdown-html)) + :defer t) ;;;; --------------------- WIP: Markdown-Preview --------------------- @@ -51,14 +49,14 @@ Idempotent: re-running while the server is already up is a no-op." (message "markdown preview server running on http://localhost:8080/imp")) ;; the filter to apply to markdown before impatient-mode pushes it to the server -(defun markdown-preview () +(defun cj/markdown-preview () "Open the current buffer as a live HTML preview at http://localhost:8080/imp. The simple-httpd listener must already be running -- see `cj/markdown-preview-server-start'. Starting a network listener as a side effect of opening a preview is surprising, so the server start lives in a separate command." (interactive) - (unless (and (boundp 'httpd-process) httpd-process) + (unless (httpd-running-p) (user-error "markdown preview server not running; run `M-x cj/markdown-preview-server-start' first")) (impatient-mode 1) (setq imp-user-filter #'cj/markdown-html) diff --git a/modules/music-config.el b/modules/music-config.el index fd619d8c..799db133 100644 --- a/modules/music-config.el +++ b/modules/music-config.el @@ -95,6 +95,7 @@ (require 'user-constants) (require 'keybindings) ;; provides cj/custom-keymap (require 'cj-window-toggle-lib) ;; side-window size memory (F10 toggle) +(require 'system-lib) ;; cj/confirm-strong (overwrite confirms) ;;; Settings (no Customize) @@ -371,7 +372,7 @@ Offers completion over existing names but allows new names." (filename (if (string-suffix-p ".m3u" chosen) chosen (concat chosen ".m3u"))) (full (expand-file-name filename cj/music-m3u-root))) (when (and (file-exists-p full) - (not (yes-or-no-p (format "Overwrite %s? " filename)))) + (not (cj/confirm-strong (format "Overwrite %s? " filename)))) (user-error "Aborted saving playlist")) (with-current-buffer (cj/music--ensure-playlist-buffer) (let ((emms-source-playlist-ask-before-overwrite nil)) @@ -924,7 +925,7 @@ For URL tracks: decoded URL." (file (expand-file-name (concat safe "_Radio.m3u") cj/music-m3u-root)) (content (format "#EXTM3U\n#EXTINF:-1,%s\n%s\n" name url))) (when (and (file-exists-p file) - (not (yes-or-no-p (format "Overwrite %s? " (file-name-nondirectory file))))) + (not (cj/confirm-strong (format "Overwrite %s? " (file-name-nondirectory file))))) (user-error "Aborted creating radio station")) (with-temp-file file (insert content)) 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. diff --git a/modules/org-drill-config.el b/modules/org-drill-config.el index 296b0550..2c6e400e 100644 --- a/modules/org-drill-config.el +++ b/modules/org-drill-config.el @@ -95,9 +95,12 @@ With a prefix arg OTHER-DIR, prompt for the directory instead of `drill-dir'." (defun cj/drill-refile () "Refile to a drill file." (interactive) - (setq org-refile-targets '((nil :maxlevel . 1) - (drill-dir :maxlevel . 1))) - (call-interactively 'org-refile)) + (let ((org-refile-targets + `((nil :maxlevel . 1) + (,(mapcar (lambda (f) (expand-file-name f drill-dir)) + (cj/--drill-files-or-error drill-dir)) + :maxlevel . 1)))) + (call-interactively 'org-refile))) ;; ------------------------------- Drill Keymap -------------------------------- diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el index fdd9e1fc..218f37d6 100644 --- a/modules/org-roam-config.el +++ b/modules/org-roam-config.el @@ -29,6 +29,12 @@ ;; ---------------------------------- Org Roam --------------------------------- +(defconst cj/--org-roam-dailies-head + "#+FILETAGS: Journal\n#+TITLE: %<%Y-%m-%d>\n" + "Head inserted into a new org-roam daily file. +FILETAGS and TITLE must sit on separate lines so Org parses the +#+TITLE keyword (see `org-roam-dailies-capture-templates').") + (use-package org-roam :defer 1 :commands (org-roam-node-find org-roam-node-insert org-roam-db-autosync-mode) @@ -37,9 +43,9 @@ (org-roam-dailies-directory journals-dir) (org-roam-completion-everywhere t) (org-roam-dailies-capture-templates - '(("d" "default" entry "* %<%I:%M:%S %p %Z> %?" + `(("d" "default" entry "* %<%I:%M:%S %p %Z> %?" :if-new (file+head "%<%Y-%m-%d>.org" - "#+FILETAGS: Journal #+TITLE: %<%Y-%m-%d>")))) + ,cj/--org-roam-dailies-head)))) (org-roam-capture-templates `(("d" "default" plain "%?" diff --git a/modules/pearl-config.el b/modules/pearl-config.el new file mode 100644 index 00000000..52994219 --- /dev/null +++ b/modules/pearl-config.el @@ -0,0 +1,66 @@ +;;; pearl-config.el --- Linear.app integration via pearl -*- lexical-binding: t; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D/P. +;; Load shape: deferred (command-loaded). +;; Top-level side effects: package configuration via use-package. +;; Runtime requires: none. +;; Direct test load: no. +;; +;; Near-vanilla pearl setup (local checkout instead of a package archive), in +;; multi-account mode: two Linear workspaces, deepsat (work) and craigjennings +;; (personal), named by Linear's own urlKey. Each account renders to its own +;; Org file, deepsat.pearl.org / craigjennings.pearl.org, so they never collide. +;; `M-x pearl-switch-account' swaps the active one; the mode line shows it. +;; +;; pearl owns its own keymap. `pearl-mode' turns on automatically in any buffer +;; pearl renders (it carries a `#+LINEAR-SOURCE' header) and binds the whole +;; command surface under `pearl-keymap-prefix' (default "C-; L"). This config +;; also binds that same `pearl-prefix-map' globally under C-; L (`:bind-keymap'), +;; so the full command surface is reachable from any buffer; the first press +;; autoloads pearl. `M-x pearl-menu' / `M-x pearl-list-issues' still work too. +;; +;; Authentication: each account reads its key from authinfo.gpg by a distinct +;; login under the api.linear.app host: +;; machine api.linear.app login apikey password lin_api_<deepsat key> +;; machine api.linear.app login pearl-personal password lin_api_<personal key> +;; Generate keys in Linear: Settings -> Security & access -> Personal API keys. + +;;; Code: + +(use-package pearl + :ensure nil ;; local checkout, not from an archive + :load-path "~/code/pearl" + :commands (pearl-menu pearl-list-issues pearl-create-issue + pearl-run-linear-view pearl-switch-account) + ;; Bind pearl's command map globally under C-; L, so the full surface is + ;; reachable from any buffer (not only inside a pearl-rendered one). The + ;; first press autoloads pearl; it's the same `pearl-prefix-map' that + ;; `pearl-mode' binds in-buffer, so behavior is identical everywhere. + :bind-keymap ("C-; L" . pearl-prefix-map) + :custom + ;; Shorten the assignee @-tag to the first name only (e.g. @first instead of + ;; @first_last), trading disambiguation for a tighter tag line. + (pearl-assignee-tag-short t) + ;; Two workspaces, keyed by Linear's urlKey. Each resolves its API key from + ;; authinfo.gpg by its own login (see Commentary), renders to its own Org + ;; file, and carries a default team so create / by-project skip the prompt. + (pearl-accounts + '(("deepsat" + :api-key-source (:auth-source :host "api.linear.app" :user "apikey") + :org-file "~/org/gtd/deepsat.pearl.org" + :default-team-id "9fca2cf6-390c-4102-a9ff-f94a4ed823c5") ;; DeepSat SE + ("craigjennings" + :api-key-source (:auth-source :host "api.linear.app" :user "pearl-personal") + :org-file "~/org/gtd/craigjennings.pearl.org" + :default-team-id "ee285e6c-fcc9-4dd6-9292-c47f2df75b82"))) ;; Pearl + ;; Which workspace pearl opens into. Work is primary; switch per-session at + ;; runtime with `M-x pearl-switch-account' (e.g. to dogfood the personal + ;; "craigjennings" workspace). + (pearl-default-account "deepsat")) + +(provide 'pearl-config) +;;; pearl-config.el ends here diff --git a/modules/prog-general.el b/modules/prog-general.el index a4be7205..8b4dedda 100644 --- a/modules/prog-general.el +++ b/modules/prog-general.el @@ -298,6 +298,22 @@ This is what makes universal snippets like =<cj= work in any buffer." (yas-reload-all) (yas-global-mode 1)) +;; Most of the snippet keys start with "<" (=<cj=, =<for=, =<main=…), mirroring +;; org-tempo. But `electric-pair-mode' pairs "<" into "<>" wherever the mode's +;; syntax table gives "<" paren syntax (org, and the prog modes that enable +;; pairing), so typing "<cj" lands as "<cj>"; expanding the "<cj" key then +;; strands the ">" after the snippet — the cj-comment fence comes out as +;; "#+end_src>", which breaks the cj-scan fence parser. Inhibit pairing for the +;; open angle bracket globally; defer to the default for every other character. +(defun cj/--electric-pair-inhibit-angle (char) + "Return non-nil to stop `electric-pair-mode' from pairing the angle CHAR. +Inhibit the open angle bracket so \"<\"-prefixed yasnippet keys expand cleanly; +defer to `electric-pair-default-inhibit' for any other CHAR." + (or (eq char ?<) + (electric-pair-default-inhibit char))) + +(setq electric-pair-inhibit-predicate #'cj/--electric-pair-inhibit-angle) + ;; --------------------- Display Color On Color Declaration -------------------- ;; display the actual color as highlight to color hex code diff --git a/modules/reconcile-open-repos.el b/modules/reconcile-open-repos.el index dd82ef0f..79a895bf 100644 --- a/modules/reconcile-open-repos.el +++ b/modules/reconcile-open-repos.el @@ -171,8 +171,11 @@ Prunes generated/heavy directories. Once a repository root is found, do not descend into it unless INCLUDE-NESTED is non-nil." (let (repos) (when (file-directory-p directory) - (dolist (child (directory-files directory t "^[^.]+$" 'nosort)) + (dolist (child (directory-files directory t directory-files-no-dot-files-regexp 'nosort)) (when (and (file-directory-p child) + ;; Skip hidden dirs (.git, .config) but keep dotted repo + ;; names like mcp.el; the old "^[^.]+$" filter dropped both. + (not (string-prefix-p "." (file-name-nondirectory child))) (not (cj/reconcile--pruned-directory-p child))) (if (file-directory-p (expand-file-name ".git" child)) (progn diff --git a/modules/selection-framework.el b/modules/selection-framework.el index 11687337..b136ad15 100644 --- a/modules/selection-framework.el +++ b/modules/selection-framework.el @@ -47,6 +47,11 @@ :init (vertico-mode)) +;; Save each completion session so `vertico-repeat' (the second C-s in +;; `cj/consult-line-or-repeat') has a session to resume. `vertico-repeat-save' +;; is autoloaded, so this defers loading vertico-repeat until the first minibuffer. +(add-hook 'minibuffer-setup-hook #'vertico-repeat-save) + (use-package marginalia :demand t :custom @@ -246,6 +251,11 @@ (use-package vertico-prescient :demand t + :custom + ;; orderless does the matching; prescient only sorts. Without this, + ;; vertico-prescient-mode's default filtering overrides completion-styles to + ;; prescient inside vertico sessions, leaving the orderless config above dead. + (vertico-prescient-enable-filtering nil) :config (vertico-prescient-mode)) diff --git a/modules/signal-config.el b/modules/signal-config.el index 102ece86..7e980b62 100644 --- a/modules/signal-config.el +++ b/modules/signal-config.el @@ -16,6 +16,10 @@ ;;; Code: (require 'seq) +(require 'keybindings) ;; provides cj/custom-keymap + cj/register-prefix-map +(require 'system-lib) ;; for cj/executable-find-or-warn + +(declare-function notifications-notify "notifications") (defun cj/signal--jstr (value) "Return VALUE if it is a non-blank string, else nil. @@ -101,6 +105,46 @@ window of a focused frame." (buffer-name (window-buffer (selected-window))) (cj/signal--frame-focused-p)))) +;;; Notifications + +(defcustom cj/signel-notify-sound nil + "When non-nil, incoming-message notifications play the notify script's sound. +Nil (the default) passes --silent so the toast is visual only." + :type 'boolean + :group 'signel) + +(defconst cj/signal--notify-body-max 120 + "Maximum character length of a desktop-notification body. +Longer message text truncates to this length ending in an ellipsis; +the full text is always in the chat buffer.") + +(defun cj/signal--format-notify-body (text) + "Collapse whitespace in TEXT and truncate it for a notification body. +Whitespace runs (including newlines) become single spaces, the result +is trimmed, and anything over `cj/signal--notify-body-max' characters +truncates to that length with a trailing ellipsis." + (let ((flat (string-trim (replace-regexp-in-string "[ \t\n\r]+" " " text)))) + (if (<= (length flat) cj/signal--notify-body-max) + flat + (concat (substring flat 0 (1- cj/signal--notify-body-max)) "…")))) + +(defun cj/signel--notify (chat-id sender body) + "Raise a desktop notification for an incoming Signal message. +Suppressed via `cj/signal--should-notify-p' when the user is actively +viewing CHAT-ID. Routes through the external notify script when it is +on PATH (type info, sound gated by `cj/signel-notify-sound'), falling +back to `notifications-notify' otherwise. SENDER names the title; +BODY is formatted by `cj/signal--format-notify-body'. Installed as +`signel-notify-function' in the use-package :config below." + (when (cj/signal--should-notify-p chat-id) + (let ((title (format "Signal: %s" sender)) + (text (cj/signal--format-notify-body body)) + (script (executable-find "notify"))) + (if script + (apply #'start-process "signel-notify" nil script "info" title text + (unless cj/signel-notify-sound (list "--silent"))) + (notifications-notify :title title :body text))))) + ;;; signel — fork integration (defcustom cj/signal-private-config-file @@ -125,7 +169,13 @@ time." (signel-auto-open-buffer nil) :config (when (file-readable-p cj/signal-private-config-file) - (load cj/signal-private-config-file nil t))) + (load cj/signal-private-config-file nil t)) + ;; Route incoming-message notifications through cj/signel--notify + ;; (suppression + notify script + truncation); warn once at load when + ;; the script is missing — the runtime path still falls back to + ;; notifications-notify, so messages are never silently dropped. + (setq signel-notify-function #'cj/signel--notify) + (cj/executable-find-or-warn "notify" "Signal desktop notifications via the notify script (falling back to notifications-notify)" 'signal-config)) ;; Chat buffers (named `*Signel: <id>*') open in the bottom 30% of the ;; frame rather than wherever display-buffer's fallback rule picks. @@ -291,10 +341,11 @@ that on first use." Leaves =l= unbound for now -- the future =cj/signel-link= command lands in a later pass. See =docs/design/signal-client.org= scope summary.") -(declare-function cj/custom-keymap "keybindings" ()) -(with-eval-after-load 'keybindings - (when (boundp 'cj/custom-keymap) - (keymap-set cj/custom-keymap "M" cj/signel-prefix-map))) +;; Register the messages prefix under C-; M via the documented helper. +;; keybindings.el owns cj/custom-keymap; the (require 'keybindings) above +;; guarantees it is loaded before this runs, so no load-order guard is +;; needed. This is the same pattern every other feature module uses. +(cj/register-prefix-map "M" cj/signel-prefix-map "signal messages") (provide 'signal-config) ;;; signal-config.el ends here diff --git a/modules/system-commands.el b/modules/system-commands.el index dba4d40e..44ac3ae8 100644 --- a/modules/system-commands.el +++ b/modules/system-commands.el @@ -9,7 +9,7 @@ ;; Eager reason: registers the C-; ! system-command keymap; high-impact commands ;; that should run only by command (command-loaded target). ;; Top-level side effects: defines a system-command keymap under cj/custom-keymap. -;; Runtime requires: keybindings, rx. +;; Runtime requires: keybindings, host-environment, rx. ;; Direct test load: yes (requires keybindings explicitly). ;; ;; System commands for logout, lock, suspend, shutdown, reboot, and Emacs @@ -17,7 +17,7 @@ ;; ;; Commands include: ;; - Logout (terminate user session) -;; - Lock screen (slock) +;; - Lock screen (hyprlock on Wayland, slock on X11) ;; - Suspend (systemctl suspend) ;; - Shutdown (systemctl poweroff) ;; - Reboot (systemctl reboot) @@ -34,6 +34,14 @@ ;; the load-time reference void if anything required `system-commands' ;; before `keybindings'. Make the dependency explicit. (require 'keybindings) +;; `host-environment' provides `env-wayland-p', referenced at load time by the +;; `lockscreen-cmd' defvar below to pick the session-appropriate locker. A hard +;; require keeps the module loadable on its own (tests, byte-compile) rather +;; than relying on init.el's load order. +(require 'host-environment) +;; `system-lib' provides `cj/confirm-strong', used at runtime by the `strong' +;; confirm branch of `cj/system-cmd' for irreversible actions (shutdown/reboot). +(require 'system-lib) (eval-when-compile (require 'subr-x)) (require 'rx) @@ -71,7 +79,7 @@ If CMD is deemed dangerous, ask for confirmation." ;; Strong confirm for irreversible actions (shutdown, reboot): ;; require an explicit "yes", so a stray RET/space can't trigger them. ((eq confirm 'strong) - (unless (yes-or-no-p (format "Really run %s (%s)? " label cmdstr)) + (unless (cj/confirm-strong (format "Really run %s (%s)? " label cmdstr)) (user-error "Aborted"))) ;; Quick (Y/n) confirm for recoverable actions (logout, suspend). (confirm @@ -102,7 +110,13 @@ actions like shutdown and reboot), nil for no confirmation." ;; Define system commands (cj/defsystem-command cj/system-cmd-logout logout-cmd "loginctl terminate-user $(whoami)" t) -(cj/defsystem-command cj/system-cmd-lock lockscreen-cmd "slock") +;; slock is X11-only and can't grab a Wayland session. On Wayland, lock via +;; the session manager (`loginctl lock-session') rather than spawning a locker +;; directly: logind emits the Lock signal, hypridle catches it and runs its +;; lock_cmd (hyprlock), the same path idle/before-sleep locking already uses. +;; X11 machines keep slock. +(cj/defsystem-command cj/system-cmd-lock lockscreen-cmd + (if (env-wayland-p) "loginctl lock-session" "slock")) (cj/defsystem-command cj/system-cmd-suspend suspend-cmd "systemctl suspend" t) (cj/defsystem-command cj/system-cmd-shutdown shutdown-cmd "systemctl poweroff" strong) (cj/defsystem-command cj/system-cmd-reboot reboot-cmd "systemctl reboot" strong) diff --git a/modules/system-defaults.el b/modules/system-defaults.el index eccc6c35..1703b1bf 100644 --- a/modules/system-defaults.el +++ b/modules/system-defaults.el @@ -200,8 +200,9 @@ appears only once per session." (setq confirm-nonexistent-file-or-buffer nil) ;; don't ask if a file I visit with C-x C-f or C-x b doesn't exist (setq ad-redefinition-action 'accept) ;; silence warnings about advised functions getting redefined. (setq large-file-warning-threshold nil) ;; open files regardless of size -(fset 'yes-or-no-p 'y-or-n-p) ;; require a single letter for binary answers -(setq use-short-answers t) ;; same as above with Emacs 28+ +(setq use-short-answers t) ;; single-key y/n for ordinary yes-or-no-p prompts + ;; (irreversible actions use `cj/confirm-strong', which + ;; forces a typed "yes" by binding this nil for that call) (setq auto-revert-verbose nil) ;; turn off auto revert messages (setq custom-safe-themes t) ;; treat all themes as safe (stop asking) (setq server-client-instructions nil) ;; I already know what to do when done with the frame diff --git a/modules/system-lib.el b/modules/system-lib.el index 333c15ee..9e25be5b 100644 --- a/modules/system-lib.el +++ b/modules/system-lib.el @@ -130,5 +130,16 @@ Callers that must have a secret layer their own error on top." (secret (plist-get (car (apply #'auth-source-search spec)) :secret))) (if (functionp secret) (funcall secret) secret))) +;; ---------------------------- Strong Confirmation ---------------------------- + +(defun cj/confirm-strong (prompt) + "Ask PROMPT, requiring a full typed \"yes\" or \"no\" answer. +For irreversible actions -- file destruction, overwrites, power-off. The +global default makes `yes-or-no-p' a single keystroke (`use-short-answers' +is t); this binds it to nil for the one call so the prompt demands the +long-form answer, keeping a stray RET or space from confirming." + (let ((use-short-answers nil)) + (yes-or-no-p prompt))) + (provide 'system-lib) ;;; system-lib.el ends here diff --git a/modules/term-config.el b/modules/term-config.el index 5753edde..f9c12635 100644 --- a/modules/term-config.el +++ b/modules/term-config.el @@ -29,10 +29,12 @@ ;; Two ways to lift text out of a terminal, both with the same key story: ;; - C-; x c enters copy-mode via `cj/term-copy-mode-dwim'. When a tmux ;; client is attached (typical -- `cj/term-launch-tmux' auto-starts tmux), -;; sends tmux's prefix C-b [ so the user lands in tmux's own copy-mode with -;; the full pane history available. Without tmux, falls back to +;; sends tmux's prefix C-b [ then C-a, so the user lands in tmux's own +;; copy-mode with the full pane history and the cursor at column 0 (so +;; scrolling up runs up the left, not the right). Without tmux, falls back to ;; `ghostel-copy-mode' (read-only standard-Emacs navigation over the -;; scrollback; M-w copies and stays, q / C-g exit). +;; scrollback; M-w copies and stays, q / C-g exit) and moves point to the +;; start of the line for the same column-0 reason. ;; - C-; x h captures the current tmux pane's full history into a temporary ;; Emacs buffer. ;; In both copy surfaces, M-w copies the active region and stays open so several @@ -190,13 +192,19 @@ cheap boolean predicate." "Enter copy-mode using the engine appropriate to this terminal. When tmux is attached, write tmux's default prefix sequence (C-b [) into the -pty so the user lands in tmux's copy-mode with the full pane history. Without -tmux, falls through to `ghostel-copy-mode', a read-only standard-Emacs view of -the scrollback (M-w copies and stays, q / C-g exit)." +pty so the user lands in tmux's copy-mode with the full pane history, then +C-a to land the cursor at the start of the line. Without the trailing C-a +the copy cursor inherits the live column (far right after a prompt) and +scrolling up runs up the right edge; tmux's emacs copy-mode binds C-a to +start-of-line, so column 0 makes it run up the left. Without tmux, falls +through to `ghostel-copy-mode' (a read-only standard-Emacs view of the +scrollback; M-w copies and stays, q / C-g exit), then moves point to the +start of the line for the same column-0 reason." (interactive) (if (cj/term--in-tmux-p) - (ghostel-send-string "\C-b[") - (ghostel-copy-mode))) + (ghostel-send-string "\C-b[\C-a") + (ghostel-copy-mode) + (beginning-of-line))) ;; ----------------------------- ghostel package ------------------------------- @@ -229,9 +237,12 @@ run its own project-named tmux session instead of a bare, auto-named one. ;; rebuild is what actually lets the key through to `ghostel-mode-map' / the ;; global map. C-; and F12 are the prefix + toggle; the modified arrows are ;; windmove (S-arrows, focus) and buffer-move (C-M-arrows, swap), which the - ;; ai-term workflow expects to work from inside an agent buffer. + ;; ai-term workflow expects to work from inside an agent buffer. F8, F10 and + ;; C-F10 are global bindings (org agenda, music-playlist toggle, server + ;; shutdown) that reach Emacs by falling through to the global map once the + ;; semi-char map stops forwarding them. (with-eval-after-load 'ghostel - (dolist (key '("C-;" "<f12>" + (dolist (key '("C-;" "<f8>" "<f12>" "<f10>" "C-<f10>" "S-<up>" "S-<down>" "S-<left>" "S-<right>" "C-M-<up>" "C-M-<down>" "C-M-<left>" "C-M-<right>")) (add-to-list 'ghostel-keymap-exceptions key)) diff --git a/modules/ui-navigation.el b/modules/ui-navigation.el index f1324c16..f2181d97 100644 --- a/modules/ui-navigation.el +++ b/modules/ui-navigation.el @@ -160,7 +160,9 @@ This function won't work with more than one split window." ;; UNDO KILL BUFFER (defun cj/undo-kill-buffer (arg) - "Re-open the last buffer killed. With ARG, re-open the nth buffer." + "Re-open the last buffer killed. +With numeric prefix ARG, re-open the ARGth most-recently-killed file +\(1-based, so no prefix re-opens the most recent)." (interactive "p") (require 'recentf) (unless recentf-mode @@ -177,9 +179,11 @@ This function won't work with more than one split window." (delq buf-file recently-killed-list))) buffer-files-list) (when recently-killed-list - (find-file - (if arg (nth arg recently-killed-list) - (car recently-killed-list)))))) + (let ((file (nth (1- arg) recently-killed-list))) + (if file + (find-file file) + (user-error "Only %d killed file(s) to choose from" + (length recently-killed-list))))))) (keymap-global-set "M-S-z" #'cj/undo-kill-buffer) ;; was M-Z, overrides zap-to-char ;; ---------------------------- Undo Layout Changes ---------------------------- diff --git a/modules/user-constants.el b/modules/user-constants.el index 43a23d79..2e64b355 100644 --- a/modules/user-constants.el +++ b/modules/user-constants.el @@ -125,8 +125,10 @@ fallback only.") (defconst org-dir (expand-file-name "org/" sync-dir) "This directory is synchronized across machines.") -(defconst roam-dir (expand-file-name "roam/" org-dir) - "The location of org-roam files.") +(defconst roam-dir (expand-file-name "org/roam/" user-home-dir) + "The location of org-roam files. +A standalone git repo (cjennings.net:roam.git), no longer inside the +Syncthing-synced `org-dir' — see the 2026-06-10 transport migration.") (defconst journals-dir (expand-file-name "journal/" roam-dir) "The location of org-roam dailies or journals files.") @@ -149,7 +151,7 @@ fallback only.") (defconst music-dir (expand-file-name "music/" user-home-dir) "The location to save your music files.") -(defconst website-dir (expand-file-name "projects/website/" user-home-dir) +(defconst website-dir (expand-file-name "code/website/" user-home-dir) "Root directory of the Hugo website project.") |
