aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/ai-term.el257
-rw-r--r--modules/auth-config.el9
-rw-r--r--modules/browser-config.el3
-rw-r--r--modules/calendar-sync.el96
-rw-r--r--modules/calibredb-epub-config.el17
-rw-r--r--modules/chrono-tools.el5
-rw-r--r--modules/config-utilities.el19
-rw-r--r--modules/coverage-core.el9
-rw-r--r--modules/custom-ordering.el8
-rw-r--r--modules/dashboard-config.el61
-rw-r--r--modules/diff-config.el6
-rw-r--r--modules/dirvish-config.el46
-rw-r--r--modules/dwim-shell-config.el10
-rw-r--r--modules/elfeed-config.el41
-rw-r--r--modules/erc-config.el33
-rw-r--r--modules/eshell-config.el36
-rw-r--r--modules/eww-config.el9
-rw-r--r--modules/face-diagnostic.el60
-rw-r--r--modules/flycheck-config.el8
-rw-r--r--modules/font-config.el3
-rw-r--r--modules/games-config.el2
-rw-r--r--modules/google-keep-config.el210
-rw-r--r--modules/help-utils.el4
-rw-r--r--modules/httpd-config.el12
-rw-r--r--modules/jumper.el16
-rw-r--r--modules/latex-config.el9
-rw-r--r--modules/ledger-config.el47
-rw-r--r--modules/local-repository.el16
-rw-r--r--modules/mail-config.el79
-rw-r--r--modules/markdown-config.el12
-rw-r--r--modules/mousetrap-mode.el10
-rw-r--r--modules/mu4e-org-contacts-integration.el1
-rw-r--r--modules/mu4e-org-contacts-setup.el4
-rw-r--r--modules/music-config.el328
-rw-r--r--modules/nerd-icons-config.el46
-rw-r--r--modules/org-agenda-config-debug.el3
-rw-r--r--modules/org-agenda-config.el37
-rw-r--r--modules/org-babel-config.el6
-rw-r--r--modules/org-capture-config.el3
-rw-r--r--modules/org-config.el82
-rw-r--r--modules/org-contacts-config.el48
-rw-r--r--modules/org-faces-config.el56
-rw-r--r--modules/org-noter-config.el25
-rw-r--r--modules/org-refile-config.el11
-rw-r--r--modules/org-roam-config.el36
-rw-r--r--modules/pdf-config.el21
-rw-r--r--modules/prog-general.el26
-rw-r--r--modules/selection-framework.el6
-rw-r--r--modules/term-config.el147
-rw-r--r--modules/tramp-config.el9
-rw-r--r--modules/transcription-config.el14
-rw-r--r--modules/ui-navigation.el10
-rw-r--r--modules/user-constants.el15
-rw-r--r--modules/vc-config.el21
-rw-r--r--modules/video-audio-recording.el7
55 files changed, 1597 insertions, 518 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
index ff8da0035..b463da90b 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -52,12 +52,14 @@
;; picker, even when an agent buffer is currently displayed.
;; Used when the user wants to start a new project session
;; instead of toggling the current one.
-;; - s-F9 `cj/ai-term-next' -- step to the next open agent in the
-;; queue. The queue is the live agent buffers in buffer-name
-;; order (a stable rotation). When an agent window is on
-;; screen, swap it to the next agent and focus it, wrapping
-;; after the last; when none is shown but agents exist, show
-;; the first. This is the "switch among existing agents"
+;; - s-F9 `cj/ai-term-next' -- step to the next active agent in the
+;; queue. The queue is every active agent in buffer-name order
+;; (a stable rotation): attached agents (a live buffer) and
+;; detached ones (a live tmux session with no Emacs buffer).
+;; Stepping onto a detached agent attaches it. When an agent
+;; window is on screen, swap it to the next agent and focus it,
+;; wrapping after the last; when none is shown but agents exist,
+;; show the first. This is the "switch among existing agents"
;; surface F9 deliberately doesn't provide.
;; - M-F9 `cj/ai-term-close' -- gracefully close an agent: kill its
;; tmux session (stopping the agent process), then its terminal
@@ -77,6 +79,7 @@
(require 'cj-window-geometry-lib)
(require 'cj-window-toggle-lib)
(require 'host-environment)
+(require 'keybindings) ;; provides cj/register-prefix-map (C-; a)
(declare-function ghostel "ghostel" (&optional arg))
(declare-function ghostel-send-string "ghostel" (string))
@@ -185,20 +188,39 @@ recently-selected first. Non-AI-term buffers are filtered out via
`cj/--ai-term-buffer-p'."
(seq-filter #'cj/--ai-term-buffer-p (buffer-list)))
-(defun cj/--ai-term-next-agent-buffer (current buffers)
- "Return the agent buffer after CURRENT in BUFFERS, wrapping to the first.
+(defun cj/--ai-term-next-agent-dir (current dirs)
+ "Return the project dir after CURRENT in DIRS, wrapping to the first.
-BUFFERS is an ordered list of live agent buffers. When CURRENT is the
-last element, wrap to the first. When CURRENT is nil or not a member of
-BUFFERS, return the first buffer. Returns nil when BUFFERS is empty.
+DIRS is an ordered list of active-agent project dirs. When CURRENT is
+the last element, wrap to the first. When CURRENT is nil or not a member
+of DIRS, return the first dir. Returns nil when DIRS is empty. Matches
+with `member' (string equality) since dirs are paths.
Pure decision helper (no buffer or window side effects) so the cycle
-order driving `cj/ai-term-next' (s-F9) is exercisable in tests."
- (when buffers
- (if (memq current buffers)
- (or (cadr (memq current buffers))
- (car buffers))
- (car buffers))))
+order driving `cj/ai-term-next' is exercisable in tests."
+ (when dirs
+ (if (member current dirs)
+ (or (cadr (member current dirs))
+ (car dirs))
+ (car dirs))))
+
+(defun cj/--ai-term-active-agent-dirs ()
+ "Return project dirs that have a live agent buffer or a live tmux session.
+
+Sorted by the agent buffer name, so the rotation is stable and matches
+what the picker shows. This is the queue `cj/ai-term-next' steps through:
+it includes detached sessions (alive in tmux but with no Emacs buffer),
+which the step materializes by attaching."
+ (let* ((sessions (cj/--ai-term-live-tmux-sessions))
+ (live-names (mapcar #'buffer-name (cj/--ai-term-agent-buffers))))
+ (sort
+ (seq-filter
+ (lambda (dir)
+ (or (member (cj/--ai-term-buffer-name dir) live-names)
+ (cj/--ai-term-session-active-p dir sessions)))
+ (cj/--ai-term-candidates))
+ (lambda (a b)
+ (string< (cj/--ai-term-buffer-name a) (cj/--ai-term-buffer-name b))))))
(defun cj/--ai-term-most-recent-non-agent-buffer ()
"Return the most-recently-selected live non-agent buffer, or nil.
@@ -987,59 +1009,164 @@ interrupt work in progress. Bound to M-<f9>."
(defun cj/ai-term-next ()
"Step to the next open AI-term agent in the queue.
-The queue is the live agent buffers ordered by buffer name -- a stable
-rotation, unaffected by which agent was most recently selected. When an
-agent window is on screen, swap it to the next agent in the queue
-\(wrapping after the last) and select it. When no agent is displayed but
-agents exist, show the first. Signals `user-error' when none are open.
-
-Bound to s-<f9>. Unlike <f9> (toggle the most-recent agent on/off), this
-is the \"switch among existing agents\" surface; C-<f9> opens the project
-picker and M-<f9> closes an agent."
+The queue is every active agent ordered by buffer name -- a stable
+rotation, unaffected by which agent was most recently selected. Active
+means a live agent buffer (attached) OR a live tmux session with no Emacs
+buffer (detached); stepping onto a detached agent attaches it (recreates
+its terminal, which reattaches the session). When an agent window is on
+screen, swap it to the next agent (wrapping after the last) and select it.
+When no agent is displayed but agents exist, show the first. When none
+are open, open the project picker to launch the first agent rather than
+erroring.
+
+Bound to M-SPC. Unlike C-; a a (toggle the most-recent agent on/off), this
+is the \"switch among existing agents\" surface; C-; a s opens the project
+picker and C-; a k closes an agent."
(interactive)
- (let* ((buffers (sort (cj/--ai-term-agent-buffers)
- (lambda (a b)
- (string< (buffer-name a) (buffer-name b)))))
+ (let* ((dirs (cj/--ai-term-active-agent-dirs))
(win (cj/--ai-term-displayed-agent-window))
- (current (and win (window-buffer win)))
- (next (cj/--ai-term-next-agent-buffer current buffers)))
- (unless next
- (user-error "No AI-term agent buffers open"))
- (if win
- (progn
- (set-window-buffer win next)
- (select-window win))
- (display-buffer next)
- (let ((w (get-buffer-window next)))
- (when w (select-window w))))
- (message "Agent: %s" (buffer-name next))))
-
-(keymap-global-set "<f9>" #'cj/ai-term)
-(keymap-global-set "C-<f9>" #'cj/ai-term-pick-project)
-(keymap-global-set "s-<f9>" #'cj/ai-term-next)
-(keymap-global-set "M-<f9>" #'cj/ai-term-close)
-
-;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to
-;; the terminal program, so a plain <f9> typed while point is inside an agent
-;; buffer would be sent to the program instead of toggling the agent -- which
-;; bites hard when the agent buffer is the only window in the frame. Re-bind
-;; the F9 family in `ghostel-mode-map' so the toggle reaches Emacs from there
-;; too. (C-<f9> / M-<f9> are bound here as well so the behaviour is uniform.)
+ (current-name (and win (buffer-name (window-buffer win))))
+ (current-dir (and current-name
+ (seq-find (lambda (d)
+ (equal (cj/--ai-term-buffer-name d) current-name))
+ dirs)))
+ (next-dir (cj/--ai-term-next-agent-dir current-dir dirs)))
+ (if (not next-dir)
+ ;; No agents open: launch the first via the project picker instead of
+ ;; erroring, so the swap key doubles as a "start an agent" key.
+ (cj/ai-term-pick-project)
+ (let* ((name (cj/--ai-term-buffer-name next-dir))
+ (existing (get-buffer name)))
+ ;; Live agent and an agent window is up: swap it into that window in
+ ;; place (faithful to the prior buffer-only behavior). Detached, or no
+ ;; window yet: show-or-create attaches the tmux session / displays it.
+ (if (and win existing (cj/--ai-term-process-live-p existing))
+ (progn (set-window-buffer win existing) (select-window win))
+ (cj/--ai-term-show-or-create next-dir name)
+ (let ((w (get-buffer-window name)))
+ (when w (select-window w))))
+ (message "Agent: %s" name)))))
+
+;; ai-term lives under the C-; a prefix (vacated when gptel was archived).
+;; The frequent "swap to the next agent" also gets M-SPC for a fast chord.
+(defvar-keymap cj/ai-term-keymap
+ :doc "Keymap for ai-term agent commands (C-; a)."
+ "a" #'cj/ai-term ;; toggle the most-recent agent on/off
+ "s" #'cj/ai-term-pick-project ;; select / launch via the project picker
+ "n" #'cj/ai-term-next ;; swap to the next open agent
+ "k" #'cj/ai-term-close) ;; kill the current agent
+(cj/register-prefix-map "a" cj/ai-term-keymap "ai-term")
+(keymap-global-set "M-SPC" #'cj/ai-term-next)
+
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; a" "ai-term menu"
+ "C-; a a" "toggle agent"
+ "C-; a s" "select / launch"
+ "C-; a n" "next agent"
+ "C-; a k" "kill agent"
+ "M-SPC" "ai-term: next agent"))
+
+;; In ghostel's semi-char mode, keys not in `ghostel-keymap-exceptions' are
+;; forwarded to the pty, and `ghostel-semi-char-mode-map' outranks the major
+;; mode map. M-SPC (swap to the next agent) must reach Emacs from inside an
+;; agent buffer, so add it to the exceptions, rebuild the semi-char map, and
+;; bind it in `ghostel-mode-map'. C-; is already an exception (term-config),
+;; so the C-; a family resolves through the global prefix without extra wiring.
(with-eval-after-load 'ghostel
- (keymap-set ghostel-mode-map "<f9>" #'cj/ai-term)
- (keymap-set ghostel-mode-map "C-<f9>" #'cj/ai-term-pick-project)
- (keymap-set ghostel-mode-map "s-<f9>" #'cj/ai-term-next)
- (keymap-set ghostel-mode-map "M-<f9>" #'cj/ai-term-close)
- ;; The bindings above live in `ghostel-mode-map', but in semi-char mode
- ;; ghostel's own `ghostel-semi-char-mode-map' forwards every key not in
- ;; `ghostel-keymap-exceptions' to the pty -- and that map outranks the
- ;; major-mode map, so it would swallow the F9 family before the bindings
- ;; above fire. Add the family to the exceptions and rebuild the semi-char
- ;; map so the keys fall through to `ghostel-mode-map' inside agent buffers.
- (dolist (key '("<f9>" "C-<f9>" "s-<f9>" "M-<f9>"))
- (add-to-list 'ghostel-keymap-exceptions key))
+ (keymap-set ghostel-mode-map "M-SPC" #'cj/ai-term-next)
+ (add-to-list 'ghostel-keymap-exceptions "M-SPC")
(ghostel--rebuild-semi-char-keymap))
+;; ------------------- Wrap-it-up teardown + shutdown -------------------------
+;;
+;; Headless entry points the rulesets wrap-it-up workflow calls via
+;; `emacsclient -e' (its Stop hook ~/.claude/hooks/ai-wrap-teardown.sh). All
+;; three must work with no interactive frame guaranteed. rulesets owns the
+;; workflow + hook that call these; this module owns the aiv- session naming,
+;; the agent buffer, and the geometry restore, so the functions live here.
+;; See docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org (rulesets).
+
+(defcustom cj/ai-term-shutdown-command "sudo shutdown now"
+ "Shell command run when the shutdown countdown completes uncancelled.
+A defcustom so development and tests can stub it instead of powering off
+\(sudo is NOPASSWD on Craig's machines, so the default really shuts down)."
+ :type 'string
+ :group 'cj)
+
+(defun cj/ai-term-quit (&optional project)
+ "Tear down PROJECT's AI-term: kill its tmux session, buffer, and restore layout.
+PROJECT is a project basename (as the rulesets Stop hook passes) or a directory;
+nil means the current project (`default-directory'). Kills the `aiv-<name>'
+tmux session (taking the agent process with it), then, when the agent buffer is
+live, swaps its window back to the working buffer and kills it. Idempotent and
+safe headless: a session or buffer already gone is a no-op, not an error."
+ (let* ((key (or project default-directory))
+ (session (cj/--ai-term-tmux-session-name key))
+ (buffer (get-buffer (cj/--ai-term-buffer-name key))))
+ (cj/--ai-term-kill-tmux-session session)
+ (when (cj/--ai-term-buffer-p buffer)
+ (let ((win (get-buffer-window buffer)))
+ (when (window-live-p win)
+ (cj/--ai-term-swap-to-working-buffer win)))
+ (let ((kill-buffer-query-functions nil))
+ (kill-buffer buffer)))
+ session))
+
+(defun cj/ai-term-live-count ()
+ "Return the integer count of live AI-term (aiv-*) tmux sessions.
+0 when tmux has no server or no AI-term sessions. The shutdown safety gate:
+`emacsclient -e (cj/ai-term-live-count)' prints the integer for the hook."
+ (length (cj/--ai-term-live-tmux-sessions)))
+
+(defvar cj/--ai-term-shutdown-timer nil
+ "The active shutdown-countdown repeating timer, or nil when none is running.")
+
+(defun cj/--ai-term-shutdown-clear-timer ()
+ "Cancel and forget the shutdown-countdown timer, if any."
+ (when (timerp cj/--ai-term-shutdown-timer)
+ (cancel-timer cj/--ai-term-shutdown-timer))
+ (setq cj/--ai-term-shutdown-timer nil))
+
+(defun cj/ai-term-shutdown-cancel ()
+ "Cancel an in-progress AI-term shutdown countdown."
+ (interactive)
+ (when cj/--ai-term-shutdown-timer
+ (cj/--ai-term-shutdown-clear-timer)
+ (message "Shutdown cancelled.")))
+
+(defun cj/ai-term-shutdown-countdown (&optional seconds)
+ "Count down SECONDS (default 10) in the echo area, then shut the machine down.
+Re-checks the safety gate first (a TOCTOU guard against the workflow's earlier
+check): aborts with a message when more than one `aiv-*' session is live. The
+countdown is an abort-able `run-at-time' timer -- `C-g' (while the countdown
+owns the keymap) or \\[cj/ai-term-shutdown-cancel] stops it. On reaching zero
+uncancelled it runs `cj/ai-term-shutdown-command'. Returns immediately so the
+Stop hook does not block; the daemon ticks the timer asynchronously."
+ (if (> (cj/ai-term-live-count) 1)
+ (progn
+ (message "Shutdown aborted: %d AI-term sessions still live."
+ (cj/ai-term-live-count))
+ nil)
+ (cj/--ai-term-shutdown-clear-timer)
+ (let ((remaining (or seconds 10)))
+ (set-transient-map
+ (let ((m (make-sparse-keymap)))
+ (define-key m (kbd "C-g") #'cj/ai-term-shutdown-cancel)
+ m)
+ (lambda () (and cj/--ai-term-shutdown-timer t)))
+ (setq cj/--ai-term-shutdown-timer
+ (run-at-time
+ 0 1
+ (lambda ()
+ (if (<= remaining 0)
+ (progn
+ (cj/--ai-term-shutdown-clear-timer)
+ (shell-command cj/ai-term-shutdown-command))
+ (message "Shutting down in %d… (C-g to cancel)" remaining)
+ (setq remaining (1- remaining))))))
+ nil)))
+
;; ---------- emacsclient: keep opened files off the agent terminal ----------
;;
;; `server-start' (in system-defaults.el) leaves `server-window' nil, so
diff --git a/modules/auth-config.el b/modules/auth-config.el
index f18c0c1fd..62d773057 100644
--- a/modules/auth-config.el
+++ b/modules/auth-config.el
@@ -35,6 +35,15 @@
(require 'system-lib)
(require 'user-constants) ;; defines authinfo-file, read at load time below
+;; Lazily-loaded oauth2-auto / plstore internals used by the cache-fix advice
+;; below. oauth2-auto is required at runtime inside the advised function; these
+;; declarations satisfy the byte-compiler without forcing an eager load.
+(declare-function oauth2-auto--compute-id "oauth2-auto")
+(declare-function plstore-get "plstore")
+(declare-function plstore-close "plstore")
+(defvar oauth2-auto--plstore-cache)
+(defvar oauth2-auto-plstore)
+
(defcustom cj/auth-source-debug-enabled nil
"Non-nil means enable verbose auth-source debug logging.
diff --git a/modules/browser-config.el b/modules/browser-config.el
index 0312cdd18..d596b9e9d 100644
--- a/modules/browser-config.el
+++ b/modules/browser-config.el
@@ -145,7 +145,8 @@ Persists the choice for future sessions."
(defun cj/--do-initialize-browser ()
"Initialize browser configuration.
Returns: (cons \\='loaded browser-plist) if saved choice was loaded,
- (cons \\='first-available browser-plist) if using first discovered browser,
+ (cons \\='first-available browser-plist) if using first
+ discovered browser,
(cons \\='no-browsers nil) if no browsers found."
(let ((saved-choice (cj/load-browser-choice)))
(if saved-choice
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 2ff535668..c0e0e935a 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -223,7 +223,7 @@ Example: -21600 for CST (UTC-6), -28800 for PST (UTC-8)."
(defun calendar-sync--format-timezone-offset (offset)
"Format timezone OFFSET (in seconds) as human-readable string.
-Example: -21600 → 'UTC-6' or 'UTC-6:00'."
+Example: -21600 → `UTC-6' or `UTC-6:00'."
(if (null offset)
"unknown"
(let* ((hours (/ offset 3600))
@@ -255,8 +255,10 @@ Example: -21600 → 'UTC-6' or 'UTC-6:00'."
(dir (file-name-directory calendar-sync--state-file)))
(unless (file-directory-p dir)
(make-directory dir t))
- (with-temp-file calendar-sync--state-file
- (prin1 state (current-buffer)))))
+ (let ((tmp (make-temp-file (expand-file-name ".calendar-sync-state-" dir))))
+ (with-temp-file tmp
+ (prin1 state (current-buffer)))
+ (rename-file tmp calendar-sync--state-file t))))
(defun calendar-sync--load-state ()
"Load sync state from disk."
@@ -289,7 +291,7 @@ Example: -21600 → 'UTC-6' or 'UTC-6:00'."
"Normalize line endings in CONTENT to Unix format (LF only).
Removes all carriage return characters (\\r) from CONTENT.
The iCalendar format (RFC 5545) uses CRLF line endings, but Emacs
-and 'org-mode' expect LF only. This function ensures consistent line
+and `org-mode' expect LF only. This function ensures consistent line
endings throughout the parsing pipeline.
Returns CONTENT with all \\r characters removed."
@@ -423,14 +425,16 @@ Handles both simple values and values with parameters like TZID."
(defun calendar-sync--get-recurrence-id-line (event-str)
"Extract full RECURRENCE-ID line from EVENT-STR, including parameters.
-Returns the complete line like 'RECURRENCE-ID;TZID=Europe/Tallinn:20260203T170000'.
+Returns the complete line like
+`RECURRENCE-ID;TZID=Europe/Tallinn:20260203T170000'.
Returns nil if not found."
(when (and event-str (stringp event-str))
(calendar-sync--get-property-line event-str "RECURRENCE-ID")))
(defun calendar-sync--parse-ics-datetime (value)
"Parse iCal datetime VALUE into (year month day hour minute) list.
-Returns nil for invalid input. For date-only values, returns (year month day nil nil).
+Returns nil for invalid input. For date-only values, returns
+(year month day nil nil).
Handles formats: 20260203T090000Z, 20260203T090000, 20260203."
(when (and value
(stringp value)
@@ -493,7 +497,8 @@ start time fail to parse. The plist holds :recurrence-id (localized),
(defun calendar-sync--collect-recurrence-exceptions (ics-content)
"Collect all RECURRENCE-ID events from ICS-CONTENT.
Returns hash table mapping UID to list of exception event plists.
-Each exception plist contains :recurrence-id (parsed), :start, :end, :summary, etc."
+Each exception plist contains :recurrence-id (parsed), :start, :end,
+:summary, etc."
(let ((exceptions (make-hash-table :test 'equal)))
(when (and ics-content (stringp ics-content))
(dolist (event-str (calendar-sync--split-events ics-content))
@@ -537,7 +542,15 @@ Compares year, month, day, hour, minute."
(plist-put result :location (plist-get exception :location)))
;; Pass through new fields if exception overrides them
(when (plist-get exception :attendees)
- (plist-put result :attendees (plist-get exception :attendees)))
+ (plist-put result :attendees (plist-get exception :attendees))
+ ;; Re-derive the user's status from the overridden attendees so a
+ ;; singly-declined occurrence drops its inherited series "accepted"
+ ;; (otherwise `calendar-sync--filter-declined' can't drop it). Leave the
+ ;; inherited status when the override doesn't name the user.
+ (let ((status (calendar-sync--find-user-status
+ (plist-get exception :attendees) calendar-sync-user-emails)))
+ (when status
+ (plist-put result :status status))))
(when (plist-get exception :organizer)
(plist-put result :organizer (plist-get exception :organizer)))
(when (plist-get exception :url)
@@ -571,7 +584,8 @@ Returns new list with matching occurrences replaced by exception times."
(defun calendar-sync--get-exdates (event-str)
"Extract all EXDATE values from EVENT-STR.
-Returns list of datetime strings (without TZID parameters), or nil if none found.
+Returns list of datetime strings (without TZID parameters), or nil if
+none found.
Handles both simple values and values with parameters like TZID."
(when (and event-str (stringp event-str) (not (string-empty-p event-str)))
(let ((exdates '())
@@ -584,7 +598,8 @@ Handles both simple values and values with parameters like TZID."
(defun calendar-sync--get-exdate-line (event-str exdate-value)
"Find the full EXDATE line containing EXDATE-VALUE from EVENT-STR.
-Returns the complete line like 'EXDATE;TZID=America/New_York:20260210T130000'.
+Returns the complete line like
+`EXDATE;TZID=America/New_York:20260210T130000'.
Returns nil if not found."
(when (and event-str (stringp event-str) exdate-value)
(let ((pattern (format "^\\(EXDATE[^:]*:%s\\)" (regexp-quote exdate-value))))
@@ -618,7 +633,8 @@ Converts TZID-qualified and UTC times to local time."
(defun calendar-sync--exdate-matches-p (occurrence-start exdate)
"Check if OCCURRENCE-START matches EXDATE.
OCCURRENCE-START is (year month day hour minute).
-EXDATE is (year month day hour minute) or (year month day nil nil) for date-only.
+EXDATE is (year month day hour minute) or (year month day nil nil) for
+date-only.
Date-only EXDATE matches any time on that day."
(and occurrence-start exdate
(= (nth 0 occurrence-start) (nth 0 exdate)) ; year
@@ -682,7 +698,8 @@ Returns nil if property not found."
(defun calendar-sync--get-property-line (event property)
"Extract full PROPERTY line from EVENT string, including parameters.
-Returns the complete line like 'DTSTART;TZID=Europe/Lisbon:20260202T190000'.
+Returns the complete line like
+`DTSTART;TZID=Europe/Lisbon:20260202T190000'.
Returns nil if property not found."
(when (string-match (format "^\\(%s[^\n]*\\)$" (regexp-quote property)) event)
(match-string 1 event)))
@@ -790,8 +807,8 @@ Returns URL string or nil."
(defun calendar-sync--extract-tzid (property-line)
"Extract TZID parameter value from PROPERTY-LINE.
-PROPERTY-LINE is like 'DTSTART;TZID=Europe/Lisbon:20260202T190000'.
-Returns timezone string like 'Europe/Lisbon', or nil if no TZID.
+PROPERTY-LINE is like `DTSTART;TZID=Europe/Lisbon:20260202T190000'.
+Returns timezone string like `Europe/Lisbon', or nil if no TZID.
Returns nil for malformed lines (missing colon separator)."
(when (and property-line
(stringp property-line)
@@ -813,7 +830,7 @@ Returns list (year month day hour minute) in local timezone."
(defun calendar-sync--convert-tz-to-local (year month day hour minute source-tz)
"Convert datetime from SOURCE-TZ timezone to local time.
-SOURCE-TZ is a timezone name like 'Europe/Lisbon' or 'Asia/Yerevan'.
+SOURCE-TZ is a timezone name like `Europe/Lisbon' or `Asia/Yerevan'.
Returns list (year month day hour minute) in local timezone, or nil on error.
Uses Emacs built-in timezone support (encode-time/decode-time with ZONE
@@ -837,8 +854,10 @@ TZ database as the `date' command."
"Convert PARSED datetime to local time using timezone info.
PARSED is (year month day hour minute) or (year month day nil nil).
IS-UTC non-nil means the value had a Z suffix.
+
TZID is a timezone string like \"Europe/Lisbon\", or nil.
-Returns PARSED converted to local time, or PARSED unchanged if no conversion needed."
+Returns PARSED converted to local time, or PARSED unchanged if no
+conversion needed."
(cond
(is-utc
(calendar-sync--convert-utc-to-local
@@ -856,7 +875,8 @@ Returns PARSED converted to local time, or PARSED unchanged if no conversion nee
"Parse iCal timestamp string TIMESTAMP-STR.
Returns (year month day hour minute) or (year month day) for all-day events.
Converts UTC times (ending in Z) to local time.
-If TZID is provided (e.g., 'Europe/Lisbon'), converts from that timezone to local.
+If TZID is provided (e.g., `Europe/Lisbon'), converts from that timezone
+to local.
Returns nil if parsing fails."
(cond
;; DateTime format: 20251116T140000Z or 20251116T140000
@@ -913,7 +933,8 @@ Returns string like '<2025-11-16 Sun 14:00-15:00>' or '<2025-11-16 Sun>'."
(defun calendar-sync--date-to-time (date)
"Convert DATE to time value for comparison.
DATE should be a list starting with (year month day ...).
-Only the first three elements are used; extra elements (hour, minute) are ignored."
+Only the first three elements are used; extra elements (hour, minute) are
+ignored."
(let ((day (nth 2 date))
(month (nth 1 date))
(year (nth 0 date)))
@@ -1082,7 +1103,8 @@ Returns nil if event lacks required fields (DTSTART, SUMMARY).
Skips events with RECURRENCE-ID (individual instances of recurring events
are handled separately via exception collection).
Handles TZID-qualified timestamps by converting to local time.
-Cleans text fields (description, location, summary) via `calendar-sync--clean-text'."
+Cleans text fields (description, location, summary) via
+`calendar-sync--clean-text'."
;; Skip individual instances of recurring events (they're collected as exceptions)
(unless (calendar-sync--get-property event-str "RECURRENCE-ID")
(let* ((uid (calendar-sync--get-property event-str "UID"))
@@ -1228,11 +1250,19 @@ RECURRENCE-ID exceptions are applied to override specific occurrences."
(time-less-p (calendar-sync--event-start-time a)
(calendar-sync--event-start-time b)))))
(org-entries (mapcar #'calendar-sync--event-to-org sorted-events)))
- (if org-entries
- (concat "# Calendar Events\n\n"
- (string-join org-entries "\n\n")
- "\n")
- nil)))
+ ;; Distinguish a healthy zero-event calendar from garbage: a real
+ ;; iCalendar (carries BEGIN:VCALENDAR) with no in-window events
+ ;; returns the header alone, so the caller writes an empty calendar
+ ;; and reports success. Non-iCalendar content (an HTML error page, a
+ ;; truncated download) has no VCALENDAR and returns nil -- a failure.
+ (cond
+ (org-entries
+ (concat "# Calendar Events\n\n"
+ (string-join org-entries "\n\n")
+ "\n"))
+ ((string-match-p "BEGIN:VCALENDAR" ics-content)
+ "# Calendar Events\n\n")
+ (t nil))))
(error
(calendar-sync--log-silently "calendar-sync: Parse error: %s" (error-message-string err))
nil)))
@@ -1251,7 +1281,7 @@ invoked when the fetch completes, either successfully or with an error."
(make-process
:name "calendar-sync-curl"
:buffer buffer
- :command (list "curl" "-s" "-L"
+ :command (list "curl" "-s" "-L" "--fail"
"--connect-timeout" "10"
"--max-time" (number-to-string calendar-sync-fetch-timeout)
url)
@@ -1283,7 +1313,7 @@ owns deleting the temp file after a successful callback."
(make-process
:name "calendar-sync-curl"
:buffer buffer
- :command (list "curl" "-s" "-L"
+ :command (list "curl" "-s" "-L" "--fail"
"--connect-timeout" "10"
"--max-time" (number-to-string calendar-sync-fetch-timeout)
"-o" temp-file
@@ -1309,13 +1339,17 @@ owns deleting the temp file after a successful callback."
(funcall callback nil))))
(defun calendar-sync--write-file (content file)
- "Write CONTENT to FILE.
-Creates parent directories if needed."
+ "Write CONTENT to FILE atomically.
+Creates parent directories if needed, then writes a temp file in the same
+directory and renames it into place, so org-agenda or chime reading mid-write
+never sees a half-written calendar."
(let ((dir (file-name-directory file)))
(unless (file-directory-p dir)
- (make-directory dir t)))
- (with-temp-file file
- (insert content)))
+ (make-directory dir t))
+ (let ((tmp (make-temp-file (expand-file-name ".calendar-sync-" dir))))
+ (with-temp-file tmp
+ (insert content))
+ (rename-file tmp file t))))
(defun calendar-sync--emacs-binary ()
"Return the Emacs executable to use for calendar conversion workers."
diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el
index 6d5963515..1e6437d26 100644
--- a/modules/calibredb-epub-config.el
+++ b/modules/calibredb-epub-config.el
@@ -77,6 +77,13 @@
(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 filter-state vars (set by cj/calibredb-clear-filters and friends)
+(defvar calibredb-tag-filter-p) ; from calibredb-search.el
+(defvar calibredb-favorite-filter-p) ; from calibredb-search.el
+(defvar calibredb-author-filter-p) ; from calibredb-search.el
+(defvar calibredb-date-filter-p) ; from calibredb-search.el
+(defvar calibredb-format-filter-p) ; from calibredb-search.el
+(defvar calibredb-search-current-page) ; from calibredb-search.el
;; -------------------------- CalibreDB Ebook Manager --------------------------
@@ -306,11 +313,11 @@ A positive DELTA narrows the text column; a negative DELTA widens it."
"Apply preferences after nov-mode has launched."
(interactive)
;; Use Merriweather for comfortable reading with appropriate scaling.
- ;; Darker sepia color (#E8DCC0) is easier on the eyes than pure white.
- (let ((sepia "#E8DCC0"))
- (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.0 :foreground sepia)
- (face-remap-add-relative 'default :family "Merriweather" :height 180 :foreground sepia)
- (face-remap-add-relative 'fixed-pitch :height 180 :foreground sepia))
+ ;; (Reading fg color stripped; falls back to the theme default until a
+ ;; themeable reading face exists -- see todo.org.)
+ (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.0)
+ (face-remap-add-relative 'default :family "Merriweather" :height 180)
+ (face-remap-add-relative 'fixed-pitch :height 180)
;; Enable visual-line-mode for proper text wrapping
(visual-line-mode 1)
;; Set fill-column as a fallback
diff --git a/modules/chrono-tools.el b/modules/chrono-tools.el
index 6f88b2018..744781268 100644
--- a/modules/chrono-tools.el
+++ b/modules/chrono-tools.el
@@ -22,6 +22,11 @@
(require 'user-constants)
+;; Declared by the lazily-loaded `tmr' package; quiet the byte-compiler
+;; without forcing the package to load.
+(defvar tmr-sound-file)
+(defvar tmr-descriptions-list)
+
;; -------------------------------- Time Zones ---------------------------------
(use-package time-zones
diff --git a/modules/config-utilities.el b/modules/config-utilities.el
index b3eec5d3d..f448327c1 100644
--- a/modules/config-utilities.el
+++ b/modules/config-utilities.el
@@ -21,6 +21,19 @@
(require 'find-lisp)
(require 'profiler)
+;; External variables referenced at runtime only (org and the native
+;; compiler are loaded lazily; declare to quiet the byte-compiler).
+(defvar comp-async-report-warnings-errors)
+(defvar org-ts-regexp)
+(defvar org-agenda-files)
+
+;; External functions referenced at runtime only.
+(declare-function org-element-parse-buffer "org-element")
+(declare-function org-element-map "org-element")
+(declare-function org-element-property "org-element-ast")
+(declare-function org-time-string-to-absolute "org")
+(declare-function org-alert-check "org-alert")
+
;;; -------------------------------- Debug Keymap -------------------------------
(defvar-keymap cj/debug-config-keymap
@@ -65,13 +78,15 @@
(with-eval-after-load 'emacsql-sqlite-builtin
(cl-defmethod emacsql-close :around
((connection emacsql-sqlite-builtin-connection))
- (when (oref connection handle)
+ ;; The class is loaded lazily, so the slot is unknown at compile time.
+ (when (with-no-warnings (oref connection handle))
(cl-call-next-method))))
(with-eval-after-load 'emacsql-sqlite-module
(cl-defmethod emacsql-close :around
((connection emacsql-sqlite-module-connection))
- (when (oref connection handle)
+ ;; The class is loaded lazily, so the slot is unknown at compile time.
+ (when (with-no-warnings (oref connection handle))
(cl-call-next-method))))
;;; -------------------------------- Benchmarking -------------------------------
diff --git a/modules/coverage-core.el b/modules/coverage-core.el
index 9b102bb7b..e8f7a4740 100644
--- a/modules/coverage-core.el
+++ b/modules/coverage-core.el
@@ -25,6 +25,15 @@
(require 'subr-x)
(require 'system-lib)
+;; Make json.el's reader variables visible to the byte/native compiler so the
+;; `let' bindings of `json-object-type' / `json-array-type' / `json-key-type'
+;; in the parse helpers below bind dynamically. Without this the compiler
+;; treats them as lexical (this file is lexical-binding), the bindings never
+;; reach `json-read-file', and it returns json.el's default alist instead of
+;; the hash tables the parsers maphash over. The runtime `(require 'json)'
+;; inside each helper still keeps json off the load-time path.
+(eval-when-compile (require 'json))
+
(defvar cj/coverage-backends nil
"Registry of coverage backends in priority order.
Each entry is a plist with at least :name, :detect, :run, and :report-path.
diff --git a/modules/custom-ordering.el b/modules/custom-ordering.el
index a2423742d..0a499a35a 100644
--- a/modules/custom-ordering.el
+++ b/modules/custom-ordering.el
@@ -49,10 +49,10 @@ buffer region and must reject an inverted one before reading it."
(defun cj/--ordering-replace-region (start end insertion)
"Replace the buffer text between START and END with INSERTION.
-Point is left after the inserted text. Shared tail for the interactive ordering commands,
-which all compute a transformed string from the original region then swap it
-in. INSERTION is evaluated by the caller before this runs, so the transform
-reads the pre-deletion text."
+Point is left after the inserted text. Shared tail for the interactive
+ordering commands, which all compute a transformed string from the
+original region then swap it in. INSERTION is evaluated by the caller
+before this runs, so the transform reads the pre-deletion text."
(delete-region start end)
(goto-char start)
(insert insertion))
diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el
index 38510e801..96aaaf6a1 100644
--- a/modules/dashboard-config.el
+++ b/modules/dashboard-config.el
@@ -23,6 +23,57 @@
(autoload 'cj/make-buffer-undead "undead-buffers" nil t)
(declare-function ghostel "ghostel" (&optional arg))
+;; ------------------------------ Declarations -------------------------------
+;; These functions and variables belong to lazily-loaded packages or to other
+;; cj modules; declaring them keeps the byte-compiler quiet without forcing an
+;; eager require. Behavior is unchanged -- the symbols still resolve at runtime
+;; once their owning package/module loads.
+
+;; dashboard package internals used by the bookmark-insertion override.
+(declare-function dashboard-insert-section "dashboard")
+(declare-function dashboard-subseq "dashboard")
+(declare-function dashboard-get-shortcut "dashboard")
+(declare-function dashboard-shorten-path "dashboard")
+(declare-function dashboard--align-length-by-type "dashboard")
+(declare-function dashboard--generate-align-format "dashboard")
+(declare-function dashboard-refresh-buffer "dashboard")
+(declare-function dashboard-open "dashboard")
+(defvar dashboard-bookmarks-show-path)
+(defvar dashboard--bookmarks-cache-item-format)
+
+;; bookmark.el (required at runtime inside `dashboard-insert-bookmarks').
+(declare-function bookmark-all-names "bookmark")
+(declare-function bookmark-get-filename "bookmark")
+
+;; recentf.el (required at runtime inside the exclude helper).
+(defvar recentf-exclude)
+
+;; nerd-icons glyph functions used in the launcher table.
+(declare-function nerd-icons-faicon "nerd-icons")
+(declare-function nerd-icons-devicon "nerd-icons")
+(declare-function nerd-icons-mdicon "nerd-icons")
+(declare-function nerd-icons-codicon "nerd-icons")
+(declare-function nerd-icons-octicon "nerd-icons")
+
+;; user-constants.el provides the home-directory constant.
+(defvar user-home-dir)
+
+;; Launcher actions defined in other cj modules.
+(declare-function cj/main-agenda-display "org-agenda-config")
+(declare-function cj/elfeed-open "elfeed-config")
+(declare-function cj/drill-start "org-drill-config")
+(declare-function cj/music-playlist-toggle "music-config")
+(declare-function cj/music-playlist-load "music-config")
+(declare-function cj/erc-switch-to-buffer-with-completion "erc-config")
+(declare-function cj/telega "telega-config")
+(declare-function cj/slack-start "slack-config")
+(declare-function cj/signel-message "signal-config")
+(declare-function cj/kill-all-other-buffers-and-windows "undead-buffers")
+
+;; External package commands invoked by launchers.
+(declare-function mu4e "mu4e")
+(declare-function pearl-list-issues "pearl")
+
;; ------------------------ Dashboard Bookmarks Override -----------------------
;; overrides the bookmark insertion from the dashboard package to provide an
;; option that only shows the bookmark name, avoiding the path. Paths are often
@@ -35,8 +86,11 @@
;; `el' is bound dynamically by dashboard's section-insertion machinery, which the
;; override below plugs into. Declare it so the byte-compiler reads the
-;; references as that special variable rather than a free variable.
-(defvar el)
+;; references as that special variable rather than a free variable. The name is
+;; dashboard's, not ours, so the missing-prefix lint is suppressed rather than
+;; renamed (renaming would break the dynamic binding dashboard supplies).
+(with-suppressed-warnings ((lexical el))
+ (defvar el))
(defun dashboard-insert-bookmarks (list-size)
"Add the list of LIST-SIZE items of bookmarks."
@@ -86,7 +140,7 @@ Adjust this if the title doesn't appear centered under the banner image.")
(list "t" #'nerd-icons-devicon "nf-dev-terminal" "Terminal" "Launch Terminal" (lambda () (ghostel)))
(list "a" #'nerd-icons-mdicon "nf-md-calendar" "Agenda" "Main Org Agenda" (lambda () (cj/main-agenda-display)))
(list "r" #'nerd-icons-faicon "nf-fa-rss_square" "Feeds" "Elfeed Feed Reader" (lambda () (cj/elfeed-open)))
- (list "b" #'nerd-icons-faicon "nf-fae-book_open_o" "Books" "Calibre Ebook Reader" (lambda () (calibredb)))
+ (list "b" #'nerd-icons-codicon "nf-cod-library" "Books" "Calibre Ebook Reader" (lambda () (calibredb)))
(list "f" #'nerd-icons-mdicon "nf-md-school" "Flashcards" "Org-Drill" (lambda () (cj/drill-start)))
(list "m" #'nerd-icons-mdicon "nf-md-music" "Music" "EMMS Music Player" (lambda () (cj/music-playlist-toggle) (cj/music-playlist-load)))
(list "e" #'nerd-icons-faicon "nf-fa-envelope" "Email" "Mu4e Email Client" (lambda () (mu4e)))
@@ -219,6 +273,7 @@ system-defaults) are preserved rather than overwritten."
(setq dashboard-display-icons-p t) ;; display icons on both GUI and terminal
(setq dashboard-icon-type 'nerd-icons) ;; use `nerd-icons' package
(setq dashboard-set-file-icons t) ;; per-filetype icons on the list items (nerd-icons colors them by type)
+ (setq dashboard-set-heading-icons t) ;; nerd-icons on the section titles (Projects/Bookmarks/Recent)
(setq dashboard-center-content t) ;; horizontally center dashboard content
(setq dashboard-bookmarks-show-path nil) ;; don't show paths in bookmarks
(setq dashboard-recentf-show-base t) ;; show filename, not full path
diff --git a/modules/diff-config.el b/modules/diff-config.el
index 75869a73f..0c09b9516 100644
--- a/modules/diff-config.el
+++ b/modules/diff-config.el
@@ -28,6 +28,12 @@
;;; Code:
+(declare-function ediff-setup-keymap "ediff")
+(declare-function ediff-next-difference "ediff")
+(declare-function ediff-previous-difference "ediff")
+(declare-function cj/ediff-hook "diff-config")
+(declare-function winner-undo "winner")
+
(use-package ediff
:ensure nil ;; built-in
:defer t
diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el
index 04f9ce20e..81d352dbd 100644
--- a/modules/dirvish-config.el
+++ b/modules/dirvish-config.el
@@ -41,6 +41,24 @@
(declare-function cj/drill-this-file "org-drill-config")
+;; Dirvish/Dired functions called from lazy-loaded packages.
+(declare-function dirvish-peek-mode "dirvish")
+(declare-function dirvish-side-follow-mode "dirvish")
+(declare-function dirvish-quit "dirvish")
+(declare-function dired-get-marked-files "dired")
+(declare-function dired-dwim-target-directory "dired-aux")
+(declare-function dired-get-file-for-visit "dired")
+(declare-function dired-get-filename "dired")
+(declare-function dired-mark "dired")
+(declare-function dired-current-directory "dired")
+(declare-function dired-file-name-at-point "dired-x")
+(declare-function dired-find-file "dired")
+(declare-function project-roots "project")
+
+;; External package variables referenced before their package loads.
+(defvar ediff-after-quit-hook-internal)
+(defvar dirvish-side-attributes)
+
;; mark files in dirvish, attach in mu4e
(add-hook 'dired-mode-hook 'turn-on-gnus-dired-mode)
@@ -349,7 +367,8 @@ Shadows dired's `P' (`dired-do-print') with this type-aware version."
(defun cj/dirvish-drill-file ()
"Open the Org file at point and start an `org-drill' session on it.
-Bound to `S' (\"study\") in `dirvish-mode-map'; refuses anything but a `.org' file."
+Bound to `S' (\"study\") in `dirvish-mode-map'; refuses anything but
+a `.org' file."
(interactive)
(let ((file (dired-get-filename nil t)))
(unless (and file (not (file-directory-p file)) (string-suffix-p ".org" file t))
@@ -381,18 +400,19 @@ regardless of what file or subdirectory the point is on."
"Return the (PROGRAM PRE-FILE-ARG...) list for setting wallpaper under ENV.
ENV is a display-server symbol: `x11' picks feh with --bg-fill, `wayland'
-picks swww with the img subcommand. Any other value returns nil so the
-caller can surface an \"unknown display server\" error.
+picks the `set-wallpaper' script (on PATH from dotfiles; it wraps the awww
+backend and persists the choice to waypaper's config). Any other value
+returns nil so the caller can surface an \"unknown display server\" error.
Pure helper used by `cj/set-wallpaper'."
(pcase env
('x11 '("feh" "--bg-fill"))
- ('wayland '("swww" "img"))
+ ('wayland '("set-wallpaper"))
(_ nil)))
(defun cj/set-wallpaper ()
"Set the image at point as the desktop wallpaper.
-Uses feh on X11, swww on Wayland."
+Uses feh on X11, the `set-wallpaper' script on Wayland."
(interactive)
(let* ((raw (dired-file-name-at-point))
(file (and raw (expand-file-name raw)))
@@ -466,6 +486,22 @@ leaves an empty frame behind."
(when (frame-live-p popup) (delete-frame popup)))
(dirvish-quit))))
+(defun cj/--dirvish-popup-reap-on-delete (frame)
+ "Quit the Dirvish session when the Super+F popup FRAME is closed any way.
+`q' runs `cj/dirvish-popup-quit', but closing the Hyprland float directly (or
+letting it lose focus) bypasses that and orphans the session's dired buffers --
+the \"leaves a load of buffers around\" symptom. As a `delete-frame-functions'
+hook this fires on every close path; `dirvish-quit' reaps the session's buffers
+(verified: a navigated session drops back to baseline on quit). Scoped to the
+popup frame so ordinary `C-x d' sessions -- where multiple dired buffers are
+wanted for mark-and-move -- are untouched."
+ (when (and (frame-live-p frame)
+ (equal (frame-parameter frame 'name) "dirvish"))
+ (with-selected-frame frame
+ (ignore-errors (dirvish-quit)))))
+
+(add-hook 'delete-frame-functions #'cj/--dirvish-popup-reap-on-delete)
+
(defun cj/--dirvish-popup-selected-p ()
"Return non-nil when the selected frame is the dirvish popup frame."
(let ((popup (cj/--dirvish-popup-frame)))
diff --git a/modules/dwim-shell-config.el b/modules/dwim-shell-config.el
index 230a8532c..014194c7b 100644
--- a/modules/dwim-shell-config.el
+++ b/modules/dwim-shell-config.el
@@ -100,6 +100,16 @@
(require 'cl-lib)
(require 'system-lib) ;; cj/confirm-strong (permanent file destruction confirm)
+;; Function declarations (lazily-loaded packages and sibling modules).
+(declare-function dwim-shell-command-on-marked-files "dwim-shell-command")
+(declare-function dwim-shell-command-read-file-name "dwim-shell-command")
+(declare-function dwim-shell-command--files "dwim-shell-command")
+(declare-function cj/xdg-open "external-open")
+(declare-function dwim-shell-commands-menu "dwim-shell-config")
+
+;; Forward declaration: external variable provided by the dirvish package.
+(defvar dirvish-mode-map)
+
;; --------------------------- Password-file helpers ---------------------------
(defun cj/dwim-shell--password-cleanup-callback (temp-file)
diff --git a/modules/elfeed-config.el b/modules/elfeed-config.el
index 7712f48db..7b4d7d745 100644
--- a/modules/elfeed-config.el
+++ b/modules/elfeed-config.el
@@ -29,21 +29,26 @@
(require 'system-lib)
(require 'media-utils)
+(declare-function elfeed "elfeed")
+(declare-function elfeed-update "elfeed")
+(declare-function elfeed-entry-link "elfeed")
+(declare-function elfeed-untag "elfeed")
+(declare-function elfeed-search-selected "elfeed")
+(declare-function elfeed-search-tag-all "elfeed")
+(declare-function elfeed-search-update-entry "elfeed")
+(declare-function elfeed-search-update--force "elfeed")
+(declare-function elfeed-search-untag-all-unread "elfeed")
+(declare-function eww-browse-url "eww")
+(declare-function eww-readable "eww")
+
;; ------------------------------- Elfeed Config -------------------------------
(use-package elfeed
:bind
- ("M-S-r" . cj/elfeed-open) ;; was M-R
(:map elfeed-show-mode-map
("w" . eww-open-in-new-buffer))
(:map elfeed-search-mode-map
- ("w" . cj/elfeed-eww-open) ;; opens in eww
- ("b" . cj/elfeed-browser-open) ;; opens in external browser
- ("d" . cj/elfeed-youtube-dl) ;; async download with yt-dlp and tsp
- ("v" . cj/play-with-video-player)) ;; async play with mpv
- ("V" . cj/select-media-player) ;; Capital V to select player
- ("R" . cj/elfeed-mark-all-as-read) ;; capital marks all as read, since upper case marks one as read
- ("U" . cj/elfeed-mark-all-as-unread) ;; capital marks all as unread, since lower case marks one as unread
+ ("V" . cj/select-media-player)) ;; Capital V to select player
:config
(setq elfeed-db-directory (concat user-emacs-directory ".elfeed-db"))
(setq-default elfeed-search-title-max-width 150)
@@ -90,19 +95,22 @@
(elfeed)
(elfeed-update)
(elfeed-search-update--force))
+(keymap-global-set "M-S-r" #'cj/elfeed-open) ;; was M-R
;; -------------------------- Elfeed Filter Functions --------------------------
(defun cj/elfeed-mark-all-as-read ()
"Remove the \='unread\=' tag from all visible entries in search buffer."
(interactive)
- (mark-whole-buffer)
+ (goto-char (point-min))
+ (push-mark (point-max) nil t)
(elfeed-search-untag-all-unread))
(defun cj/elfeed-mark-all-as-unread ()
"Add the \='unread\=' tag from all visible entries in the search buffer."
(interactive)
- (mark-whole-buffer)
+ (goto-char (point-min))
+ (push-mark (point-max) nil t)
(elfeed-search-tag-all 'unread))
(defun cj/elfeed-set-filter-and-update (filterstring)
@@ -302,5 +310,18 @@ TYPE should be either \='channel or \='playlist."
(insert result))
result))
+;; --------------------------- Search-Mode Keybindings -------------------------
+;; Bound here (not in use-package :bind) because these commands are defined in
+;; this file; a :bind autoload stub plus the defun triggers a "defined multiple
+;; times" byte-compile warning.
+
+(with-eval-after-load 'elfeed
+ (keymap-set elfeed-search-mode-map "w" #'cj/elfeed-eww-open) ;; opens in eww
+ (keymap-set elfeed-search-mode-map "b" #'cj/elfeed-browser-open) ;; opens in external browser
+ (keymap-set elfeed-search-mode-map "d" #'cj/elfeed-youtube-dl) ;; async download with yt-dlp and tsp
+ (keymap-set elfeed-search-mode-map "v" #'cj/play-with-video-player) ;; async play with mpv
+ (keymap-set elfeed-search-mode-map "R" #'cj/elfeed-mark-all-as-read) ;; capital R marks all read (lower case marks one)
+ (keymap-set elfeed-search-mode-map "U" #'cj/elfeed-mark-all-as-unread)) ;; capital U marks all unread (lower case marks one)
+
(provide 'elfeed-config)
;;; elfeed-config.el ends here.
diff --git a/modules/erc-config.el b/modules/erc-config.el
index c89e46bb3..3e98a66a3 100644
--- a/modules/erc-config.el
+++ b/modules/erc-config.el
@@ -33,6 +33,33 @@
;; is read at load time below (erc-user-full-name), so a standalone .elc needs it.
(require 'user-constants)
+;; ERC loads lazily (use-package :commands), so these symbols aren't bound at
+;; this file's compile time. Declare them to keep the byte-compiler quiet
+;; without forcing an eager require.
+
+;; Functions provided by the erc package.
+(declare-function erc-buffer-list "erc")
+(declare-function erc-server-process-alive "erc")
+(declare-function erc-server-or-unjoined-channel-buffer-p "erc")
+(declare-function erc-current-nick "erc")
+(declare-function erc-join-channel "erc")
+(declare-function erc-part-from-channel "erc")
+(declare-function erc-quit-server "erc")
+
+;; Variables read/set in the use-package :config block below.
+(defvar erc-log-channels-directory)
+(defvar erc-track-exclude-types)
+(defvar erc-track-exclude-server-buffer)
+(defvar erc-track-visibility)
+(defvar erc-track-switch-direction)
+(defvar erc-track-showcount)
+;; NOTE: erc-unique-buffers and erc-generate-buffer-name-function are not ERC
+;; variables in Emacs 30.x (no defcustom/defvar in the package); the setq below
+;; only creates inert globals. Declared here to silence the warning without
+;; changing the existing (no-op) behavior -- see the SUSPICIOUS note.
+(defvar erc-unique-buffers)
+(defvar erc-generate-buffer-name-function)
+
;; ------------------------------------ ERC ------------------------------------
;; Server definitions and connection settings
@@ -99,7 +126,7 @@ Change this value to use a different nickname.")
(let ((server-buffers '()))
(dolist (buf (erc-buffer-list))
(with-current-buffer buf
- (when (and (erc-server-buffer-p) (erc-server-process-alive))
+ (when (and (erc-server-or-unjoined-channel-buffer-p) (erc-server-process-alive))
(unless (member (buffer-name) server-buffers)
(push (buffer-name) server-buffers)))))
@@ -132,7 +159,7 @@ Buffer names are shown with server context for clarity."
"Return t if the current buffer is an active ERC server buffer."
(and (derived-mode-p 'erc-mode)
(erc-server-process-alive)
- (erc-server-buffer-p)))
+ (erc-server-or-unjoined-channel-buffer-p)))
(defun cj/erc-get-channels-for-current-server ()
@@ -158,7 +185,7 @@ Auto-adds # prefix if missing. Offers completion from configured channels."
(let ((server-buffers (cl-remove-if-not
(lambda (buf)
(with-current-buffer buf
- (and (erc-server-buffer-p)
+ (and (erc-server-or-unjoined-channel-buffer-p)
(erc-server-process-alive))))
(erc-buffer-list))))
(if server-buffers
diff --git a/modules/eshell-config.el b/modules/eshell-config.el
index d3c8ccdfd..c2ec6d152 100644
--- a/modules/eshell-config.el
+++ b/modules/eshell-config.el
@@ -26,6 +26,32 @@
(require 'system-utils)
+;; Eshell is loaded lazily (:commands eshell), so its vars and functions are
+;; not defined when this file is byte-compiled standalone. Declare them to
+;; silence compile-time free-variable / undefined-function warnings.
+(defvar eshell-banner-message)
+(defvar eshell-scroll-to-bottom-on-input)
+(defvar eshell-error-if-no-glob)
+(defvar eshell-hist-ignoredups)
+(defvar eshell-save-history-on-exit)
+(defvar eshell-prefer-lisp-functions)
+(defvar eshell-destroy-buffer-when-process-dies)
+(defvar eshell-prompt-function)
+(defvar eshell-cmpl-cycle-completions)
+(defvar eshell-modules-list)
+(defvar eshell-hist-mode-map)
+(defvar eshell-visual-commands)
+(defvar eshell-visual-subcommands)
+(defvar eshell-visual-options)
+(defvar eshell-history-ring)
+(defvar eshell-preoutput-filter-functions)
+(defvar eshell-output-filter-functions)
+
+(declare-function ring-elements "ring")
+(declare-function eshell-send-input "esh-mode")
+(declare-function eshell/pwd "em-dirs")
+(declare-function eshell/alias "em-alias")
+
(defgroup cj/eshell nil
"Personal Eshell configuration."
:group 'eshell)
@@ -75,15 +101,15 @@ pairs where COMMAND is the `cd' string `eshell/alias' should run."
(setq eshell-prompt-function
(lambda ()
(concat
- (propertize (format-time-string "[%d-%m-%y %T]") 'face '(:foreground "gray"))
+ (propertize (format-time-string "[%d-%m-%y %T]") 'face 'default)
" "
- (propertize (user-login-name) 'face '(:foreground "gray"))
+ (propertize (user-login-name) 'face 'default)
" "
- (propertize (system-name) 'face '(:foreground "gray"))
+ (propertize (system-name) 'face 'default)
":"
- (propertize (abbreviate-file-name (eshell/pwd)) 'face '(:foreground "gray"))
+ (propertize (abbreviate-file-name (eshell/pwd)) 'face 'default)
"\n"
- (propertize "%" 'face '(:foreground "white"))
+ (propertize "%" 'face 'default)
" ")))
(add-hook
diff --git a/modules/eww-config.el b/modules/eww-config.el
index a41a9a76e..ff7ddc211 100644
--- a/modules/eww-config.el
+++ b/modules/eww-config.el
@@ -32,6 +32,8 @@
(require 'cl-lib)
+(declare-function eww-add-bookmark "eww")
+
(defgroup my-eww-user-agent nil
"EWW-only User-Agent management."
:group 'eww)
@@ -42,6 +44,13 @@
:type 'string
:group 'my-eww-user-agent)
+;; This file is lexical-binding, so `let'-binding url.el's special var below
+;; needs it declared special at compile time. Without this the byte-compiled
+;; advice binds `url-request-extra-headers' lexically and the injected
+;; User-Agent never reaches `url-retrieve' (it reads the dynamic value) -- the
+;; UA injection silently no-ops in compiled production, and the test sees nil.
+(defvar url-request-extra-headers)
+
(defun my-eww--inject-user-agent (orig-fun &rest args)
"Set a User-Agent only when making requests from an EWW buffer."
(if (derived-mode-p 'eww-mode)
diff --git a/modules/face-diagnostic.el b/modules/face-diagnostic.el
index 6b1b547f1..a2bfe2483 100644
--- a/modules/face-diagnostic.el
+++ b/modules/face-diagnostic.el
@@ -298,6 +298,18 @@ mutation."
;; ------------------------------- Rendering -----------------------------------
+(defun cj/--face-diag-face-button (face)
+ "Render FACE as a button that runs `describe-face' on it.
+A real, named face becomes a `buttonize'd string (RET or mouse opens its
+`describe-face' help); anything else -- an anonymous (:attr val ...) spec or a
+symbol that is not a face -- is returned as a plain string so the report still
+reads cleanly."
+ (let ((label (format "%s" face)))
+ (if (and (symbolp face) (facep face))
+ (buttonize label (lambda (f) (describe-face f)) face
+ (format "describe-face: %s" face))
+ label)))
+
(defun cj/--face-diag-render-banner (classification)
"Return a one-line banner for an out-of-scope CLASSIFICATION, or \"\"."
(pcase classification
@@ -320,8 +332,9 @@ mutation."
(or (plist-get char :script) "none"))))
(defun cj/--face-diag-render-faces (faces)
- "Render a list of FACES (symbols or specs) comma-separated, or \"(none)\"."
- (if faces (mapconcat (lambda (f) (format "%s" f)) faces ", ") "(none)"))
+ "Render a list of FACES (symbols or specs) comma-separated, or \"(none)\".
+Real faces render as `describe-face' buttons (see `cj/--face-diag-face-button')."
+ (if faces (mapconcat #'cj/--face-diag-face-button faces ", ") "(none)"))
(defun cj/--face-diag-render-stack (stack)
"Render the STACK plist (faces by source) as a block."
@@ -329,18 +342,21 @@ mutation."
"Face stack (highest priority first):\n"
(format " text properties: %s\n"
(cj/--face-diag-render-faces (plist-get stack :text-property)))
- (format " overlays: %s\n"
- (let ((ov (plist-get stack :overlays)))
- (if ov
- (mapconcat (lambda (e)
- (format "%s (priority %s)"
- (plist-get e :face)
- (or (plist-get e :priority) "nil")))
- ov ", ")
- "(none)")))
- (format " active remaps: %s\n"
- (let ((rm (plist-get stack :remaps)))
- (if rm (mapconcat (lambda (e) (format "%s" (car e))) rm ", ") "(none)")))
+ " overlays: "
+ (let ((ov (plist-get stack :overlays)))
+ (if ov
+ (mapconcat (lambda (e)
+ (concat (cj/--face-diag-face-button (plist-get e :face))
+ (format " (priority %s)"
+ (or (plist-get e :priority) "nil"))))
+ ov ", ")
+ "(none)"))
+ "\n"
+ " active remaps: "
+ (let ((rm (plist-get stack :remaps)))
+ (if rm (mapconcat (lambda (e) (cj/--face-diag-face-button (car e))) rm ", ")
+ "(none)"))
+ "\n"
" default: default\n\n"))
(defun cj/--face-diag-render-attributes (attrs)
@@ -372,13 +388,15 @@ mutation."
(if prov
(mapconcat
(lambda (p)
- (format (concat " %s\n themes: %s\n config: %s\n"
- " inherits: %s\n unspecified (-> default): %s")
- (plist-get p :face)
- (or (plist-get p :themes) "(none)")
- (or (plist-get p :config) "(none)")
- (or (plist-get p :inherit-chain) "(none)")
- (or (plist-get p :unspecified) "(none)")))
+ (concat
+ " "
+ (cj/--face-diag-face-button (plist-get p :face))
+ (format (concat "\n themes: %s\n config: %s\n"
+ " inherits: %s\n unspecified (-> default): %s")
+ (or (plist-get p :themes) "(none)")
+ (or (plist-get p :config) "(none)")
+ (or (plist-get p :inherit-chain) "(none)")
+ (or (plist-get p :unspecified) "(none)"))))
prov "\n")
" (no named faces)")
"\n"))
diff --git a/modules/flycheck-config.el b/modules/flycheck-config.el
index 5626095c5..1afd3ae6c 100644
--- a/modules/flycheck-config.el
+++ b/modules/flycheck-config.el
@@ -45,6 +45,14 @@
(require 'keybindings) ;; provides cj/custom-keymap (use-package :map below)
+;; ------------------------------- Declarations --------------------------------
+
+(declare-function flycheck-mode "flycheck")
+(declare-function flycheck-list-errors "flycheck")
+(declare-function flycheck-add-mode "flycheck")
+(declare-function flycheck-buffer "flycheck")
+(declare-function cj/flycheck-prose-on-demand "flycheck-config")
+
(defun cj/prose-helpers-on ()
"Ensure that `abbrev-mode' and `flycheck-mode' are on in the current buffer."
(interactive)
diff --git a/modules/font-config.el b/modules/font-config.el
index 1c431c864..3272a946e 100644
--- a/modules/font-config.el
+++ b/modules/font-config.el
@@ -56,6 +56,9 @@
(require 'host-environment)
(require 'keybindings) ;; establishes the C-z prefix used for "C-z F" below
+(defvar text-scale-mode-step)
+(declare-function cj/disable-emojify-mode "font-config")
+
;; ---------------------- HarfBuzz Font Cache Crash Fix -----------------------
;; Prevents Emacs from compacting font caches during GC. Without this, GC can
;; free font cache entries that HarfBuzz still references, causing SIGSEGV
diff --git a/modules/games-config.el b/modules/games-config.el
index aa26d31ee..0ff01c809 100644
--- a/modules/games-config.el
+++ b/modules/games-config.el
@@ -25,6 +25,8 @@
(require 'user-constants) ;; org-dir
+(defvar malyon-stories-directory)
+
(with-eval-after-load 'malyon
(setq malyon-stories-directory (concat org-dir "text.games/")))
diff --git a/modules/google-keep-config.el b/modules/google-keep-config.el
new file mode 100644
index 000000000..1738fa6e0
--- /dev/null
+++ b/modules/google-keep-config.el
@@ -0,0 +1,210 @@
+;;; google-keep-config.el --- Google Keep -> org integration -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; A read-only view of Google Keep notes as an org page. `cj/keep-refresh'
+;; runs a Python gkeepapi bridge (scripts/google-keep/keep-bridge.py), parses
+;; its JSON, and regenerates `keep-file' with one org header per note. Editing
+;; the file does NOT sync back to Keep -- that is v2.
+;;
+;; The pure JSON-to-org core (the cj/keep--render* / --note-* helpers) is kept
+;; free of .emacs.d specifics so it can later extract to a standalone package;
+;; the IO layer and this module supply paths, auth, and keys.
+;;
+;; One-time setup: install the client (pip install gkeepapi), obtain a Google
+;; master token, set `cj/keep-email', and store the token in authinfo.gpg as
+;; machine google-keep login <you@gmail.com> password <master-token>
+;; See docs/specs/google-keep-emacs-integration-spec.org.
+
+;;; Code:
+
+(require 'json)
+(require 'subr-x)
+(require 'system-lib) ;; cj/auth-source-secret-value, cj/executable-find-or-warn
+(require 'user-constants) ;; keep-file
+
+;; ------------------------------ Configuration --------------------------------
+
+(defgroup cj/keep nil
+ "Google Keep to org integration."
+ :group 'applications
+ :prefix "cj/keep-")
+
+(defcustom cj/keep-email nil
+ "Google account email for the Keep bridge, also the authinfo login.
+Unset until the one-time setup is done; `cj/keep-refresh' warns when nil."
+ :type '(choice (const :tag "Unset" nil) string)
+ :group 'cj/keep)
+
+(defcustom cj/keep-auth-host "google-keep"
+ "The authinfo.gpg machine entry holding the Keep master token."
+ :type 'string
+ :group 'cj/keep)
+
+(defcustom cj/keep-python "python3"
+ "Python interpreter used to run the Keep bridge."
+ :type 'string
+ :group 'cj/keep)
+
+(defvar cj/keep--bridge-script
+ (expand-file-name "scripts/google-keep/keep-bridge.py" user-emacs-directory)
+ "Path to the gkeepapi bridge script.")
+
+(defconst cj/keep--web-base "https://keep.google.com/#NOTE/"
+ "Base URL for a Keep note back-link.")
+
+;; --------------------------- Pure core: JSON -> org --------------------------
+;; These take plain data and return strings -- no IO, no .emacs.d paths -- so
+;; they unit-test directly and lift out to a package unchanged.
+
+(defun cj/keep--parse-json (json-string)
+ "Parse the bridge JSON-STRING into a list of note alists."
+ (json-parse-string json-string
+ :object-type 'alist :array-type 'list
+ :false-object nil :null-object nil))
+
+(defun cj/keep--label-to-tag (label)
+ "Sanitize LABEL into a valid org tag (alphanumerics / _ / @ / # / %)."
+ (replace-regexp-in-string "[^[:alnum:]_@#%]" "_" label))
+
+(defun cj/keep--note-tags (note)
+ "Return the trailing org-tag string for NOTE (labels + archived), or \"\"."
+ (let ((tags (append (mapcar #'cj/keep--label-to-tag (alist-get 'labels note))
+ (and (alist-get 'archived note) '("archived")))))
+ (if tags (concat " :" (string-join tags ":") ":") "")))
+
+(defun cj/keep--note-heading (note)
+ "Render NOTE (an alist) as one org subtree string."
+ (let* ((id (alist-get 'id note))
+ (title (alist-get 'title note))
+ (text (alist-get 'text note))
+ (heading (if (and title (> (length title) 0)) title "(untitled)")))
+ (concat
+ "* " heading (cj/keep--note-tags note) "\n"
+ ":PROPERTIES:\n"
+ ":KEEP_ID: " (or id "") "\n"
+ ":PINNED: " (if (alist-get 'pinned note) "t" "nil") "\n"
+ ":COLOR: " (or (alist-get 'color note) "") "\n"
+ ":ARCHIVED: " (if (alist-get 'archived note) "t" "nil") "\n"
+ ":UPDATED: " (or (alist-get 'updated note) "") "\n"
+ ":END:\n"
+ (if (and id (> (length id) 0))
+ (concat "[[" cj/keep--web-base id "][open in Keep]]\n")
+ "")
+ "\n"
+ (if (and text (> (length text) 0)) (concat text "\n") ""))))
+
+(defun cj/keep--sort-pinned-first (notes)
+ "Return NOTES with pinned ones first, original order otherwise preserved."
+ (let (pinned rest)
+ (dolist (n notes)
+ (if (alist-get 'pinned n) (push n pinned) (push n rest)))
+ (append (nreverse pinned) (nreverse rest))))
+
+(defun cj/keep--render (notes &optional generated-at)
+ "Render NOTES (a list of alists) into the full org page string.
+GENERATED-AT is an optional last-refresh timestamp string for the header."
+ (concat
+ "# Generated by cj/keep-refresh -- read-only view; edits here do NOT sync to Keep.\n"
+ "#+TITLE: Google Keep\n"
+ (if generated-at (concat "# Last refresh: " generated-at "\n") "")
+ "\n"
+ (mapconcat #'cj/keep--note-heading (cj/keep--sort-pinned-first notes) "")))
+
+;; ------------------------------- IO: run + write -----------------------------
+
+(defun cj/keep--write-atomically (content file)
+ "Write CONTENT to FILE via a temp file in FILE's directory + atomic rename."
+ (let ((tmp (make-temp-file
+ (expand-file-name (concat "." (file-name-nondirectory file) ".")
+ (file-name-directory file))
+ nil nil content)))
+ (rename-file tmp file t)))
+
+(defun cj/keep--warn (token)
+ "Surface a Keep bridge failure TOKEN as a `display-warning'."
+ (display-warning
+ 'cj/keep
+ (pcase token
+ ("no-gkeepapi" "Keep bridge: gkeepapi is not installed (pip install gkeepapi).")
+ ("no-token" "Keep bridge: no master token in authinfo.gpg, or `cj/keep-email' is unset.")
+ ("auth-failed" "Keep bridge: Google rejected the credentials (token expired or revoked?).")
+ ("network" "Keep bridge: network error reaching Google Keep.")
+ (_ (format "Keep bridge failed: %s" (if (string-empty-p token) "unknown error" token))))
+ :error))
+
+(defun cj/keep--write-notes (json)
+ "Parse bridge JSON, render, and write `keep-file' atomically.
+Returns the note count."
+ (let* ((notes (cj/keep--parse-json json))
+ (org (cj/keep--render notes (format-time-string "%Y-%m-%d %H:%M"))))
+ (cj/keep--write-atomically org keep-file)
+ (length notes)))
+
+;;;###autoload
+(defun cj/keep-refresh ()
+ "Fetch Google Keep notes and regenerate `keep-file' (a read-only view)."
+ (interactive)
+ (let ((token (and cj/keep-email
+ (cj/auth-source-secret-value cj/keep-auth-host cj/keep-email))))
+ (cond
+ ((not (file-exists-p cj/keep--bridge-script))
+ (user-error "Keep bridge script not found: %s" cj/keep--bridge-script))
+ ((or (not cj/keep-email) (not token))
+ (cj/keep--warn "no-token"))
+ (t
+ (let* ((out (generate-new-buffer " *keep-bridge-out*"))
+ (err (generate-new-buffer " *keep-bridge-err*"))
+ (process-environment
+ (append (list (concat "KEEP_EMAIL=" cj/keep-email)
+ (concat "KEEP_MASTER_TOKEN=" token))
+ process-environment)))
+ (message "Keep: fetching...")
+ (make-process
+ :name "keep-bridge"
+ :buffer out
+ :stderr err
+ :command (list cj/keep-python cj/keep--bridge-script)
+ :sentinel
+ (lambda (proc _event)
+ (when (memq (process-status proc) '(exit signal))
+ (unwind-protect
+ (if (and (eq (process-status proc) 'exit)
+ (= (process-exit-status proc) 0))
+ (let ((n (cj/keep--write-notes
+ (with-current-buffer out (buffer-string)))))
+ (message "Keep: wrote %d notes to %s" n keep-file))
+ (cj/keep--warn
+ (string-trim (if (buffer-live-p err)
+ (with-current-buffer err (buffer-string))
+ ""))))
+ (when (buffer-live-p out) (kill-buffer out))
+ (when (buffer-live-p err) (kill-buffer err)))))))))))
+
+;;;###autoload
+(defun cj/keep-open ()
+ "Open the generated Keep org file, offering to refresh when it's absent."
+ (interactive)
+ (if (file-exists-p keep-file)
+ (find-file keep-file)
+ (if (y-or-n-p "Keep file doesn't exist yet. Refresh now? ")
+ (cj/keep-refresh)
+ (message "Run M-x cj/keep-refresh to generate it"))))
+
+;; --------------------------------- Glue / keys -------------------------------
+
+(defvar cj/keep-prefix-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map "r" #'cj/keep-refresh)
+ (define-key map "o" #'cj/keep-open)
+ map)
+ "Prefix keymap for Google Keep commands (bound to \\=`C-c k').")
+
+(keymap-global-set "C-c k" cj/keep-prefix-map)
+
+;; Warn at load if the interpreter is missing; gkeepapi/token failures surface
+;; at refresh time via the bridge's stderr reason token.
+(cj/executable-find-or-warn cj/keep-python "Google Keep bridge" 'google-keep-config)
+
+(provide 'google-keep-config)
+;;; google-keep-config.el ends here
diff --git a/modules/help-utils.el b/modules/help-utils.el
index f9f5d1427..3e31efffe 100644
--- a/modules/help-utils.el
+++ b/modules/help-utils.el
@@ -32,6 +32,10 @@
;;
;;; Code:
+;; Lazily-loaded functions referenced below.
+(declare-function devdocs-go-back "devdocs")
+(declare-function devdocs-go-forward "devdocs")
+
;; ---------------------------------- Devdocs ----------------------------------
(use-package devdocs
diff --git a/modules/httpd-config.el b/modules/httpd-config.el
index c90399425..60baf7e82 100644
--- a/modules/httpd-config.el
+++ b/modules/httpd-config.el
@@ -19,13 +19,13 @@
(use-package simple-httpd
:defer 1
:preface
- (defconst wwwdir (concat user-emacs-directory "www"))
- (defun check-or-create-wwwdir ()
- (unless (file-exists-p wwwdir)
- (make-directory wwwdir)))
- :init (check-or-create-wwwdir)
+ (defconst cj/httpd-wwwdir (concat user-emacs-directory "www"))
+ (defun cj/httpd-check-or-create-wwwdir ()
+ (unless (file-exists-p cj/httpd-wwwdir)
+ (make-directory cj/httpd-wwwdir)))
+ :init (cj/httpd-check-or-create-wwwdir)
:config
- (setq httpd-root wwwdir)
+ (setq httpd-root cj/httpd-wwwdir)
(setq httpd-show-backtrace-when-error t)
(setq httpd-serve-files t))
diff --git a/modules/jumper.el b/modules/jumper.el
index d5d0cf7a7..3dc00aa18 100644
--- a/modules/jumper.el
+++ b/modules/jumper.el
@@ -303,16 +303,12 @@ Returns: \\='no-locations if no locations stored,
(interactive)
(keymap-global-set jumper-prefix-key jumper-map))
-;; Call jumper-setup-keys when the package is loaded
-(jumper-setup-keys)
-
-;; which-key integration
-(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements
- "M-SPC" "jumper menu"
- "M-SPC SPC" "store location"
- "M-SPC j" "jump to location"
- "M-SPC d" "remove location"))
+;; Jumper's M-SPC prefix was removed 2026-06-23 so M-SPC could go to
+;; `cj/ai-term-next'. A cleverer home for jumper (numbers or F-keys) is
+;; pending review; until then its commands are reachable via M-x
+;; (jumper-store-location / jumper-jump-to-location / jumper-remove-location).
+;; To re-home: set `jumper-prefix-key' to the new prefix and call
+;; `jumper-setup-keys' (and restore the which-key labels for that prefix).
(provide 'jumper)
;;; jumper.el ends here.
diff --git a/modules/latex-config.el b/modules/latex-config.el
index 0db21f2f2..f2a586704 100644
--- a/modules/latex-config.el
+++ b/modules/latex-config.el
@@ -63,7 +63,10 @@ single entry."
:ensure auctex
:defer t
:hook
- (TeX-mode-hook . (lambda () (setq TeX-command-default "latexmk"))) ; use latexmk by default
+ ;; Name the mode, not the hook: use-package appends "-hook" to any symbol not
+ ;; ending in "-mode", so `TeX-mode' becomes `TeX-mode-hook' while the literal
+ ;; `TeX-mode-hook' would expand to the unbound `TeX-mode-hook-hook'.
+ (TeX-mode . (lambda () (setq TeX-command-default "latexmk"))) ; use latexmk by default
(LaTeX-mode . (lambda () (TeX-fold-mode 1))) ; automatically activate TeX-fold-mode.
(LaTeX-mode . flyspell-mode) ; turn on flyspell-mode by default
(LaTeX-mode . TeX-PDF-mode)
@@ -78,7 +81,9 @@ single entry."
(setq-default TeX-master t)) ; Assume the file is the master file itself
(use-package auctex-latexmk
- :defer t
+ ;; Load with AUCTeX, not deferred: `:defer t' has no autoload trigger here, so
+ ;; `auctex-latexmk-setup' never runs and "latexmk" never joins TeX-command-list.
+ :after tex
:config
(auctex-latexmk-setup)
(setq auctex-latexmk-inherit-TeX-PDF-mode t))
diff --git a/modules/ledger-config.el b/modules/ledger-config.el
index c268fa368..018601043 100644
--- a/modules/ledger-config.el
+++ b/modules/ledger-config.el
@@ -2,6 +2,24 @@
;; author Craig Jennings <c@cjennings.net>
;;; Commentary:
+;; Editing support for ledger-format plain-text accounting files: ledger-mode,
+;; flycheck linting, company completion, clean-on-save, and a small report set.
+;; The reports and reconcile shell out to the `ledger' CLI; a load-time check
+;; warns when it is missing rather than letting a report fail cryptically.
+
+;;; Code:
+
+;; ------------------------------- Declarations --------------------------------
+
+(declare-function ledger-mode-clean-buffer "ledger-mode")
+(declare-function cj/executable-find-or-warn "system-lib")
+(defvar ledger-mode-map)
+(defvar company-backends)
+
+(defcustom cj/ledger-clean-on-save t
+ "When non-nil, tidy a ledger buffer with `ledger-mode-clean-buffer' before save."
+ :type 'boolean
+ :group 'ledger)
;; -------------------------------- Ledger Mode --------------------------------
;; edit files in ledger format
@@ -11,35 +29,38 @@
"\\.ledger\\'"
"\\.journal\\'")
:preface
- (defun cj/ledger-save ()
- "Automatically clean the ledger buffer at each save."
- (interactive)
- (save-excursion
- (when (buffer-modified-p)
- (with-demoted-errors (ledger-mode-clean-buffer))
- (save-buffer))))
- :bind
- (:map ledger-mode-map
- ("C-x C-s" . cj/ledger-save))
+ (defun cj/ledger--clean-before-save ()
+ "Tidy the ledger buffer before save when `cj/ledger-clean-on-save' is set.
+Errors are demoted so a malformed buffer still saves."
+ (when cj/ledger-clean-on-save
+ (with-demoted-errors "Error cleaning ledger buffer: %S"
+ (ledger-mode-clean-buffer))))
+ (defun cj/ledger--enable-clean-on-save ()
+ "Install the clean-on-save hook buffer-locally so it fires on every save path."
+ (add-hook 'before-save-hook #'cj/ledger--clean-before-save nil t))
+ :hook (ledger-mode . cj/ledger--enable-clean-on-save)
:custom
(ledger-clear-whole-transactions t)
(ledger-reconcile-default-commodity "$")
(ledger-report-use-header-line nil)
+ (ledger-highlight-xact-under-point t)
(ledger-reports
'(("bal" "%(binary) --strict -f %(ledger-file) bal")
("bal this month" "%(binary) --strict -f %(ledger-file) bal -p %(month) -S amount")
("bal this year" "%(binary) --strict -f %(ledger-file) bal -p 'this year'")
("net worth" "%(binary) --strict -f %(ledger-file) bal Assets Liabilities")
- ("account" "%(binary) --strict -f %(ledger-file) reg %(account)"))))
+ ("account" "%(binary) --strict -f %(ledger-file) reg %(account)")))
+ :config
+ (cj/executable-find-or-warn "ledger" 'ledger-mode))
;; ------------------------------ Flycheck Ledger ------------------------------
-;; syntax and unbalanced transaction linting
+;; syntax and unbalanced-transaction linting
(use-package flycheck-ledger
:after ledger-mode)
;; ------------------------------- Company Ledger ------------------------------
-;; autocompletion for ledger
+;; account/payee autocompletion for ledger
(use-package company-ledger
:after (company ledger-mode)
diff --git a/modules/local-repository.el b/modules/local-repository.el
index b97b74f41..6376d9f73 100644
--- a/modules/local-repository.el
+++ b/modules/local-repository.el
@@ -25,23 +25,33 @@
;; ------------------------------- Customizations ------------------------------
+(defgroup localrepo nil
+ "Local last-known-good package repository."
+ :group 'package)
+
(defcustom localrepo-repository-id "localrepo"
"The name used to identify the local repository internally.
-Used for the package-archive and package-archive-priorities lists.")
+Used for the package-archive and package-archive-priorities lists."
+ :type 'string
+ :group 'localrepo)
(defcustom localrepo-repository-priority 100
"The value for the local repository in the package-archive-priority list.
A higher value means higher priority. If you want your local packages to be
-preferred, this must be a higher number than any other repositories.")
+preferred, this must be a higher number than any other repositories."
+ :type 'integer
+ :group 'localrepo)
(defcustom localrepo-repository-location
(concat user-emacs-directory "/.localrepo")
"The location of the local repository.
It's a good idea to keep this with the rest of your configuration files and
-keep them in source control.")
+keep them in source control."
+ :type 'directory
+ :group 'localrepo)
(defun cj/update-localrepo-repository ()
"Update the local repository with currently installed packages."
diff --git a/modules/mail-config.el b/modules/mail-config.el
index 08f50b12f..1d8a98c97 100644
--- a/modules/mail-config.el
+++ b/modules/mail-config.el
@@ -50,6 +50,31 @@
(declare-function mu4e-message-field "mu4e-message")
+;; ----------------------------- Declarations ----------------------------------
+;; mu4e/org-msg load lazily, so the byte-compiler can't see these package
+;; functions and variables when this module is compiled standalone. Declare
+;; them to silence free-variable / undefined-function warnings without forcing
+;; an eager require (which would defeat lazy loading). The cj/... entries are
+;; forward references: defined later in this file's `:config' block, or in
+;; mu4e-org-contacts-integration (required at load time inside that block).
+
+(declare-function mu4e-headers-mark-for-each-if "mu4e-mark")
+(declare-function mu4e-search "mu4e-search")
+(declare-function mu4e-view-refresh "mu4e-view")
+(declare-function message-add-header "message")
+(declare-function org-msg-edit-mode "org-msg")
+(declare-function no-auto-fill "mail-config")
+(declare-function cj/disable-company-in-mu4e-compose "mail-config")
+(declare-function cj/disable-ispell-in-email-headers "mail-config")
+(declare-function cj/activate-mu4e-org-contacts-integration
+ "mu4e-org-contacts-integration")
+
+;; Package variables assigned in the lazy `:config' blocks below.
+(defvar mu4e-compose-keep-self-cc)
+(defvar mu4e-root-maildir)
+(defvar mu4e-show-images)
+(defvar org-msg-extra-css)
+
;; 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
@@ -197,12 +222,16 @@ Prompts user for the action when executing."
;; (setq mu4e-compose-format-flowed t) ;; plain text mails must flow correctly for recipients
(setq mu4e-compose-keep-self-cc t) ;; keep me in the cc list
- (setq mu4e-compose-signature-auto-include nil) ;; don't include signature by default
+ (with-suppressed-warnings ((obsolete mu4e-compose-signature-auto-include)
+ (free-vars mu4e-compose-signature-auto-include))
+ (setq mu4e-compose-signature-auto-include nil)) ;; don't include signature by default
(setq mu4e-confirm-quit nil) ;; don't ask when quitting
(setq mu4e-context-policy 'pick-first) ;; start with the first (default) context
(setq mu4e-headers-auto-update nil) ;; updating headers buffer on email is too jarring
(setq mu4e-root-maildir mail-dir) ;; root directory for all email accounts
- (setq mu4e-maildir mail-dir) ;; same as above (for newer mu4e)
+ (with-suppressed-warnings ((obsolete mu4e-maildir)
+ (free-vars mu4e-maildir))
+ (setq mu4e-maildir mail-dir)) ;; same as above (for newer mu4e)
(setq mu4e-sent-messages-behavior 'delete) ;; don't save to "Sent", IMAP does this already
(setq mu4e-show-images t) ;; show embedded images
;; (setq mu4e-update-interval 600) ;; check for new mail every 10 minutes (600 seconds)
@@ -214,12 +243,16 @@ Prompts user for the action when executing."
;; This will be automatically disabled when org-msg is active
(setq mu4e-compose-format-flowed t)
- (setq mu4e-html2text-command 'mu4e-shr2text) ;; email conversion to html via shr2text
+ (with-suppressed-warnings ((obsolete mu4e-html2text-command)
+ (free-vars mu4e-html2text-command))
+ (setq mu4e-html2text-command 'mu4e-shr2text)) ;; email conversion to html via shr2text
(setq mu4e-mu-binary (executable-find "mu"))
(setq mu4e-get-mail-command (cj/mail--mbsync-command)) ;; command to sync mail
- (setq mu4e-user-mail-address-list '("c@cjennings.net"
- "craigmartinjennings@gmail.com"
- "craig.jennings@deepsat.com"))
+ (with-suppressed-warnings ((obsolete mu4e-user-mail-address-list)
+ (free-vars mu4e-user-mail-address-list))
+ (setq mu4e-user-mail-address-list '("c@cjennings.net"
+ "craigmartinjennings@gmail.com"
+ "craig.jennings@deepsat.com")))
(setq mu4e-index-update-error-warning nil) ;; don't warn me about spurious sync issues
;; ------------------------------ Mu4e Contexts ------------------------------
@@ -295,7 +328,7 @@ Prompts user for the action when executing."
:key ?d)))
(defun no-auto-fill ()
- "Turn off \'auto-fill-mode\'."
+ "Turn off `auto-fill-mode'."
(auto-fill-mode -1))
(add-hook 'mu4e-compose-mode-hook #'no-auto-fill)
@@ -317,19 +350,23 @@ Prompts user for the action when executing."
;; also see org-msg below
;; Prefer HTML over plain text when both are available
- (setq mu4e-view-prefer-html t)
+ (with-suppressed-warnings ((obsolete mu4e-view-prefer-html)
+ (free-vars mu4e-view-prefer-html))
+ (setq mu4e-view-prefer-html t))
;; Use a better HTML renderer with more control
- (setq mu4e-html2text-command
- (cond
- ;; Best option: pandoc (if available)
- ((executable-find "pandoc")
- "pandoc -f html -t plain --reference-links")
- ;; Good option: w3m (better tables/formatting)
- ((executable-find "w3m")
- "w3m -dump -T text/html -cols 72 -o display_link_number=true")
- ;; Fallback: built-in shr
- (t 'mu4e-shr2text)))
+ (with-suppressed-warnings ((obsolete mu4e-html2text-command)
+ (free-vars mu4e-html2text-command))
+ (setq mu4e-html2text-command
+ (cond
+ ;; Best option: pandoc (if available)
+ ((executable-find "pandoc")
+ "pandoc -f html -t plain --reference-links")
+ ;; Good option: w3m (better tables/formatting)
+ ((executable-find "w3m")
+ "w3m -dump -T text/html -cols 72 -o display_link_number=true")
+ ;; Fallback: built-in shr
+ (t 'mu4e-shr2text))))
;; Configure shr (built-in HTML renderer) for better display
(setq shr-use-colors nil) ; Don't use colors in terminal
@@ -339,8 +376,10 @@ Prompts user for the action when executing."
(setq shr-bullet "• ") ; Nice bullet points
;; Block remote images by default (privacy/security)
- (setq mu4e-view-show-images t)
- (setq mu4e-view-image-max-width 800)
+ (with-suppressed-warnings ((obsolete mu4e-view-show-images mu4e-view-image-max-width)
+ (free-vars mu4e-view-show-images mu4e-view-image-max-width))
+ (setq mu4e-view-show-images t)
+ (setq mu4e-view-image-max-width 800))
;; ------------------------------- View Actions ------------------------------
;; define view and article menus
diff --git a/modules/markdown-config.el b/modules/markdown-config.el
index 16935425d..424c09cc8 100644
--- a/modules/markdown-config.el
+++ b/modules/markdown-config.el
@@ -20,14 +20,13 @@
:mode (("README\\.md\\'" . gfm-mode)
("\\.md\\'" . markdown-mode)
("\\.markdown\\'" . markdown-mode))
- :bind (:map markdown-mode-map
- ("<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'
;; stops warning on `#+begin_src markdown ... #+end_src' and `C-c ''
;; inside such a block opens it in `markdown-mode' instead of falling
;; back to fundamental-mode.
+(defvar org-src-lang-modes)
(with-eval-after-load 'org
(add-to-list 'org-src-lang-modes '("markdown" . markdown)))
@@ -40,6 +39,8 @@
;;;; --------------------- WIP: Markdown-Preview ---------------------
+(declare-function imp--notify-clients "impatient-mode")
+
(defun cj/markdown-preview-server-start ()
"Start the simple-httpd listener that serves the live markdown preview.
Idempotent: re-running while the server is already up is a no-op."
@@ -75,5 +76,12 @@ lives in a separate command."
(buffer-substring-no-properties (point-min) (point-max))))
(current-buffer)))
+;; Bind the preview key after the defun so use-package's `:bind' autoload
+;; stub doesn't collide with this file's own definition of the command
+;; (that collision is the "defined multiple times" byte-compile warning).
+;; Same key as compile, for consistency.
+(with-eval-after-load 'markdown-mode
+ (keymap-set markdown-mode-map "<f2>" #'cj/markdown-preview))
+
(provide 'markdown-config)
;;; markdown-config.el ends here
diff --git a/modules/mousetrap-mode.el b/modules/mousetrap-mode.el
index 99475fcde..3817e0081 100644
--- a/modules/mousetrap-mode.el
+++ b/modules/mousetrap-mode.el
@@ -67,7 +67,8 @@ Categories can be combined in profiles to allow specific interaction patterns.")
"Mouse interaction profiles for different use cases.
Each profile specifies which event categories are allowed.
-Available categories: primary-click, secondary-click, drags, multi-clicks, scroll.
+Available categories: primary-click, secondary-click, drags,
+multi-clicks, scroll.
Profiles:
- disabled: Block all mouse events
@@ -88,7 +89,7 @@ Modes not listed here will use `mouse-trap-default-profile'.
When checking, the mode hierarchy is respected via `derived-mode-p'.")
(defvar mouse-trap-default-profile 'disabled
- "Default profile to use when current major mode is not in `mouse-trap-mode-profiles'.")
+ "Default profile when the major mode is not in `mouse-trap-mode-profiles'.")
;;; Keymap Builder
@@ -187,6 +188,11 @@ Used via `emulation-mode-map-alists' so each buffer gets its own keymap.")
;;; Minor Mode Definition
+;; Forward declaration: the minor-mode variable is defined by the
+;; `define-minor-mode' form below, but referenced earlier in the lighter
+;; keymap and lighter-string helpers.
+(defvar mouse-trap-mode)
+
(defvar mouse-trap--lighter-keymap
(let ((map (make-sparse-keymap)))
(define-key map [mode-line mouse-1]
diff --git a/modules/mu4e-org-contacts-integration.el b/modules/mu4e-org-contacts-integration.el
index 6aed3d4cf..daa12701a 100644
--- a/modules/mu4e-org-contacts-integration.el
+++ b/modules/mu4e-org-contacts-integration.el
@@ -32,7 +32,6 @@ This function is designed to work with mu4e's compose buffers."
(re-search-backward "\\(\\`\\|[\n:,]\\)[ \t]*" nil t)
(goto-char (match-end 0))
(point)))
- (initial (buffer-substring-no-properties start end))
(contacts (cj/get-all-contact-emails)))
(when contacts
(list start end
diff --git a/modules/mu4e-org-contacts-setup.el b/modules/mu4e-org-contacts-setup.el
index 034e74574..64e9a611f 100644
--- a/modules/mu4e-org-contacts-setup.el
+++ b/modules/mu4e-org-contacts-setup.el
@@ -7,6 +7,10 @@
;;; Code:
+(defvar mu4e-compose-complete-only-personal)
+(defvar mu4e-compose-complete-only-after)
+(declare-function cj/activate-mu4e-org-contacts-integration "mu4e-org-contacts-integration")
+
;; Load the integration module. Activation only runs when the module loaded
;; cleanly AND mu4e is present; otherwise this file is a no-op so the rest
;; of the config can load without mu4e installed.
diff --git a/modules/music-config.el b/modules/music-config.el
index 55eb47d25..76fff283b 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -98,6 +98,43 @@
(require 'cj-window-toggle-lib) ;; side-window size memory (F10 toggle)
(require 'system-lib) ;; cj/confirm-strong (overwrite confirms)
+;; Declare these foreign package vars special so `let'-binding them below
+;; compiles as a dynamic bind, not a dead lexical local -- otherwise emms /
+;; orderless never see the binding (the lexical-binding foreign-special-var trap).
+(defvar orderless-smart-case)
+(defvar emms-source-playlist-ask-before-overwrite)
+(defvar emms-playlist-buffer-p)
+(defvar emms-playlist-buffer)
+(defvar emms-random-playlist)
+(defvar emms-playlist-selected-marker)
+(defvar emms-source-file-default-directory)
+(defvar emms-player-mpv-parameters)
+(defvar emms-player-mpv-regexp)
+(defvar emms-player-playing-p)
+(defvar emms-player-paused-p)
+(defvar emms-playlist-mode-map)
+(defvar dirvish-mode-map)
+
+;; Foreign functions used lazily after their packages load.
+(declare-function emms-playlist-mode "emms-playlist-mode")
+(declare-function emms-playlist-track-at "emms-playlist-mode")
+(declare-function emms-playlist-mode-kill-track "emms-playlist-mode")
+(declare-function emms-track-name "emms")
+(declare-function emms-track-type "emms")
+(declare-function emms-track-get "emms")
+(declare-function emms-track-simple-description "emms")
+(declare-function emms-playlist-current-selected-track "emms")
+(declare-function emms-playlist-select "emms")
+(declare-function emms-playlist-clear "emms")
+(declare-function emms-playlist-save "emms-source-playlist")
+(declare-function emms-start "emms")
+(declare-function emms-random "emms")
+(declare-function emms-next "emms")
+(declare-function emms-previous "emms")
+(declare-function dired-get-marked-files "dired")
+(declare-function dired-get-file-for-visit "dired")
+(declare-function face-remap-remove-relative "face-remap")
+
;;; Settings (no Customize)
(defvar cj/music-root music-dir
@@ -613,26 +650,26 @@ Initializes EMMS if needed."
;;; Dired/Dirvish integration
-(with-eval-after-load 'dirvish
- (defun cj/music-add-dired-selection ()
- "Add selected files/dirs in Dired/Dirvish to the EMMS playlist.
+(defun cj/music-add-dired-selection ()
+ "Add selected files/dirs in Dired/Dirvish to the EMMS playlist.
Dirs added recursively."
- (interactive)
- (unless (derived-mode-p 'dired-mode)
- (user-error "This command must be run in a Dired buffer"))
- (cj/music--ensure-playlist-buffer)
- (let ((files (if (use-region-p)
- (dired-get-marked-files)
- (list (dired-get-file-for-visit)))))
- (when (null files)
- (user-error "No files selected"))
- (dolist (file files)
- (cond
- ((file-directory-p file) (cj/music-add-directory-recursive file))
- ((cj/music--valid-file-p file) (emms-add-file file))
- (t (message "Skipping non-music file: %s" file))))
- (message "Added %d item(s) to playlist" (length files))))
+ (interactive)
+ (unless (derived-mode-p 'dired-mode)
+ (user-error "This command must be run in a Dired buffer"))
+ (cj/music--ensure-playlist-buffer)
+ (let ((files (if (use-region-p)
+ (dired-get-marked-files)
+ (list (dired-get-file-for-visit)))))
+ (when (null files)
+ (user-error "No files selected"))
+ (dolist (file files)
+ (cond
+ ((file-directory-p file) (cj/music-add-directory-recursive file))
+ ((cj/music--valid-file-p file) (emms-add-file file))
+ (t (message "Skipping non-music file: %s" file))))
+ (message "Added %d item(s) to playlist" (length files))))
+(with-eval-after-load 'dirvish
(keymap-set dirvish-mode-map "+" #'cj/music-add-dired-selection))
;;; EMMS setup and keybindings
@@ -674,6 +711,130 @@ Dirs added recursively."
"C-; m z" "random"
"C-; m x" "consume"))
+;;; Playlist display helpers
+;;
+;; Defined at top level (not inside the `emms' use-package `:config') so the
+;; byte-compiler sees them; they touch EMMS only at call time, after load.
+
+(defun cj/music--after-playlist-clear (&rest _)
+ "Forget the associated M3U file after the playlist is cleared."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (setq cj/music-playlist-file nil))))
+
+(defun cj/music--format-duration (seconds)
+ "Convert SECONDS to a \"M:SS\" string."
+ (when (and seconds (numberp seconds) (> seconds 0))
+ (format "%d:%02d" (/ seconds 60) (mod seconds 60))))
+
+(defun cj/music--track-description (track)
+ "Return a human-readable description of TRACK.
+For tagged tracks: \"Artist - Title [M:SS]\".
+For file tracks without tags: filename without path or extension.
+For URL tracks: decoded URL."
+ (let ((type (emms-track-type track))
+ (title (emms-track-get track 'info-title))
+ (artist (emms-track-get track 'info-artist))
+ (duration (emms-track-get track 'info-playing-time))
+ (name (emms-track-name track)))
+ (cond
+ ;; Tagged track with title
+ (title
+ (let ((dur-str (cj/music--format-duration duration))
+ (parts '()))
+ (when artist (push artist parts))
+ (push title parts)
+ (let ((desc (string-join (nreverse parts) " - ")))
+ (if dur-str (format "%s [%s]" desc dur-str) desc))))
+ ;; File without tags — show clean filename
+ ((eq type 'file)
+ (file-name-sans-extension (file-name-nondirectory name)))
+ ;; URL — decode percent-encoded characters
+ ((eq type 'url)
+ (decode-coding-string (url-unhex-string name) 'utf-8))
+ ;; Fallback
+ (t (emms-track-simple-description track)))))
+
+;; Multi-line header overlay
+(defvar-local cj/music--header-overlay nil
+ "Overlay displaying the playlist header.")
+
+(defun cj/music--header-text ()
+ "Build a multi-line header string for the playlist buffer overlay."
+ (let* ((pl-name (if cj/music-playlist-file
+ (file-name-sans-extension
+ (file-name-nondirectory cj/music-playlist-file))
+ "Untitled"))
+ (track-count (count-lines (point-min) (point-max)))
+ (now-playing (cond
+ ((not emms-player-playing-p) "Stopped")
+ (emms-player-paused-p "Paused")
+ (t (let ((track (emms-playlist-current-selected-track)))
+ (if track
+ (cj/music--track-description track)
+ "Playing")))))
+ (mode-indicator
+ (lambda (key label active)
+ (let ((face (if active 'cj/music-mode-on-face 'cj/music-mode-off-face)))
+ (propertize (format "[%s] %s" key label) 'face face)))))
+ (concat
+ (propertize "Playlist" 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize (format "%s (%d)" pl-name track-count) 'face 'cj/music-header-value-face)
+ "\n"
+ (propertize "Current " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize now-playing 'face 'cj/music-header-value-face)
+ "\n"
+ (propertize "Mode " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (funcall mode-indicator "r" "repeat" (bound-and-true-p emms-repeat-playlist))
+ " "
+ (funcall mode-indicator "t" "single" (bound-and-true-p emms-repeat-track))
+ " "
+ (funcall mode-indicator "z" "random" (bound-and-true-p emms-random-playlist))
+ " "
+ (funcall mode-indicator "x" "consume" cj/music-consume-mode)
+ "\n"
+ (propertize "Keys " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize "a:add c:clear L:load S:save SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
+ 'face 'cj/music-keyhint-face)
+ "\n\n")))
+
+(defun cj/music--update-header ()
+ "Insert or update the multi-line header overlay in the playlist buffer."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (unless cj/music--header-overlay
+ (setq cj/music--header-overlay (make-overlay (point-min) (point-min)))
+ (overlay-put cj/music--header-overlay 'priority 100))
+ (move-overlay cj/music--header-overlay (point-min) (point-min))
+ (overlay-put cj/music--header-overlay 'before-string
+ (cj/music--header-text)))))
+
+(defvar-local cj/music--bg-remap-cookie nil
+ "Cookie for the active-window background face remapping.")
+
+(defun cj/music--update-active-bg (&rest _)
+ "Toggle playlist buffer background based on whether its window is selected."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (let ((active (eq buf (window-buffer (selected-window)))))
+ (cond
+ ((and active (not cj/music--bg-remap-cookie))
+ (setq cj/music--bg-remap-cookie
+ (face-remap-add-relative 'default)))
+ ((and (not active) cj/music--bg-remap-cookie)
+ (face-remap-remove-relative cj/music--bg-remap-cookie)
+ (setq cj/music--bg-remap-cookie nil)))))))
+
+(defun cj/music--setup-playlist-display ()
+ "Set up header overlay and focus tracking in the playlist buffer."
+ (setq header-line-format nil)
+ (cj/music--update-header)
+ (add-hook 'window-selection-change-functions #'cj/music--update-active-bg nil t))
+
(use-package emms
:defer t
:init
@@ -698,7 +859,7 @@ Dirs added recursively."
(emms-all)
;; Disable modeline display (keep modeline clean)
- (emms-playing-time-disable-display)
+ (emms-playing-time-display-mode -1)
(emms-mode-line-mode -1)
;; MPV configuration
@@ -712,134 +873,16 @@ Dirs added recursively."
(regexp-opt cj/music-file-extensions)
"\\'\\)"))
- ;; Keep cj/music-playlist-file in sync if playlist is cleared
- (defun cj/music--after-playlist-clear (&rest _)
- (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
- (with-current-buffer buf
- (setq cj/music-playlist-file nil))))
-
- ;; Ensure we don't stack duplicate advice on reload
+ ;; Keep cj/music-playlist-file in sync if playlist is cleared.
+ ;; Ensure we don't stack duplicate advice on reload.
(advice-remove 'emms-playlist-clear #'cj/music--after-playlist-clear)
(advice-add 'emms-playlist-clear :after #'cj/music--after-playlist-clear)
;;; Playlist display
;; Track description: show "Artist - Title [M:SS]" instead of file paths
- (defun cj/music--format-duration (seconds)
- "Convert SECONDS to a \"M:SS\" string."
- (when (and seconds (numberp seconds) (> seconds 0))
- (format "%d:%02d" (/ seconds 60) (mod seconds 60))))
-
- (defun cj/music--track-description (track)
- "Return a human-readable description of TRACK.
-For tagged tracks: \"Artist - Title [M:SS]\".
-For file tracks without tags: filename without path or extension.
-For URL tracks: decoded URL."
- (let ((type (emms-track-type track))
- (title (emms-track-get track 'info-title))
- (artist (emms-track-get track 'info-artist))
- (duration (emms-track-get track 'info-playing-time))
- (name (emms-track-name track)))
- (cond
- ;; Tagged track with title
- (title
- (let ((dur-str (cj/music--format-duration duration))
- (parts '()))
- (when artist (push artist parts))
- (push title parts)
- (let ((desc (string-join (nreverse parts) " - ")))
- (if dur-str (format "%s [%s]" desc dur-str) desc))))
- ;; File without tags — show clean filename
- ((eq type 'file)
- (file-name-sans-extension (file-name-nondirectory name)))
- ;; URL — decode percent-encoded characters
- ((eq type 'url)
- (decode-coding-string (url-unhex-string name) 'utf-8))
- ;; Fallback
- (t (emms-track-simple-description track)))))
-
(setq emms-track-description-function #'cj/music--track-description)
- ;; Multi-line header overlay
- (defvar-local cj/music--header-overlay nil
- "Overlay displaying the playlist header.")
-
- (defun cj/music--header-text ()
- "Build a multi-line header string for the playlist buffer overlay."
- (let* ((pl-name (if cj/music-playlist-file
- (file-name-sans-extension
- (file-name-nondirectory cj/music-playlist-file))
- "Untitled"))
- (track-count (count-lines (point-min) (point-max)))
- (now-playing (cond
- ((not emms-player-playing-p) "Stopped")
- (emms-player-paused-p "Paused")
- (t (let ((track (emms-playlist-current-selected-track)))
- (if track
- (cj/music--track-description track)
- "Playing")))))
- (mode-indicator
- (lambda (key label active)
- (let ((face (if active 'cj/music-mode-on-face 'cj/music-mode-off-face)))
- (propertize (format "[%s] %s" key label) 'face face)))))
- (concat
- (propertize "Playlist" 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (propertize (format "%s (%d)" pl-name track-count) 'face 'cj/music-header-value-face)
- "\n"
- (propertize "Current " 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (propertize now-playing 'face 'cj/music-header-value-face)
- "\n"
- (propertize "Mode " 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (funcall mode-indicator "r" "repeat" (bound-and-true-p emms-repeat-playlist))
- " "
- (funcall mode-indicator "t" "single" (bound-and-true-p emms-repeat-track))
- " "
- (funcall mode-indicator "z" "random" (bound-and-true-p emms-random-playlist))
- " "
- (funcall mode-indicator "x" "consume" cj/music-consume-mode)
- "\n"
- (propertize "Keys " 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (propertize "a:add c:clear L:load S:save SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
- 'face 'cj/music-keyhint-face)
- "\n\n")))
-
- (defun cj/music--update-header ()
- "Insert or update the multi-line header overlay in the playlist buffer."
- (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
- (with-current-buffer buf
- (unless cj/music--header-overlay
- (setq cj/music--header-overlay (make-overlay (point-min) (point-min)))
- (overlay-put cj/music--header-overlay 'priority 100))
- (move-overlay cj/music--header-overlay (point-min) (point-min))
- (overlay-put cj/music--header-overlay 'before-string
- (cj/music--header-text)))))
-
- (defvar-local cj/music--bg-remap-cookie nil
- "Cookie for the active-window background face remapping.")
-
- (defun cj/music--update-active-bg (&rest _)
- "Toggle playlist buffer background based on whether its window is selected."
- (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
- (with-current-buffer buf
- (let ((active (eq buf (window-buffer (selected-window)))))
- (cond
- ((and active (not cj/music--bg-remap-cookie))
- (setq cj/music--bg-remap-cookie
- (face-remap-add-relative 'default :background "#1d1b19")))
- ((and (not active) cj/music--bg-remap-cookie)
- (face-remap-remove-relative cj/music--bg-remap-cookie)
- (setq cj/music--bg-remap-cookie nil)))))))
-
- (defun cj/music--setup-playlist-display ()
- "Set up header overlay and focus tracking in the playlist buffer."
- (setq header-line-format nil)
- (cj/music--update-header)
- (add-hook 'window-selection-change-functions #'cj/music--update-active-bg nil t))
-
(add-hook 'emms-playlist-mode-hook #'cj/music--setup-playlist-display)
(add-hook 'emms-player-started-hook #'cj/music--record-random-history)
(add-hook 'emms-player-started-hook #'cj/music--update-header)
@@ -891,8 +934,6 @@ For URL tracks: decoded URL."
("S-<down>" . emms-playlist-mode-shift-track-down)
("C-<up>" . emms-playlist-mode-shift-track-up)
("C-<down>" . emms-playlist-mode-shift-track-down)
- ;; Radio
- ("R" . cj/music-create-radio-station)
;; Volume
("+" . emms-volume-raise)
("=" . emms-volume-raise)
@@ -921,5 +962,10 @@ For URL tracks: decoded URL."
(insert content))
(message "Created radio station: %s" (file-name-nondirectory file))))
+;; Bound here rather than in the emms `:bind' so use-package does not emit a
+;; redundant autoload that collides with this same-file definition.
+(with-eval-after-load 'emms
+ (keymap-set emms-playlist-mode-map "R" #'cj/music-create-radio-station))
+
(provide 'music-config)
;;; music-config.el ends here
diff --git a/modules/nerd-icons-config.el b/modules/nerd-icons-config.el
index d3d55b864..e2edb0717 100644
--- a/modules/nerd-icons-config.el
+++ b/modules/nerd-icons-config.el
@@ -1,4 +1,4 @@
-;;; nerd-icons-config.el --- Nerd-icons setup, integrations, and tinting -*- lexical-binding: t; -*-
+;;; nerd-icons-config.el --- Nerd-icons setup and integrations -*- lexical-binding: t; -*-
;; author: Craig Jennings <c@cjennings.net>
;;; Commentary:
@@ -16,51 +16,21 @@
;; - the package itself
;; - completion integration (`nerd-icons-completion')
;; - ibuffer integration (`nerd-icons-ibuffer')
-;; - bulk color tinting of every `nerd-icons-*' color face
;; - dir-icon color advice (so directory glyphs carry a color face like
;; file glyphs do, instead of falling through to the buffer default
;; face)
;;
+;; Icon colors are theme-driven: nerd-icons' 34 `nerd-icons-*' color faces are
+;; owned by the theme (themeable in theme-studio), not overwritten at load time.
+;;
;; Per-feature USE of nerd-icons stays in the feature module that consumes
;; it: `dashboard-icon-type', `dirvish-attributes', and the keyboard-compat
;; terminal-frame icon-blanking advice are not centralized here.
;;; Code:
-;; ----------------------------- Customization ---------------------------------
-
-(defcustom cj/nerd-icons-tint-color "darkgoldenrod"
- "Single foreground color applied to every `nerd-icons-*' color face.
-Set via Customize or by `setq' before this module loads, then call
-`cj/nerd-icons-apply-tint' to re-apply on demand."
- :type 'string
- :group 'cj)
-
-(defconst cj/--nerd-icons-color-faces
- '(nerd-icons-red nerd-icons-lred nerd-icons-dred nerd-icons-red-alt
- nerd-icons-green nerd-icons-lgreen nerd-icons-dgreen
- nerd-icons-yellow nerd-icons-lyellow nerd-icons-dyellow
- nerd-icons-orange nerd-icons-lorange nerd-icons-dorange
- nerd-icons-blue nerd-icons-blue-alt nerd-icons-lblue nerd-icons-dblue
- nerd-icons-cyan nerd-icons-cyan-alt nerd-icons-lcyan nerd-icons-dcyan
- nerd-icons-purple nerd-icons-purple-alt nerd-icons-lpurple nerd-icons-dpurple
- nerd-icons-pink nerd-icons-lpink nerd-icons-dpink
- nerd-icons-maroon nerd-icons-lmaroon nerd-icons-dmaroon
- nerd-icons-silver nerd-icons-lsilver nerd-icons-dsilver)
- "Every color face nerd-icons attaches to glyphs via `:inherit'.")
-
;; ------------------------------- Helpers -------------------------------------
-(defun cj/nerd-icons-apply-tint (&optional color)
- "Set every face in `cj/--nerd-icons-color-faces' to foreground COLOR.
-COLOR defaults to `cj/nerd-icons-tint-color'. Faces that are not yet
-defined (nerd-icons not loaded) are silently skipped."
- (interactive)
- (let ((c (or color cj/nerd-icons-tint-color)))
- (dolist (f cj/--nerd-icons-color-faces)
- (when (facep f)
- (set-face-foreground f c)))))
-
(defun cj/--nerd-icons-color-dir (icon)
"Layer `nerd-icons-yellow' onto ICON's face stack and return ICON.
ICON is the propertized string returned by `nerd-icons-icon-for-dir'.
@@ -87,17 +57,15 @@ every call. The `memq' check skips when the face is already present."
(use-package nerd-icons
:demand t
:config
- (advice-add 'nerd-icons-icon-for-dir :filter-return #'cj/--nerd-icons-color-dir)
- (cj/nerd-icons-apply-tint))
+ (advice-add 'nerd-icons-icon-for-dir :filter-return #'cj/--nerd-icons-color-dir))
;; Safety net: if this module is re-evaluated in a running Emacs where
;; nerd-icons is already loaded, `:config' above won't fire again --
-;; ensure the advice and tint still apply.
+;; ensure the dir advice still applies.
(with-eval-after-load 'nerd-icons
(unless (advice-member-p #'cj/--nerd-icons-color-dir 'nerd-icons-icon-for-dir)
(advice-add 'nerd-icons-icon-for-dir
- :filter-return #'cj/--nerd-icons-color-dir))
- (cj/nerd-icons-apply-tint))
+ :filter-return #'cj/--nerd-icons-color-dir)))
(use-package nerd-icons-completion
:demand t
diff --git a/modules/org-agenda-config-debug.el b/modules/org-agenda-config-debug.el
index a9c713a13..4c1b1dd84 100644
--- a/modules/org-agenda-config-debug.el
+++ b/modules/org-agenda-config-debug.el
@@ -18,6 +18,9 @@
(require 'user-constants)
(require 'system-lib)
+(defvar org-agenda-files)
+(declare-function cj/build-org-agenda-list "org-agenda-config")
+
;; ---------------------------- Debug Functions --------------------------------
;;;###autoload
diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el
index d5d610f27..3234cc929 100644
--- a/modules/org-agenda-config.el
+++ b/modules/org-agenda-config.el
@@ -47,9 +47,10 @@
;; M-f8 - TASK LIST containing all tasks from just the current org-mode buffer.
;;
;; NOTE:
-;; Files that contain information relevant to the agenda will be found in the
-;; following places: the schedule-file, org-roam notes tagged as 'Projects' and
-;; project todo.org files found in project-dir and code-dir.
+;; Files that contain information relevant to the agenda are: the inbox, the
+;; schedule-file, the synced calendars, and the per-project todo.org files found
+;; in immediate subdirectories of projects-dir. (org-roam notes are refile
+;; targets, not agenda sources -- see org-refile-config.el.)
;;; Code:
(require 'user-constants)
@@ -58,7 +59,8 @@
(defcustom cj/org-agenda-window-height 0.75
"Fraction of the selected frame used for the org agenda window."
- :type 'number)
+ :type 'number
+ :group 'org-agenda)
(defun cj/--org-agenda-display-rule ()
"Return the display-buffer rule for the org agenda buffer."
@@ -89,6 +91,12 @@
(setq org-agenda-skip-scheduled-if-done nil)
(setq org-agenda-remove-tags t)
(setq org-agenda-compact-blocks t)
+ ;; Backstop against a non-existent agenda file (e.g. a calendar not yet synced
+ ;; on a fresh machine): skip it silently instead of prompting to create it --
+ ;; the interactive-prompt class that once hung the chime daemon.
+ ;; `cj/--org-agenda-base-files' already filters the list; this catches any path
+ ;; that reaches `org-agenda-files' another way.
+ (setq org-agenda-skip-unavailable-files t)
;; display the agenda from the bottom
(add-to-list 'display-buffer-alist
@@ -177,14 +185,18 @@ Only checks DIRECTORY/*/todo.org — does not recurse deeper."
;; ---------------------------- Rebuild Org Agenda ---------------------------
;; builds the org agenda list from all agenda targets with caching.
-;; agenda targets is the schedule, contacts, project todos,
-;; inbox, and org roam projects.
+;; agenda targets are the inbox, the schedule, the synced calendars,
+;; and the per-project todo.org files under projects-dir.
(defun cj/--org-agenda-base-files ()
- "Return the fixed base files for the agenda: inbox, schedule, and calendars.
+ "Return the existing base files for the agenda: inbox, schedule, and calendars.
The single source of the base list shared by the agenda builders and the chime
initializer, so adding a calendar source is a one-place change. Per-project
-todo.org files are layered on separately."
- (list inbox-file schedule-file gcal-file pcal-file dcal-file))
+todo.org files are layered on separately. Files that do not exist are dropped
+\(a fresh machine may lack the synced calendars or the inbox) so org-agenda
+never prompts to create them -- the interactive-prompt class that once hung the
+chime daemon; `org-agenda-skip-unavailable-files' is the backstop."
+ (seq-filter #'file-exists-p
+ (list inbox-file schedule-file gcal-file pcal-file dcal-file)))
(defun cj/--org-agenda-scan-files ()
"Scan disk for the agenda files list. Pure-ish: no caching, no logging.
@@ -243,8 +255,8 @@ Bypasses cache and scans directories from scratch."
(defun cj/todo-list-all-agenda-files ()
"Displays an \\='org-agenda\\=' todo list.
-The contents of the agenda will be built from org-project-files and org-roam
-files that have project in their filetag."
+The contents of the agenda are built from the base files (inbox, schedule, and
+the synced calendars) plus the per-project todo.org files under projects-dir."
(interactive)
(cj/build-org-agenda-list)
(org-agenda "a" "t"))
@@ -385,8 +397,7 @@ This uses all org-agenda targets and presents three sections:
- Today's schedule, including habits with consistency graphs
- All priority B and C unscheduled/undeadlined tasks
The agenda is rebuilt from all sources before display, including:
-- inbox-file and schedule-file
-- Org-roam nodes tagged as \"Project\"
+- inbox-file, schedule-file, and the synced calendars
- All todo.org files in immediate subdirectories of projects-dir"
(interactive)
(cj/build-org-agenda-list)
diff --git a/modules/org-babel-config.el b/modules/org-babel-config.el
index 821403a0d..bc7ccb806 100644
--- a/modules/org-babel-config.el
+++ b/modules/org-babel-config.el
@@ -29,6 +29,12 @@
(setq org-src-fontify-natively t) ;; fontify the code in blocks
(setq org-src-tab-acts-natively t) ;; tabs act like in language major mode buffer
(setq org-src-window-setup 'current-window) ;; don't split window when source editing wih C-c '
+ ;; Treat cj comment blocks (#+begin_src cj: comment ...) as org for editing
+ ;; and fontification: the "cj:" language token maps to org-mode, so C-c '
+ ;; opens an org buffer and the block's prose gets org font-lock in place.
+ ;; The block stays a src block (the cj: grep marker is unchanged); org markup
+ ;; is highlighted and editable, though links are followed from the C-c ' buffer.
+ (add-to-list 'org-src-lang-modes '("cj:" . org))
(setq org-confirm-babel-evaluate t) ;; confirm before running babel; toggle with C-; k
(setq org-babel-default-header-args
(cons '(:tangle . "yes")
diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el
index 2f245185f..9f5bfbe7f 100644
--- a/modules/org-capture-config.el
+++ b/modules/org-capture-config.el
@@ -30,6 +30,7 @@
(defvar org-complex-heading-regexp-format)
(declare-function cj/--drill-pick-file "org-drill-config")
+(declare-function cj/org-capture--date-prefix "org-capture-config")
(declare-function org-at-encrypted-entry-p "org-crypt")
(declare-function org-at-heading-p "org")
(declare-function org-back-to-heading "org")
@@ -170,7 +171,7 @@ letter upcased: \"~/.emacs.d/\" -> \"Emacs.d\", \"~/code/duet/\" -> \"Duet\"."
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 without a todo.org -> F is INBOX, :open-work nil, :warn names 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))
diff --git a/modules/org-config.el b/modules/org-config.el
index 8d722ad46..f316ee0df 100644
--- a/modules/org-config.el
+++ b/modules/org-config.el
@@ -17,6 +17,72 @@
(require 'keybindings) ;; provides cj/custom-keymap (used in :init below)
+;; Declare org variables and functions used before org is loaded so this module
+;; byte-compiles standalone. Plain `defvar' (no value) marks the symbol special
+;; without assigning anything, so org's own defaults still apply at runtime.
+(defvar org-dir)
+(defvar org-mode-map)
+(defvar org-mouse-map)
+(defvar org-modules)
+(defvar org-startup-folded)
+(defvar org-cycle-open-archived-trees)
+(defvar org-cycle-hide-drawers)
+(defvar org-id-locations-file)
+(defvar org-return-follows-link)
+(defvar org-list-allow-alphabetical)
+(defvar org-startup-indented)
+(defvar org-adapt-indentation)
+(defvar org-startup-with-inline-images)
+(defvar org-image-actual-width)
+(defvar org-yank-image-save-method)
+(defvar org-bookmark-names-plist)
+(defvar org-file-apps)
+(defvar org-ellipsis)
+(defvar org-hide-emphasis-markers)
+(defvar org-hide-leading-stars)
+(defvar org-pretty-entities)
+(defvar org-pretty-entities-include-sub-superscripts)
+(defvar org-fontify-emphasized-text)
+(defvar org-fontify-whole-heading-line)
+(defvar org-tags-column)
+(defvar org-agenda-tags-column)
+(defvar org-todo-keywords)
+(defvar org-highest-priority)
+(defvar org-lowest-priority)
+(defvar org-default-priority)
+(defvar org-enforce-todo-dependencies)
+(defvar org-enforce-todo-checkbox-dependencies)
+(defvar org-deadline-warning-days)
+(defvar org-treat-insert-todo-heading-as-state-change)
+(defvar org-log-into-drawer)
+(defvar org-log-done)
+(defvar org-use-property-inheritance)
+
+(declare-function org-current-level "org")
+(declare-function org-add-planning-info "org")
+(declare-function org-get-heading "org")
+(declare-function org-edit-headline "org")
+(declare-function org-priority "org")
+(declare-function org-heading-components "org")
+(declare-function org-todo "org")
+(declare-function org-get-todo-state "org")
+(declare-function org-back-to-heading "org")
+(declare-function org-sort-entries "org")
+(declare-function org-eval-in-calendar "org")
+(declare-function org-open-at-point "org")
+(declare-function org-backward-heading-same-level "org")
+(declare-function org-forward-heading-same-level "org")
+(declare-function org-reveal "org")
+(declare-function org-show-todo-tree "org")
+(declare-function org-fold-show-all "org-fold")
+(declare-function outline-next-heading "outline")
+(declare-function org-element-cache-reset "org-element")
+(declare-function org-element-context "org-element")
+(declare-function org-element-type "org-element-ast")
+(declare-function org-superstar-configure-like-org-bullets "org-superstar")
+(declare-function cj/--org-follow-link-same-window "org-config")
+(declare-function cj/org-follow-link-at-mouse-same-window "org-config")
+
;; ---------------------------- Org General Settings ---------------------------
(defun cj/org-general-settings ()
@@ -250,14 +316,14 @@ whole row line."
(keymap-set cj/org-map "<" #'cj/org-narrow-backwards)
;; Sparse trees: lowercase creates, capital of the same letter cancels.
- ;; Both `S' and `T' resolve to `org-show-all' -- same cancel command,
+ ;; Both `S' and `T' resolve to `org-fold-show-all' -- same cancel command,
;; paired with each lowercase create so the mental model is "capital
;; cancels the lowercase command I just ran" without having to recall
;; which letter the cancel actually lives on.
(keymap-set cj/org-map "s" #'org-match-sparse-tree)
- (keymap-set cj/org-map "S" #'org-show-all)
+ (keymap-set cj/org-map "S" #'org-fold-show-all)
(keymap-set cj/org-map "t" #'org-show-todo-tree)
- (keymap-set cj/org-map "T" #'org-show-all)
+ (keymap-set cj/org-map "T" #'org-fold-show-all)
(keymap-set cj/org-map "R" #'org-reveal)
:bind
("C-c c" . org-capture)
@@ -273,8 +339,7 @@ whole row line."
("C-c N" . org-narrow-to-subtree)
("C-c >" . cj/org-narrow-forward)
("C-c <" . cj/org-narrow-backwards)
- ("C-c <ESC>" . widen)
- ("C-c C-a" . cj/org-appear-toggle))
+ ("C-c <ESC>" . widen))
(:map cj/org-map
("r i" . org-table-insert-row)
("r d" . org-table-kill-row)
@@ -401,6 +466,11 @@ especially in tables with long URLs)."
(org-appear-mode 1)
(message "org-appear enabled (links/emphasis show when editing)")))
+;; Bound here (after the defun) rather than in the org use-package `:bind' so
+;; the command isn't autoloaded into a stub that shadows this definition.
+(with-eval-after-load 'org
+ (keymap-set org-mode-map "C-c C-a" #'cj/org-appear-toggle))
+
;; --------------------------------- Org-Tidy ----------------------------------
;; Hide :PROPERTIES: drawers behind a small inline marker so headings stay
@@ -444,7 +514,7 @@ with a file, the function will throw an error."
"Clear the org-element cache for the current buffer or all buffers.
By default, clear cache for all org buffers. With prefix argument, clear only
the current buffer's cache. Useful when encountering parsing errors like
-'wrong-type-argument stringp nil' during agenda generation."
+\"wrong-type-argument stringp nil\" during agenda generation."
(interactive)
(if current-prefix-arg
(if (derived-mode-p 'org-mode)
diff --git a/modules/org-contacts-config.el b/modules/org-contacts-config.el
index 556530eb2..64abb9fb5 100644
--- a/modules/org-contacts-config.el
+++ b/modules/org-contacts-config.el
@@ -22,6 +22,36 @@
(require 'user-constants)
+;; Function declarations -- these live in lazily-loaded packages, so the
+;; byte-compiler can't see their definitions when this module compiles
+;; standalone.
+(declare-function org-contacts-db "org-contacts")
+(declare-function org-contacts-anniversaries "org-contacts")
+(declare-function org-contacts-files "org-contacts")
+(declare-function org-columns "org-colview")
+(declare-function org-reveal "org")
+(declare-function org-fold-show-entry "org-fold")
+(declare-function org-heading-components "org")
+(declare-function org-map-entries "org")
+(declare-function org-entry-get "org")
+(declare-function outline-next-heading "outline")
+(declare-function calendar-current-date "calendar")
+(declare-function mu4e-message-at-point "mu4e-message")
+(declare-function mu4e-message-field "mu4e-message")
+(declare-function which-key-add-key-based-replacements "which-key")
+
+;; External package variables referenced below; declared so the compiler
+;; treats them as special rather than free.
+(defvar org-capture-plist)
+(defvar org-capture-templates)
+(defvar mu4e~view-message)
+(defvar org-agenda-include-diary)
+(defvar org-agenda-custom-commands)
+(defvar mu4e-org-contacts-file)
+(defvar mu4e-headers-actions)
+(defvar mu4e-view-actions)
+(defvar mu4e-compose-complete-addresses)
+
;; Set `org-contacts-files' eagerly at require time. Setting it in the
;; `use-package' form below would only apply when org-contacts loads, which is
;; deferred behind `:after (org mu4e)' -- later than the first
@@ -42,10 +72,13 @@
(defun cj/org-contacts-anniversaries-safe ()
"Safely call org-contacts-anniversaries with required bindings."
(require 'diary-lib)
- ;; These need to be dynamically bound for diary functions
- (defvar date)
- (defvar entry)
- (defvar original-date)
+ ;; `date', `entry', and `original-date' are diary special vars that the
+ ;; diary functions read dynamically. Declare them special locally; the
+ ;; suppressed warning is the unprefixed-name lint on these calendar names.
+ (with-suppressed-warnings ((lexical date entry original-date))
+ (defvar date)
+ (defvar entry)
+ (defvar original-date))
(let ((date (calendar-current-date))
(entry "")
(original-date (calendar-current-date)))
@@ -186,9 +219,10 @@ Added: %U"
(defun cj/--parse-email-string (name email-string)
"Parse EMAIL-STRING and return formatted entries for NAME.
-EMAIL-STRING may contain multiple emails separated by commas, semicolons, or spaces.
-Returns a list of strings formatted as 'Name <email>'.
-Returns nil if EMAIL-STRING is nil or contains only whitespace."
+EMAIL-STRING may contain multiple emails separated by commas,
+semicolons, or spaces. Returns a list of strings formatted as
+\"Name <email>\". Returns nil if EMAIL-STRING is nil or contains only
+whitespace."
(when (and email-string (string-match-p "[^[:space:]]" email-string))
(let ((emails (split-string email-string "[,;[:space:]]+" t)))
(mapcar (lambda (email)
diff --git a/modules/org-faces-config.el b/modules/org-faces-config.el
index e0dfa83fd..dfbfe9d0d 100644
--- a/modules/org-faces-config.el
+++ b/modules/org-faces-config.el
@@ -35,72 +35,72 @@
;; --------------------------- Keyword faces (focused) -------------------------
-(defface org-faces-todo '((t (:foreground "#8fbf73" :weight bold)))
+(defface org-faces-todo '((t (:weight bold)))
"Face for the TODO keyword." :group 'org-faces-config)
-(defface org-faces-project '((t (:foreground "#7a9abe" :weight bold)))
+(defface org-faces-project '((t (:weight bold)))
"Face for the PROJECT keyword." :group 'org-faces-config)
-(defface org-faces-doing '((t (:foreground "#e8c668" :weight bold)))
+(defface org-faces-doing '((t (:weight bold)))
"Face for the DOING keyword." :group 'org-faces-config)
-(defface org-faces-waiting '((t (:foreground "#c9b08a" :weight bold)))
+(defface org-faces-waiting '((t (:weight bold)))
"Face for the WAITING keyword." :group 'org-faces-config)
-(defface org-faces-verify '((t (:foreground "#d98a5a" :weight bold)))
+(defface org-faces-verify '((t (:weight bold)))
"Face for the VERIFY keyword." :group 'org-faces-config)
-(defface org-faces-stalled '((t (:foreground "#9a8fb0" :weight bold)))
+(defface org-faces-stalled '((t (:weight bold)))
"Face for the STALLED keyword." :group 'org-faces-config)
-(defface org-faces-delegated '((t (:foreground "#7fc0a8" :weight bold)))
+(defface org-faces-delegated '((t (:weight bold)))
"Face for the DELEGATED keyword." :group 'org-faces-config)
-(defface org-faces-failed '((t (:foreground "#d05a5a" :weight bold)))
+(defface org-faces-failed '((t (:weight bold)))
"Face for the FAILED keyword." :group 'org-faces-config)
-(defface org-faces-done '((t (:foreground "#6f7a82" :weight bold)))
+(defface org-faces-done '((t (:weight bold)))
"Face for the DONE keyword." :group 'org-faces-config)
-(defface org-faces-cancelled '((t (:foreground "#6f7a82" :weight bold :strike-through t)))
+(defface org-faces-cancelled '((t (:weight bold :strike-through t)))
"Face for the CANCELLED keyword." :group 'org-faces-config)
;; -------------------------- Priority faces (focused) -------------------------
-(defface org-faces-priority-a '((t (:foreground "#7aa0d0" :weight bold)))
+(defface org-faces-priority-a '((t (:weight bold)))
"Face for the [#A] priority cookie." :group 'org-faces-config)
-(defface org-faces-priority-b '((t (:foreground "#e8c668")))
+(defface org-faces-priority-b '((t ()))
"Face for the [#B] priority cookie." :group 'org-faces-config)
-(defface org-faces-priority-c '((t (:foreground "#8fbf73")))
+(defface org-faces-priority-c '((t ()))
"Face for the [#C] priority cookie." :group 'org-faces-config)
-(defface org-faces-priority-d '((t (:foreground "#8a8a8a")))
+(defface org-faces-priority-d '((t ()))
"Face for the [#D] priority cookie." :group 'org-faces-config)
;; ----------------------------- Keyword faces (dim) ---------------------------
;; auto-dim-config.el remaps the focused faces above to these in non-selected
;; windows; a darker shade of the same hue keeps the keyword recognizable.
-(defface org-faces-todo-dim '((t (:foreground "#5f7a4d" :weight bold)))
+(defface org-faces-todo-dim '((t (:weight bold)))
"Dimmed TODO keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-project-dim '((t (:foreground "#4f6680" :weight bold)))
+(defface org-faces-project-dim '((t (:weight bold)))
"Dimmed PROJECT keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-doing-dim '((t (:foreground "#9a8544" :weight bold)))
+(defface org-faces-doing-dim '((t (:weight bold)))
"Dimmed DOING keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-waiting-dim '((t (:foreground "#87745c" :weight bold)))
+(defface org-faces-waiting-dim '((t (:weight bold)))
"Dimmed WAITING keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-verify-dim '((t (:foreground "#8f5a3c" :weight bold)))
+(defface org-faces-verify-dim '((t (:weight bold)))
"Dimmed VERIFY keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-stalled-dim '((t (:foreground "#665e75" :weight bold)))
+(defface org-faces-stalled-dim '((t (:weight bold)))
"Dimmed STALLED keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-delegated-dim '((t (:foreground "#547d6c" :weight bold)))
+(defface org-faces-delegated-dim '((t (:weight bold)))
"Dimmed DELEGATED keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-failed-dim '((t (:foreground "#8a3c3c" :weight bold)))
+(defface org-faces-failed-dim '((t (:weight bold)))
"Dimmed FAILED keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-done-dim '((t (:foreground "#4a5158" :weight bold)))
+(defface org-faces-done-dim '((t (:weight bold)))
"Dimmed DONE keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-cancelled-dim '((t (:foreground "#4a5158" :weight bold :strike-through t)))
+(defface org-faces-cancelled-dim '((t (:weight bold :strike-through t)))
"Dimmed CANCELLED keyword for non-selected windows." :group 'org-faces-config)
;; ---------------------------- Priority faces (dim) ---------------------------
-(defface org-faces-priority-a-dim '((t (:foreground "#4f6a8a" :weight bold)))
+(defface org-faces-priority-a-dim '((t (:weight bold)))
"Dimmed [#A] priority cookie for non-selected windows." :group 'org-faces-config)
-(defface org-faces-priority-b-dim '((t (:foreground "#9a8544")))
+(defface org-faces-priority-b-dim '((t ()))
"Dimmed [#B] priority cookie for non-selected windows." :group 'org-faces-config)
-(defface org-faces-priority-c-dim '((t (:foreground "#5f7a4d")))
+(defface org-faces-priority-c-dim '((t ()))
"Dimmed [#C] priority cookie for non-selected windows." :group 'org-faces-config)
-(defface org-faces-priority-d-dim '((t (:foreground "#5a5a5a")))
+(defface org-faces-priority-d-dim '((t ()))
"Dimmed [#D] priority cookie for non-selected windows." :group 'org-faces-config)
;; ---------------------------------- Wiring -----------------------------------
diff --git a/modules/org-noter-config.el b/modules/org-noter-config.el
index 4e5bd1778..f28f61bb7 100644
--- a/modules/org-noter-config.el
+++ b/modules/org-noter-config.el
@@ -39,9 +39,32 @@
;; Forward declarations
(declare-function org-id-uuid "org-id")
+(declare-function org-entry-get "org")
(declare-function nov-mode "ext:nov")
(declare-function pdf-view-mode "ext:pdf-view")
+;; pdf-tools fit commands (lazily loaded with pdf-tools)
+(declare-function pdf-view-fit-width-to-window "pdf-view")
+(declare-function pdf-view-fit-height-to-window "pdf-view")
+(declare-function pdf-view-fit-page-to-window "pdf-view")
+;; face-remap is built in but loaded lazily
+(declare-function face-remap-remove-relative "face-remap")
+;; org-noter session/sync/skeleton commands (lazily loaded with org-noter)
+(declare-function org-noter--get-notes-window "org-noter")
+(declare-function org-noter--get-doc-window "org-noter")
+(declare-function org-noter-insert-note "org-noter")
+(declare-function org-noter-enable-org-roam-integration "org-noter")
+(declare-function org-noter-sync-next-note "org-noter")
+(declare-function org-noter-sync-prev-note "org-noter")
+(declare-function org-noter-sync-current-note "org-noter")
+(declare-function org-noter-create-skeleton "org-noter")
+(declare-function org-noter-kill-session "org-noter")
+(declare-function org-noter-toggle-notes-window-location "org-noter")
(defvar nov-file-name)
+;; org-noter package variables assigned at session start / config time
+(defvar org-noter-notes-window-location)
+(defvar org-noter-use-pdftools-link-location)
+(defvar org-noter-use-org-id)
+(defvar org-noter-use-unique-org-id)
;;; Configuration Variables
(defvar cj/org-noter-notes-directory roam-dir
@@ -284,7 +307,7 @@ From a PDF/EPUB: starts org-noter session if inactive, then inserts note."
(cond
((and active (not cj/org-noter--bg-remap-cookie))
(setq cj/org-noter--bg-remap-cookie
- (face-remap-add-relative 'default :background "#1d1b19")))
+ (face-remap-add-relative 'default)))
((and (not active) cj/org-noter--bg-remap-cookie)
(face-remap-remove-relative cj/org-noter--bg-remap-cookie)
(setq cj/org-noter--bg-remap-cookie nil))))))))
diff --git a/modules/org-refile-config.el b/modules/org-refile-config.el
index a6b7ac3a4..5f826cacf 100644
--- a/modules/org-refile-config.el
+++ b/modules/org-refile-config.el
@@ -36,7 +36,8 @@
;; ----------------------------- Org Refile Targets ----------------------------
;; sets refile targets
-;; - adds project files in org-roam to the refile targets
+;; - adds org-roam notes tagged "Topic" to the refile targets
+;; (roam "Project" notes were dropped as refile targets 2026-06-24)
;; - adds todo.org files in subdirectories of the code and project directories
(defvar cj/--org-refile-targets-cache (cj/cache-make :ttl 3600)
@@ -100,11 +101,9 @@ Returns the list to assign to `org-refile-targets'. Slow -- walks
(cons schedule-file '(:maxlevel . 1)))))
(when (and (fboundp 'cj/org-roam-list-notes-by-tag)
(fboundp 'org-roam-node-list))
- (let* ((project-and-topic-files
- (append (cj/org-roam-list-notes-by-tag "Project")
- (cj/org-roam-list-notes-by-tag "Topic")))
- (file-rule '(:maxlevel . 1)))
- (dolist (file project-and-topic-files)
+ (let ((topic-files (cj/org-roam-list-notes-by-tag "Topic"))
+ (file-rule '(:maxlevel . 1)))
+ (dolist (file topic-files)
(unless (assoc file new-files)
(push (cons file file-rule) new-files)))))
(let ((file-rule '(:maxlevel . 1)))
diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el
index 218f37d68..eca867df8 100644
--- a/modules/org-roam-config.el
+++ b/modules/org-roam-config.el
@@ -27,6 +27,29 @@
(require 'user-constants)
+;; Declared special so the `let'-binding in `cj/org-roam-copy-todo-to-today'
+;; compiles as a dynamic bind, not a dead lexical local -- otherwise the custom
+;; capture template never reaches org-roam-dailies (the foreign-special-var trap).
+(defvar org-roam-dailies-capture-templates)
+
+;; External variables, declared special so byte-compilation doesn't treat them
+;; as free references/assignments. Owned by org and org-roam-dailies.
+(defvar org-agenda-timegrid-use-ampm)
+(defvar org-roam-dailies-map)
+(defvar org-last-state)
+
+;; External functions, declared so the byte-compiler knows they're defined at
+;; runtime by their respective packages.
+(declare-function org-roam-node-tags "org-roam")
+(declare-function org-roam-node-file "org-roam")
+(declare-function org-roam-node-list "org-roam")
+(declare-function org-roam-dailies--capture "org-roam-dailies")
+(declare-function org-capture-get "org-capture")
+(declare-function org-at-heading-p "org")
+(declare-function org-heading-components "org")
+(declare-function org-copy-subtree "org")
+(declare-function org-cut-subtree "org")
+
;; ---------------------------------- Org Roam ---------------------------------
(defconst cj/--org-roam-dailies-head
@@ -71,8 +94,6 @@ FILETAGS and TITLE must sit on separate lines so Org parses the
:bind (("C-c n l" . org-roam-buffer-toggle)
("C-c n f" . org-roam-node-find)
("C-c n p" . cj/org-roam-find-node-project)
- ("C-c n r" . cj/org-roam-find-node-recipe)
- ("C-c n t" . cj/org-roam-find-node-topic)
("C-c n i" . org-roam-node-insert)
("C-c n w" . cj/org-roam-find-node-webclip)
:map org-mode-map
@@ -81,8 +102,10 @@ FILETAGS and TITLE must sit on separate lines so Org parses the
;; org-log-done is set once in org-config.el (cj/org-todo-settings).
(setq org-agenda-timegrid-use-ampm t)
- (when (fboundp 'cj/build-org-refile-targets)
- (cj/build-org-refile-targets))
+ ;; Don't build the org-refile targets cache here. org-refile-config.el
+ ;; already schedules it on a 5s idle timer; doing it in org-roam's :config
+ ;; (which fires at the 1s :defer) ran the same multi-file scan synchronously
+ ;; at first idle and froze Emacs on a cold cache. The 5s timer covers it.
;; remove/disable if performance slows
;; (setq org-element-use-cache nil) ;; disables caching org files
@@ -186,6 +209,11 @@ created in that subdirectory of `org-roam-directory'."
(interactive)
(cj/org-roam-find-node "Recipe" "r" (concat roam-dir "templates/recipe.org") "recipes/"))
+;; Bound after their defuns (not in the use-package :bind) so the byte-compiler
+;; doesn't see both a :bind autoload and the real defun as two definitions.
+(keymap-global-set "C-c n r" #'cj/org-roam-find-node-recipe)
+(keymap-global-set "C-c n t" #'cj/org-roam-find-node-topic)
+
;; ---------------------- Org Capture After Finalize Hook ----------------------
(defun cj/org-roam-add-node-to-agenda-files-finalize-hook ()
diff --git a/modules/pdf-config.el b/modules/pdf-config.el
index ca2312307..56b397df3 100644
--- a/modules/pdf-config.el
+++ b/modules/pdf-config.el
@@ -14,6 +14,22 @@
;;
;;; Code:
+;; ------------------------------- Declarations --------------------------------
+
+(declare-function pdf-tools-install "pdf-tools")
+(declare-function pdf-view-midnight-minor-mode "pdf-view")
+(declare-function pdf-view-enlarge "pdf-view")
+(declare-function pdf-view-shrink "pdf-view")
+(declare-function pdf-view-next-page "pdf-view")
+(declare-function pdf-view-previous-page "pdf-view")
+(declare-function image-next-line "image-mode")
+(declare-function image-previous-line "image-mode")
+(declare-function image-bob "image-mode")
+(declare-function image-eob "image-mode")
+(declare-function org-store-link "ol")
+(declare-function cj/open-file-with-command "system-utils")
+(declare-function cj/org-noter-insert-note-dwim "org-noter-config")
+
;; --------------------------------- PDF Tools ---------------------------------
(use-package pdf-tools
@@ -24,7 +40,6 @@
:custom
(pdf-view-display-size 'fit-page)
(pdf-view-resize-factor 1.1)
- (pdf-view-midnight-colors '("#F1D5AC" . "#0F0E06")) ;; fg . bg
;; Avoid searching for unicodes to speed up pdf-tools.
;; ... and yes, 'ligther' is not a typo
(pdf-view-use-unicode-ligther nil)
@@ -61,9 +76,9 @@
(define-key pdf-view-mode-map "i" #'cj/org-noter-insert-note-dwim)
;; Page change: C-up/C-down go to top of prev/next page
(define-key pdf-view-mode-map (kbd "C-<down>")
- (lambda () (interactive) (pdf-view-next-page-command) (image-bob)))
+ (lambda () (interactive) (pdf-view-next-page) (image-bob)))
(define-key pdf-view-mode-map (kbd "C-<up>")
- (lambda () (interactive) (pdf-view-previous-page-command) (image-eob))))
+ (lambda () (interactive) (pdf-view-previous-page) (image-eob))))
;; ------------------------------ PDF View Restore -----------------------------
diff --git a/modules/prog-general.el b/modules/prog-general.el
index 968032831..f22f89923 100644
--- a/modules/prog-general.el
+++ b/modules/prog-general.el
@@ -59,13 +59,16 @@
(declare-function treesit-auto-add-to-auto-mode-alist "treesit-auto")
(declare-function treesit-auto-recipe-lang "treesit-auto")
(declare-function highlight-indent-guides-mode "highlight-indent-guides")
+(declare-function electric-pair-default-inhibit "elec-pair")
+(declare-function yas-reload-all "yasnippet")
+(declare-function yas-activate-extra-mode "yasnippet")
;; Forward declarations for treesit-auto variables
(defvar treesit-auto-recipe-list)
+(defvar electric-pair-inhibit-predicate)
;; Forward declarations for functions defined later in this file
(declare-function cj/project-switch-actions "prog-general")
-(declare-function cj/deadgrep--initial-term "prog-general")
(defun cj/find-project-root-file (regexp)
"Return first file in the current Projectile project root matching REGEXP.
@@ -287,6 +290,16 @@ seeded by `cj/deadgrep--initial-term'. Shared tail of the deadgrep commands."
(with-eval-after-load 'dired
(keymap-set dired-mode-map "G" #'cj/deadgrep-here))
+;; ------------------------------------ wgrep ----------------------------------
+;; Make a grep buffer editable, then write the edits back across files -- turns
+;; a consult-grep / embark-export result into a project-wide find-and-replace.
+;; In a grep buffer: C-c C-p to start editing, C-c C-c to apply.
+
+(use-package wgrep
+ :custom
+ (wgrep-auto-save-buffer t) ;; save the touched files when applying
+ (wgrep-change-readonly-file t)) ;; let edits flow into read-only buffers
+
;; ---------------------------------- Snippets ---------------------------------
;; reusable code and text
@@ -371,16 +384,7 @@ defer to `electric-pair-default-inhibit' for any other CHAR."
(use-package hl-todo
:defer 1
:hook
- (prog-mode . hl-todo-mode)
- :config
- (setq hl-todo-keyword-faces
- '(("FIXME" . "#FF0000")
- ("BUG" . "#FF0000")
- ("HACK" . "#FF0000")
- ("ISSUE" . "#DAA520")
- ("TASK" . "#DAA520")
- ("NOTE" . "#2C780E")
- ("WIP" . "#1E90FF"))))
+ (prog-mode . hl-todo-mode))
;; --------------------------- Whitespace Management ---------------------------
;; trims trailing whitespace only from lines you've modified when saving buffer
diff --git a/modules/selection-framework.el b/modules/selection-framework.el
index a567e8003..464654a20 100644
--- a/modules/selection-framework.el
+++ b/modules/selection-framework.el
@@ -26,6 +26,12 @@
;;
;;; Code:
+;; External variables and lazily-loaded functions referenced below.
+(defvar xref-show-xrefs-function)
+(defvar xref-show-definitions-function)
+(declare-function consult-dir-projectile-dirs "consult-dir")
+(declare-function prescient-persist-mode "prescient")
+
;; ---------------------------------- Vertico ----------------------------------
;; Vertical completion UI
diff --git a/modules/term-config.el b/modules/term-config.el
index c1c28911d..474a85c42 100644
--- a/modules/term-config.el
+++ b/modules/term-config.el
@@ -59,6 +59,14 @@
(defvar ghostel-mode-map)
(defvar ghostel-keymap-exceptions)
(defvar ghostel-buffer-name)
+(defvar ghostel--input-mode)
+
+;; eat backs the F12 toggle (see the eat package + F12 toggle sections below).
+(declare-function eat "eat" (&optional program arg))
+(defvar eat-buffer-name)
+(defvar eat-mode-map)
+(defvar eat-semi-char-mode-map)
+(defvar cj/custom-keymap)
(defvar-keymap cj/term-map
:doc "Personal terminal command map.")
@@ -206,6 +214,44 @@ start of the line for the same column-0 reason."
(ghostel-copy-mode)
(beginning-of-line)))
+;; ----------------------------- copy-mode scroll ------------------------------
+;;
+;; C-<up> both enters copy-mode and scrolls up one line, so a single stroke
+;; lands in the scrollback already moving the right way. It joins
+;; `ghostel-keymap-exceptions' so it reaches Emacs instead of the pty. Only the
+;; up gesture is bound: C-<left>/<right> are readline word-motion at the shell
+;; prompt and must pass through, and the other directions have no copy-mode use.
+;; Pressed again while already in copy-mode it just moves up -- re-entering would
+;; reset the cursor (tmux's prefix-[ + C-a, or ghostel's toggle exiting).
+
+(defun cj/term--tmux-pane-in-copy-mode-p (pane-id)
+ "Return non-nil when tmux PANE-ID is currently displaying a mode.
+tmux's `pane_in_mode' is 1 while a pane is in any mode; copy-mode is the only
+mode this config enters. tmux failures are treated as nil."
+ (condition-case nil
+ (equal "1" (string-trim
+ (cj/term--tmux-output
+ "display-message" "-p" "-t" pane-id "#{pane_in_mode}")))
+ (error nil)))
+
+(defun cj/term-copy-mode-up ()
+ "Enter copy-mode if needed, then scroll up one line.
+A single C-<up> lands in the terminal's copy-mode already moving up. Pressed
+again while already in copy-mode it just moves up another line, so it never
+re-enters and resets the cursor. In tmux, writes the up-arrow escape sequence
+into the pty; without tmux, moves point up in the `ghostel-copy-mode' buffer."
+ (interactive)
+ (let ((pane (ignore-errors (cj/term--current-tmux-pane-id))))
+ (cond
+ (pane
+ (unless (cj/term--tmux-pane-in-copy-mode-p pane)
+ (cj/term-copy-mode-dwim))
+ (ghostel-send-string "\e[A"))
+ (t
+ (unless (eq (bound-and-true-p ghostel--input-mode) 'copy)
+ (cj/term-copy-mode-dwim))
+ (forward-line -1)))))
+
;; ----------------------------- ghostel package -------------------------------
(defun cj/turn-off-chrome-for-term ()
@@ -245,16 +291,20 @@ run its own project-named tmux session instead of a bare, auto-named one.
;; `add-to-list' alone updates the list but not the already-built map, so the
;; 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. F8 and F10
- ;; are global bindings (org agenda, music-playlist toggle) that reach Emacs by
- ;; falling through to the global map once the semi-char map stops forwarding
- ;; them. (Server shutdown moved off C-F10 to C-x C, which is deliberately
- ;; left forwarding to the terminal program inside an agent buffer.)
+ ;; windmove (S-arrows, focus), buffer-move (C-M-arrows, swap), and copy-mode
+ ;; entry (C-<up> only, via `cj/term-copy-mode-up'), which the ai-term workflow
+ ;; expects to work from inside an agent buffer. C-<left>/<right> deliberately
+ ;; stay forwarding so readline word-motion works at the shell prompt. F8 and
+ ;; F10 are global bindings (org agenda, music-playlist toggle) that reach
+ ;; Emacs by falling through to the global map once the semi-char map stops
+ ;; forwarding them. (Server shutdown moved off C-F10 to C-x C, which is
+ ;; deliberately left forwarding to the terminal program inside an agent
+ ;; buffer.)
(with-eval-after-load 'ghostel
(dolist (key '("C-;" "<f8>" "<f12>" "<f10>"
"S-<up>" "S-<down>" "S-<left>" "S-<right>"
- "C-M-<up>" "C-M-<down>" "C-M-<left>" "C-M-<right>"))
+ "C-M-<up>" "C-M-<down>" "C-M-<left>" "C-M-<right>"
+ "C-<up>"))
(add-to-list 'ghostel-keymap-exceptions key))
(ghostel--rebuild-semi-char-keymap))
:hook
@@ -271,13 +321,35 @@ run its own project-named tmux session instead of a bare, auto-named one.
;; Byte analog of the prior 100000-line vterm setting (~100 bytes/line) -- D7.
(ghostel-max-scrollback (* 10 1024 1024)))
+;; ------------------------------- eat package ---------------------------------
+;; EAT (pure-elisp terminal) backs the F12 toggle: its whole palette is real
+;; Emacs faces, so it themes from the theme. ghostel stays for ai-term (M-SPC).
+;; No tmux here -- F12's EAT runs a plain $SHELL (decision 2026-06-25).
+
+(use-package eat
+ :ensure t
+ :commands (eat)
+ :hook (eat-mode . cj/turn-off-chrome-for-term)
+ :custom
+ ;; Close the EAT buffer when its shell exits (mirrors ghostel-kill-buffer-on-exit).
+ (eat-kill-buffer-on-exit t)
+ :config
+ ;; F12 and C-; must reach Emacs from inside EAT. In semi-char mode (EAT's
+ ;; default) EAT forwards unbound keys to the terminal -- a letter runs
+ ;; `eat-self-input' -- so bind these explicitly or they never reach Emacs:
+ ;; F12 toggles the terminal window, C-; opens the global prefix map.
+ (keymap-set eat-semi-char-mode-map "<f12>" #'cj/term-toggle)
+ (keymap-set eat-semi-char-mode-map "C-;" cj/custom-keymap)
+ (keymap-set eat-mode-map "<f12>" #'cj/term-toggle)
+ (keymap-set eat-mode-map "C-;" cj/custom-keymap))
+
;; ----------------------- F12 toggle (custom) -----------------------
;;
;; Mirrors the geometry-preservation pattern shared with ai-term.el: capture
;; direction + body size at toggle-off, replay them via a custom display action
;; using frame-edge directions and body-relative sizes so the result is
-;; divider-independent and layout-stable. Excludes agent-prefixed buffers,
-;; which ai-term.el owns via F9.
+;; divider-independent and layout-stable. Manages the EAT terminal only;
+;; ai-term.el's ghostel agent buffers are separate (M-SPC).
(defcustom cj/term-toggle-window-height 0.7
"Default fraction of frame height for the F12 terminal window.
@@ -320,19 +392,18 @@ Positive integer: body-cols (right/left) or total-lines (below/above) -- see
nil means fall back to `cj/term-toggle-window-height' as a fraction.")
(defun cj/--term-toggle-buffer-p (buffer)
- "Return non-nil when BUFFER is a terminal buffer F12 should manage.
+ "Return non-nil when BUFFER is the EAT terminal F12 should manage.
-Qualifies when BUFFER is alive and has `ghostel-mode' (or its name starts with
-the ghostel buffer-name prefix), AND its name does NOT start with the agent
-prefix used by ai-term.el."
+Qualifies when BUFFER is alive and has `eat-mode' (or its name starts with the
+EAT buffer-name prefix). ai-term's ghostel agent buffers never match -- they
+are managed separately via M-SPC, not F12."
(and (bufferp buffer)
(buffer-live-p buffer)
(with-current-buffer buffer
- (and (or (eq major-mode 'ghostel-mode)
- (string-prefix-p (or (bound-and-true-p ghostel-buffer-name)
- "*ghostel*")
- (buffer-name buffer)))
- (not (string-prefix-p "agent [" (buffer-name buffer)))))))
+ (or (eq major-mode 'eat-mode)
+ (string-prefix-p (or (bound-and-true-p eat-buffer-name)
+ "*eat*")
+ (buffer-name buffer))))))
(defun cj/--term-toggle-buffers ()
"Return live F12-managed terminal buffers in `buffer-list' (MRU) order."
@@ -396,18 +467,17 @@ Returns one of:
(t '(create-new))))))))
(defun cj/term-toggle ()
- "Toggle a normal (non-agent) ghostel terminal buffer.
-
-- If an F12-managed terminal is displayed in this frame, capture its geometry
- and delete its window (toggle off). Falls back to burying when it is the
- only window in the frame.
-- Otherwise, if any F12-managed terminal buffer is alive, display the most
- recent one via the saved-geometry action.
-- Otherwise, create a new terminal via `(ghostel)' which routes through the
- same display action.
-
-Excludes agent-prefixed buffers; those have their own F9 dispatch via
-`cj/ai-term'."
+ "Toggle the EAT terminal buffer.
+
+- If the EAT terminal is displayed in this frame, capture its geometry and
+ delete its window (toggle off). Falls back to burying when it is the only
+ window in the frame.
+- Otherwise, if the EAT terminal buffer is alive, display it via the
+ saved-geometry action.
+- Otherwise, create a new EAT terminal, displaying it through the same
+ saved-geometry action.
+
+ai-term's ghostel agent buffers are managed separately via M-SPC, not F12."
(interactive)
(pcase (cj/--term-toggle-dispatch)
(`(toggle-off . ,win)
@@ -422,7 +492,15 @@ Excludes agent-prefixed buffers; those have their own F9 dispatch via
(when w (select-window w)))
buf)
(`(create-new)
- (ghostel))))
+ ;; Create the EAT buffer without stealing the layout, then display it
+ ;; through the saved-geometry dock rule (same path as show-recent).
+ (save-window-excursion (eat))
+ (let ((buf (get-buffer (or (bound-and-true-p eat-buffer-name) "*eat*"))))
+ (when buf
+ (display-buffer buf)
+ (let ((w (get-buffer-window buf)))
+ (when w (select-window w))))
+ buf))))
(keymap-global-set "<f12>" #'cj/term-toggle)
@@ -452,12 +530,13 @@ Forwarding NUL makes C-Space behave like a terminal key."
(defun cj/term-install-keys ()
"Make `C-;' resolve as the personal keymap inside ghostel buffers, bind the
-F12 toggle, and forward C-SPC so it reaches the terminal (see
-`cj/term-send-C-SPC')."
+F12 toggle, forward C-SPC so it reaches the terminal (see
+`cj/term-send-C-SPC'), and bind C-<up> to enter copy-mode and scroll up."
(when (boundp 'ghostel-mode-map)
(keymap-set ghostel-mode-map "C-;" cj/custom-keymap)
(keymap-set ghostel-mode-map "<f12>" #'cj/term-toggle)
- (keymap-set ghostel-mode-map "C-SPC" #'cj/term-send-C-SPC)))
+ (keymap-set ghostel-mode-map "C-SPC" #'cj/term-send-C-SPC)
+ (keymap-set ghostel-mode-map "C-<up>" #'cj/term-copy-mode-up)))
(cj/term-install-keys)
(with-eval-after-load 'ghostel
diff --git a/modules/tramp-config.el b/modules/tramp-config.el
index 23010b3e4..e3b835f1f 100644
--- a/modules/tramp-config.el
+++ b/modules/tramp-config.el
@@ -23,6 +23,15 @@
;;; Code:
+;; Silence byte-compiler "assignment to free variable" warnings for vars
+;; defined by lazily-loaded packages (tramp, dirtrack, magit). These are
+;; only set inside the use-package :config block, after the package loads.
+(defvar tramp-copy-size-limit)
+(defvar tramp-use-ssh-controlmaster-options)
+(defvar tramp-cleanup-idle-time)
+(defvar dirtrack-list)
+(defvar magit-git-executable)
+
(use-package tramp
:defer .5
:ensure nil ;; built-in
diff --git a/modules/transcription-config.el b/modules/transcription-config.el
index 566cea499..e00306d1e 100644
--- a/modules/transcription-config.el
+++ b/modules/transcription-config.el
@@ -173,13 +173,17 @@ TITLE and MESSAGE are strings. URGENCY is normal or critical."
:body message
:urgency (or urgency 'normal))))
-(defun cj/--start-transcription-process (audio-file &optional cleanup-file)
+(defun cj/--start-transcription-process (audio-file &optional cleanup-file output-base)
"Start async transcription process for AUDIO-FILE.
Returns the process object.
When CLEANUP-FILE is non-nil, delete that path once the transcription
sentinel fires (success or failure). Used by the video flow to drop
-the temp audio file produced by ffmpeg after transcription completes."
+the temp audio file produced by ffmpeg after transcription completes.
+
+OUTPUT-BASE, when non-nil, is the path the .txt/.log outputs derive from
+instead of AUDIO-FILE. The video flow passes the original video so the
+transcript lands alongside the source, not next to the temp /tmp audio."
(unless (file-exists-p audio-file)
(user-error "Audio file does not exist: %s" audio-file))
@@ -187,7 +191,7 @@ the temp audio file produced by ffmpeg after transcription completes."
(user-error "Not an audio file: %s" audio-file))
(let* ((script (cj/--transcription-script-path))
- (outputs (cj/--transcription-output-files audio-file))
+ (outputs (cj/--transcription-output-files (or output-base audio-file)))
(txt-file (car outputs))
(log-file (cdr outputs))
(buffer-name (format " *transcribe-%s*" (file-name-nondirectory audio-file)))
@@ -371,7 +375,9 @@ FILE.log with process logs. Uses the backend in
(cj/--extract-audio-from-video
path extracted
(lambda ()
- (cj/--start-transcription-process extracted extracted))))))))
+ ;; Pass the source video as the output base so the .txt/.log land
+ ;; alongside it, not next to the temp /tmp audio.
+ (cj/--start-transcription-process extracted extracted path))))))))
;;;###autoload
(defun cj/transcribe-media-at-point ()
diff --git a/modules/ui-navigation.el b/modules/ui-navigation.el
index c099e0834..cb0fc5697 100644
--- a/modules/ui-navigation.el
+++ b/modules/ui-navigation.el
@@ -283,5 +283,15 @@ With numeric prefix ARG, re-open the ARGth most-recently-killed file
:config
(winner-mode 1))
+;; ------------------------------- Cursor Jump (avy) ---------------------------
+;; Jump anywhere visible by typing a few of the target's characters, then the
+;; decision-tree key avy overlays. Fills the in-buffer motion gap that windmove
+;; (windows) and isearch (text) leave.
+
+(use-package avy
+ :bind (("C-:" . avy-goto-char-timer) ;; type chars, pause, jump to a match
+ ("M-g w" . avy-goto-word-1)
+ ("M-g l" . avy-goto-line)))
+
(provide 'ui-navigation)
;;; ui-navigation.el ends here
diff --git a/modules/user-constants.el b/modules/user-constants.el
index dab12dcbe..570b142fb 100644
--- a/modules/user-constants.el
+++ b/modules/user-constants.el
@@ -154,15 +154,24 @@ Syncthing-synced `org-dir' — see the 2026-06-10 transport migration.")
(defvar gcal-file (expand-file-name "data/gcal.org" user-emacs-directory)
"The location of the org file containing Google Calendar information.
-Stored in .emacs.d/data/ so each machine syncs independently from Google Calendar.")
+Stored in .emacs.d/data/ so each machine syncs independently from
+Google Calendar.")
(defvar pcal-file (expand-file-name "data/pcal.org" user-emacs-directory)
"The location of the org file containing Proton Calendar information.
-Stored in .emacs.d/data/ so each machine syncs independently from Proton Calendar.")
+Stored in .emacs.d/data/ so each machine syncs independently from
+Proton Calendar.")
(defvar dcal-file (expand-file-name "data/dcal.org" user-emacs-directory)
"The location of the org file containing DeepSat Calendar information.
-Stored in .emacs.d/data/ so each machine syncs independently from Google Calendar.")
+Stored in .emacs.d/data/ so each machine syncs independently from
+Google Calendar.")
+
+(defvar keep-file (expand-file-name "data/keep.org" user-emacs-directory)
+ "The location of the generated org file containing Google Keep notes.
+A read-only view regenerated by `cj/keep-refresh'; edits here do not
+sync back to Keep. Stored in .emacs.d/data/ so each machine syncs
+independently.")
(defvar reference-file (expand-file-name "reference.org" org-dir)
"The location of the org file containing reference information.")
diff --git a/modules/vc-config.el b/modules/vc-config.el
index 654116c59..fcca7e07b 100644
--- a/modules/vc-config.el
+++ b/modules/vc-config.el
@@ -27,6 +27,27 @@
(require 'user-constants) ;; provides code-dir
(require 'keybindings) ;; provides cj/custom-keymap
+;; Forward declaration: cj/vc-map is defined later in this file (see
+;; `defvar-keymap' below) but referenced earlier in a use-package :bind form.
+(defvar cj/vc-map)
+
+;; External package variables (assigned in :config blocks of lazily-loaded
+;; packages, so not loaded at byte-compile time).
+(defvar forge-pull-notifications)
+(defvar forge-topic-list-limit)
+
+;; External package functions (from lazily-loaded packages).
+(declare-function git-gutter:next-hunk "git-gutter")
+(declare-function git-gutter:previous-hunk "git-gutter")
+(declare-function git-timemachine--start "git-timemachine")
+(declare-function git-timemachine--revisions "git-timemachine")
+(declare-function git-timemachine-show-revision "git-timemachine")
+(declare-function forge-current-repository "forge")
+(declare-function forge-create-issue "forge")
+
+;; Defined later in this file; referenced earlier in `cj/git-timemachine'.
+(declare-function cj/git-timemachine-show-selected-revision "vc-config")
+
;; ---------------------------- Magit Configuration ----------------------------
(use-package magit
diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el
index 4c934ef17..1672529f7 100644
--- a/modules/video-audio-recording.el
+++ b/modules/video-audio-recording.el
@@ -174,9 +174,10 @@ Checks if process is actually alive, not just if variable is set."
(defun cj/recording-process-sentinel (process event)
"Sentinel for recording processes — handles unexpected exits.
PROCESS is the ffmpeg shell process, EVENT describes what happened.
-This is called by Emacs when the process changes state (exits, is killed, etc.).
-It clears the process variable and updates the modeline so the recording indicator
-disappears even if the recording crashes or is killed externally."
+This is called by Emacs when the process changes state (exits, is
+killed, etc.). It clears the process variable and updates the modeline
+so the recording indicator disappears even if the recording crashes or
+is killed externally."
(when (memq (process-status process) '(exit signal))
(cond
((eq process cj/audio-recording-ffmpeg-process)