aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/ai-term.el255
-rw-r--r--modules/calendar-sync.el84
-rw-r--r--modules/calibredb-epub-config.el49
-rw-r--r--modules/cj-window-geometry-lib.el35
-rw-r--r--modules/cj-window-toggle-lib.el30
-rw-r--r--modules/custom-case.el120
-rw-r--r--modules/custom-comments.el167
-rw-r--r--modules/dirvish-config.el51
-rw-r--r--modules/dwim-shell-config.el76
-rw-r--r--modules/erc-config.el19
-rw-r--r--modules/gcmh-config.el30
-rw-r--r--modules/system-defaults.el19
-rw-r--r--modules/system-utils.el2
-rw-r--r--modules/term-config.el16
14 files changed, 530 insertions, 423 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
index 8dfd5e370..ff8da0035 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -52,15 +52,19 @@
;; 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"
+;; 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
;; buffer. Its window stays in the layout (swapped to the
;; working buffer), so closing never collapses a split. Confirms
;; first. Targets the current agent, the sole live agent, or
;; prompts among several.
-;; - C-S-F9 `cj/ai-term-close' -- same close command, second binding.
-;; (M-F9 is the primary; C-S-F9 may be swallowed by the
-;; Wayland/PGTK layer on some machines.)
;;
;; Existing windmove (Shift-arrows) handles code <-> agent focus
;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither
@@ -181,6 +185,21 @@ 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.
+
+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.
+
+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))))
+
(defun cj/--ai-term-most-recent-non-agent-buffer ()
"Return the most-recently-selected live non-agent buffer, or nil.
@@ -433,6 +452,18 @@ without deleting), nil when the window was deleted. Consumed by
buried agent in the current window (the only one) or splitting per
the saved direction.")
+(defvar cj/--ai-term-last-toggle-deleted-split nil
+ "Non-nil when the last F9 toggle-off deleted the agent's own split window.
+
+Set t by `cj/--ai-term-toggle-off' only when it actually `delete-window's
+the agent (a multi-window layout where the agent had its own window);
+nil for a bury or a degenerate swap. Consumed by
+`cj/--ai-term-reuse-edge-window': when set, the next toggle-on re-splits a
+fresh agent window instead of reusing a window at the edge. Without this,
+toggling the agent off and on in a 3+ window layout would reuse the user's
+working window at the edge, displacing its buffer and collapsing the layout
+-- the toggle must be reversible (off then on returns the same windows).")
+
(defvar cj/--ai-term-last-hidden-buffer nil
"The agent buffer hidden by the most recent F9 toggle-off.
@@ -445,21 +476,28 @@ the \"the displayed buffer changes\" bug. Falls back to the buffer-list
MRU when nil or when the remembered buffer has been killed.")
(defvar cj/--ai-term-last-size nil
- "Last user-chosen body size for the AI-term display.
+ "Last user-chosen size for the AI-term display.
Positive integer: body-columns when `cj/--ai-term-last-direction'
-is right or left, body-lines when below or above. nil means use
+is right or left, total-lines when below or above. nil means use
the host-aware default from `cj/--ai-term-default-size' (a float
-fraction).
-
-Body size, not total size, because total-width includes the
-right-edge divider when the window has a right sibling but excludes
-it when the window is at the frame edge. Capturing total-width
-from a rightmost agent (no divider) and replaying into a middle
-position (with divider) leaves the body 1 column short -- visible
-as 1 col of the sibling buffer peeking through where agent should
-have ended. Body-width is divider-independent and matches what the
-user actually sees.
+fraction). See `cj/window-replay-size' for the per-axis capture.
+
+The axis choice is asymmetric. Width captures body-width, not
+total-width: total-width includes the right-edge divider when the
+window has a right sibling but excludes it at the frame edge, so
+capturing total-width from a rightmost agent (no divider) and
+replaying into a middle position (with divider) leaves the body 1
+column short. Body-width is divider-independent.
+
+Height captures total-height, not body-height: every window has
+exactly one mode line regardless of position, so total-height has
+no divider-position problem, and total-height is the same whether
+the window is active or inactive. Body-height would subtract the
+mode line's pixel height, which differs between an active and an
+inactive (theme-shrunk) mode line -- capturing body-height active
+and replaying it inactive then re-measuring active drifts the
+window down by ~1 line per toggle (the F9 shrink bug, 2026-06-20).
Absolute values rather than fractions because
`display-buffer-in-direction' interprets a float `window-width' /
@@ -527,14 +565,22 @@ displaced buffer and the agent, never changing the window count.
Runs after `cj/--ai-term-reuse-existing-agent', so an agent already on
screen has been handled already; the window reused here always holds a
-non-agent buffer, which is replaced (it stays alive, just unshown)."
- (let* ((direction (or cj/--ai-term-last-direction
- (cj/--ai-term-default-direction)))
- (win (cj/window-at-edge direction)))
- (when (and win (not (window-dedicated-p win)))
- (display-buffer-record-window 'reuse win buffer)
- (set-window-buffer win buffer)
- win)))
+non-agent buffer, which is replaced (it stays alive, just unshown).
+
+Skipped entirely when the prior toggle-off deleted the agent's own split
+window (`cj/--ai-term-last-toggle-deleted-split'): re-showing then reuses a
+working window at the edge and collapses the layout. Consume the flag and
+return nil so `cj/--ai-term-display-saved' re-splits a fresh agent window,
+keeping the toggle reversible."
+ (if cj/--ai-term-last-toggle-deleted-split
+ (progn (setq cj/--ai-term-last-toggle-deleted-split nil) nil)
+ (let* ((direction (or cj/--ai-term-last-direction
+ (cj/--ai-term-default-direction)))
+ (win (cj/window-at-edge direction)))
+ (when (and win (not (window-dedicated-p win)))
+ (display-buffer-record-window 'reuse win buffer)
+ (set-window-buffer win buffer)
+ win))))
(defun cj/--ai-term-display-saved (buffer alist)
"Display-buffer action: split per saved direction and size.
@@ -774,6 +820,72 @@ launches from either (only kitty inline-graphics degrade in a TTY)."
(when win (select-window win))))
buf))
+(defun cj/--ai-term-swap-to-working-buffer (win)
+ "In WIN, switch to the most-recent non-agent buffer (a working file).
+Falls back to `other-buffer' (excluding WIN's current agent buffer) when no
+non-agent buffer is on record. Used at toggle-off and close so dismissing an
+agent surfaces the file the user was working on rather than another agent or
+the agent itself."
+ (with-selected-window win
+ (switch-to-buffer
+ (or (cj/--ai-term-most-recent-non-agent-buffer)
+ (other-buffer (window-buffer win) t)))))
+
+(defun cj/--ai-term-toggle-off (win)
+ "Hide the agent shown in WIN for an F9 toggle-off. Always returns nil.
+
+Two cases, by window count:
+
+- Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no prior
+ layout for the native undo to restore and deleting would leave the frame
+ empty. Bury and flag, so the next toggle-on (`cj/--ai-term-display-saved')
+ restores the agent in place at full frame rather than splitting. Capture
+ geometry for that restore. `bury-buffer' can no-op when the window's
+ prev-buffer history holds only the agent (common right after `C-x 1'), so
+ force a swap to a non-agent buffer to keep the toggle observable.
+
+- Multi-window: collapse the agent split outright by deleting its window, so
+ the working buffer (e.g. todo.org) reclaims the space. F9 is a pure
+ show/hide toggle of THE agent split -- it must never surface a different
+ agent. `quit-restore-window' can't guarantee that here: switching among
+ several agents reuses the one slot via `set-window-buffer' (see
+ `cj/--ai-term-reuse-existing-agent'), which leaves the window's
+ `quit-restore' parameter pointing at the FIRST agent shown. Once it's
+ stale, `quit-restore-window' falls back to `switch-to-prev-buffer' and
+ surfaces another agent instead of removing the window -- exactly the \"F9
+ shows another agent\" bug. `delete-window' is unconditional and
+ slot-history-independent. Capture geometry first so the next toggle-on
+ splits at the same size (the user's chosen split width is preserved)."
+ ;; Remember which agent we're hiding so the next toggle-on reopens this
+ ;; same one, not whichever agent is most-recent in `buffer-list'.
+ (setq cj/--ai-term-last-hidden-buffer (window-buffer win))
+ (cond
+ ((one-window-p)
+ (cj/--ai-term-capture-state win)
+ (setq cj/--ai-term-last-was-bury t)
+ (setq cj/--ai-term-last-toggle-deleted-split nil)
+ (bury-buffer (window-buffer win))
+ (when (and (window-live-p win)
+ (cj/--ai-term-buffer-p (window-buffer win)))
+ (cj/--ai-term-swap-to-working-buffer win)))
+ (t
+ (cj/--ai-term-capture-state win)
+ (setq cj/--ai-term-last-was-bury nil)
+ (if (and (window-live-p win)
+ (> (length (window-list (window-frame win) 'never)) 1))
+ (progn
+ (delete-window win)
+ ;; The agent had its own window in a multi-window layout, now gone:
+ ;; the next toggle-on must re-split it rather than reuse a working
+ ;; window at the edge (see `cj/--ai-term-reuse-edge-window').
+ (setq cj/--ai-term-last-toggle-deleted-split t))
+ ;; Degenerate fallback (window became sole between dispatch and
+ ;; here): swap to a non-agent buffer rather than leave the agent up.
+ (setq cj/--ai-term-last-toggle-deleted-split nil)
+ (when (window-live-p win)
+ (cj/--ai-term-swap-to-working-buffer win)))))
+ nil)
+
(defun cj/ai-term (&optional arg)
"Smart F9 dispatch for the AI-term launcher.
@@ -789,59 +901,11 @@ With prefix ARG, display the buffer without selecting its window
when a buffer is being shown (no effect on the toggle-off branch).
See `cj/ai-term-pick-project' (C-F9) to force the project picker.
-M-F9 (and C-S-F9) close an agent via `cj/ai-term-close'."
+M-F9 closes an agent via `cj/ai-term-close'."
(interactive "P")
(pcase (cj/--ai-term-dispatch)
(`(toggle-off . ,win)
- ;; Remember which agent we're hiding so the next toggle-on reopens this
- ;; same one, not whichever agent is most-recent in `buffer-list'.
- (setq cj/--ai-term-last-hidden-buffer (window-buffer win))
- (cond
- ;; Lone fullscreen agent (e.g. after `C-x 1' inside it): there is no
- ;; prior layout for the native undo to restore and deleting would
- ;; leave the frame empty. Bury and flag, so the next toggle-on
- ;; (`cj/--ai-term-display-saved') restores the agent in place at
- ;; full frame rather than splitting. Capture geometry for that
- ;; restore. `bury-buffer' can no-op when the window's prev-buffer
- ;; history holds only the agent (common right after `C-x 1'), so
- ;; force a swap to a non-agent buffer to keep the toggle observable.
- ((one-window-p)
- (cj/--ai-term-capture-state win)
- (setq cj/--ai-term-last-was-bury t)
- (bury-buffer (window-buffer win))
- (when (and (window-live-p win)
- (cj/--ai-term-buffer-p (window-buffer win)))
- (with-selected-window win
- (switch-to-buffer
- (or (cj/--ai-term-most-recent-non-agent-buffer)
- (other-buffer (window-buffer win) t))))))
- ;; Multi-window: collapse the agent split outright by deleting its
- ;; window, so the working buffer (e.g. todo.org) reclaims the space.
- ;; F9 is a pure show/hide toggle of THE agent split -- it must never
- ;; surface a different agent. `quit-restore-window' can't guarantee
- ;; that here: switching among several agents reuses the one slot via
- ;; `set-window-buffer' (see `cj/--ai-term-reuse-existing-agent'),
- ;; which leaves the window's `quit-restore' parameter pointing at the
- ;; FIRST agent shown. Once it's stale, `quit-restore-window' falls
- ;; back to `switch-to-prev-buffer' and surfaces another agent instead
- ;; of removing the window -- exactly the "F9 shows another agent"
- ;; bug. `delete-window' is unconditional and slot-history-independent.
- ;; Capture geometry first so the next toggle-on splits at the same
- ;; size (the user's chosen split width is preserved across the toggle).
- (t
- (cj/--ai-term-capture-state win)
- (setq cj/--ai-term-last-was-bury nil)
- (if (and (window-live-p win)
- (> (length (window-list (window-frame win) 'never)) 1))
- (delete-window win)
- ;; Degenerate fallback (window became sole between dispatch and
- ;; here): swap to a non-agent buffer rather than leave the agent up.
- (when (window-live-p win)
- (with-selected-window win
- (switch-to-buffer
- (or (cj/--ai-term-most-recent-non-agent-buffer)
- (other-buffer (window-buffer win) t))))))))
- nil)
+ (cj/--ai-term-toggle-off win))
(`(redisplay-recent . ,buf)
(display-buffer buf)
(unless arg
@@ -881,10 +945,7 @@ when BUFFER isn't an AI-term buffer."
(buffer-local-value 'default-directory buffer)))
(let ((win (get-buffer-window buffer)))
(when (window-live-p win)
- (with-selected-window win
- (switch-to-buffer
- (or (cj/--ai-term-most-recent-non-agent-buffer)
- (other-buffer buffer t))))))
+ (cj/--ai-term-swap-to-working-buffer win)))
(let ((kill-buffer-query-functions nil))
(kill-buffer buffer))))
@@ -910,7 +971,7 @@ buffers; nil when none are alive."
Targets the current agent buffer, the sole live agent, or prompts when
several are alive (see `cj/--ai-term-close-target'). Asks for
confirmation first -- this kills the running agent process, which can
-interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
+interrupt work in progress. Bound to M-<f9>."
(interactive)
(let ((buffer (cj/--ai-term-close-target)))
(unless buffer
@@ -921,10 +982,42 @@ interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
(cj/--ai-term-close-buffer buffer)
(message "Closed agent %s." name)))))
+;; ------------------------- Step to the next agent ----------------------------
+
+(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."
+ (interactive)
+ (let* ((buffers (sort (cj/--ai-term-agent-buffers)
+ (lambda (a b)
+ (string< (buffer-name a) (buffer-name b)))))
+ (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)
-(keymap-global-set "C-S-<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
@@ -935,15 +1028,15 @@ interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
(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)
- (keymap-set ghostel-mode-map "C-S-<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>" "M-<f9>" "C-S-<f9>"))
+ (dolist (key '("<f9>" "C-<f9>" "s-<f9>" "M-<f9>"))
(add-to-list 'ghostel-keymap-exceptions key))
(ghostel--rebuild-semi-char-keymap))
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 13c74ca16..2ff535668 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -454,53 +454,55 @@ Handles formats: 20260203T090000Z, 20260203T090000, 20260203."
(defalias 'calendar-sync--parse-recurrence-id #'calendar-sync--parse-ics-datetime
"Parse RECURRENCE-ID value. See `calendar-sync--parse-ics-datetime'.")
+(defun calendar-sync--parse-exception-event (event-str)
+ "Parse a RECURRENCE-ID override EVENT-STR into an exception plist, or nil.
+Returns nil when EVENT-STR carries no RECURRENCE-ID, or its recurrence-id /
+start time fail to parse. The plist holds :recurrence-id (localized),
+:recurrence-id-raw, :start, :end, :summary, :description, :location."
+ (let ((recurrence-id (calendar-sync--get-recurrence-id event-str)))
+ (when recurrence-id
+ (let* ((recurrence-id-line (calendar-sync--get-recurrence-id-line event-str))
+ (recurrence-id-tzid (calendar-sync--extract-tzid recurrence-id-line))
+ (recurrence-id-is-utc (string-suffix-p "Z" recurrence-id))
+ (recurrence-id-parsed (calendar-sync--parse-recurrence-id recurrence-id))
+ ;; Parse the new times from the exception
+ (dtstart (calendar-sync--get-property event-str "DTSTART"))
+ (dtend (calendar-sync--get-property event-str "DTEND"))
+ (dtstart-line (calendar-sync--get-property-line event-str "DTSTART"))
+ (dtend-line (calendar-sync--get-property-line event-str "DTEND"))
+ (start-tzid (calendar-sync--extract-tzid dtstart-line))
+ (end-tzid (calendar-sync--extract-tzid dtend-line))
+ (start-parsed (calendar-sync--parse-timestamp dtstart start-tzid))
+ (end-parsed (and dtend (calendar-sync--parse-timestamp dtend end-tzid)))
+ (summary (calendar-sync--clean-text
+ (calendar-sync--get-property event-str "SUMMARY")))
+ (description (calendar-sync--clean-text
+ (calendar-sync--get-property event-str "DESCRIPTION")))
+ (location (calendar-sync--clean-text
+ (calendar-sync--get-property event-str "LOCATION"))))
+ (when (and recurrence-id-parsed start-parsed)
+ (list :recurrence-id (calendar-sync--localize-parsed-datetime
+ recurrence-id-parsed recurrence-id-is-utc recurrence-id-tzid)
+ :recurrence-id-raw recurrence-id
+ :start start-parsed
+ :end end-parsed
+ :summary summary
+ :description description
+ :location location))))))
+
(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."
(let ((exceptions (make-hash-table :test 'equal)))
(when (and ics-content (stringp ics-content))
- (let ((events (calendar-sync--split-events ics-content)))
- (dolist (event-str events)
- (let ((recurrence-id (calendar-sync--get-recurrence-id event-str))
- (uid (calendar-sync--get-property event-str "UID")))
- (when (and recurrence-id uid)
- ;; Parse the exception event
- (let* ((recurrence-id-line (calendar-sync--get-recurrence-id-line event-str))
- (recurrence-id-tzid (calendar-sync--extract-tzid recurrence-id-line))
- (recurrence-id-is-utc (and recurrence-id
- (string-suffix-p "Z" recurrence-id)))
- (recurrence-id-parsed (calendar-sync--parse-recurrence-id recurrence-id))
- ;; Parse the new times from the exception
- (dtstart (calendar-sync--get-property event-str "DTSTART"))
- (dtend (calendar-sync--get-property event-str "DTEND"))
- (dtstart-line (calendar-sync--get-property-line event-str "DTSTART"))
- (dtend-line (calendar-sync--get-property-line event-str "DTEND"))
- (start-tzid (calendar-sync--extract-tzid dtstart-line))
- (end-tzid (calendar-sync--extract-tzid dtend-line))
- (start-parsed (calendar-sync--parse-timestamp dtstart start-tzid))
- (end-parsed (and dtend (calendar-sync--parse-timestamp dtend end-tzid)))
- (summary (calendar-sync--clean-text
- (calendar-sync--get-property event-str "SUMMARY")))
- (description (calendar-sync--clean-text
- (calendar-sync--get-property event-str "DESCRIPTION")))
- (location (calendar-sync--clean-text
- (calendar-sync--get-property event-str "LOCATION"))))
- (when (and recurrence-id-parsed start-parsed)
- (let ((local-recurrence-id
- (calendar-sync--localize-parsed-datetime
- recurrence-id-parsed recurrence-id-is-utc recurrence-id-tzid)))
- (let ((exception-plist
- (list :recurrence-id local-recurrence-id
- :recurrence-id-raw recurrence-id
- :start start-parsed
- :end end-parsed
- :summary summary
- :description description
- :location location)))
- ;; Add to hash table
- (let ((existing (gethash uid exceptions)))
- (puthash uid (cons exception-plist existing) exceptions)))))))))))
+ (dolist (event-str (calendar-sync--split-events ics-content))
+ (let ((uid (calendar-sync--get-property event-str "UID"))
+ (exception-plist (calendar-sync--parse-exception-event event-str)))
+ (when (and uid exception-plist)
+ (puthash uid
+ (cons exception-plist (gethash uid exceptions))
+ exceptions)))))
exceptions))
(defun calendar-sync--occurrence-matches-exception-p (occurrence exception)
diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el
index a17bf8c91..6d5963515 100644
--- a/modules/calibredb-epub-config.el
+++ b/modules/calibredb-epub-config.el
@@ -241,6 +241,29 @@ layout passes -- each pass narrows the body width but not the natural width."
"Return the preferred EPUB text column count for WINDOW."
(cj/nov--text-width (cj/nov--natural-window-width window)))
+(defun cj/nov--rerender-preserving-position ()
+ "Re-render the nov document, restoring point's relative position.
+Capture point as a fraction of the buffer, re-render, then move point to the
+same fraction of the re-rendered buffer so the reading position is kept
+approximately."
+ (let ((frac (when (> (point-max) (point-min))
+ (/ (float (- (point) (point-min)))
+ (- (point-max) (point-min))))))
+ (nov-render-document)
+ (when frac
+ (goto-char (+ (point-min)
+ (round (* frac (- (point-max) (point-min)))))))))
+
+(defun cj/nov--center-in-window (win total width)
+ "Center a WIDTH-column text block in WIN, given its TOTAL natural width.
+Set equal left/right display margins and push the fringes to the window edge."
+ ;; floor: never let the margins squeeze the text area below WIDTH.
+ (let ((margin (max 0 (/ (- total width) 2))))
+ (set-window-margins win margin margin))
+ ;; Push the fringes out to the window's edge; otherwise they sit between the
+ ;; margin and the text and show as thin vertical lines beside it.
+ (set-window-fringes win nil nil t))
+
(defun cj/nov-update-layout (&optional _frame)
"Size the EPUB text column for this buffer and center it in its window.
`nov-text-width' is set so nov's `shr' fills the text to roughly 80% of the
@@ -256,20 +279,9 @@ command."
(width (cj/nov--text-width total)))
(unless (eql nov-text-width width)
(setq-local nov-text-width width)
- (let ((frac (when (> (point-max) (point-min))
- (/ (float (- (point) (point-min)))
- (- (point-max) (point-min))))))
- (nov-render-document)
- (when frac
- (goto-char (+ (point-min)
- (round (* frac (- (point-max) (point-min)))))))))
+ (cj/nov--rerender-preserving-position))
(when win
- ;; floor: never let the margins squeeze the text area below WIDTH.
- (let ((margin (max 0 (/ (- total width) 2))))
- (set-window-margins win margin margin))
- ;; Push the fringes out to the window's edge; otherwise they sit between
- ;; the margin and the text and show as thin vertical lines beside it.
- (set-window-fringes win nil nil t)))))
+ (cj/nov--center-in-window win total width)))))
(defun cj/--nov-adjust-margin (delta)
"Add DELTA to `cj/nov-margin-percent' (clamped 0..25), re-lay-out, and report.
@@ -293,11 +305,12 @@ A positive DELTA narrows the text column; a negative DELTA widens it."
(defun cj/nov-apply-preferences ()
"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
- (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.0 :foreground "#E8DCC0")
- (face-remap-add-relative 'default :family "Merriweather" :height 180 :foreground "#E8DCC0")
- (face-remap-add-relative 'fixed-pitch :height 180 :foreground "#E8DCC0")
+ ;; 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))
;; Enable visual-line-mode for proper text wrapping
(visual-line-mode 1)
;; Set fill-column as a fallback
diff --git a/modules/cj-window-geometry-lib.el b/modules/cj-window-geometry-lib.el
index 4c0662124..4484a1d15 100644
--- a/modules/cj-window-geometry-lib.el
+++ b/modules/cj-window-geometry-lib.el
@@ -42,21 +42,34 @@ fails to span the full height."
((not spans-full-height) (if (= top root-top) 'above 'below))
(t (or default 'right)))))
-(defun cj/window-body-size (window direction)
- "Return WINDOW's body size on the axis matching DIRECTION.
+(defun cj/window-replay-size (window direction)
+ "Return WINDOW's size to capture for geometry replay, on DIRECTION's axis.
Returns body-width (columns) when DIRECTION is right or left.
-Returns body-height (lines) when DIRECTION is below or above.
-
-Body size, not total size, is the right thing to capture for
-geometry replay: total-width includes the right-side divider when
-the window has a right sibling but excludes it at the frame edge,
-so a captured rightmost window replayed into a middle position
-would leave the body 1 col short. Body size is divider-
-independent and matches what the user actually sees."
+Returns total-height (lines) when DIRECTION is below or above.
+
+The axis choice is deliberately asymmetric, for two different reasons:
+
+- Width: body-width, not total-width. Total-width includes the right-side
+ divider when the window has a right sibling but excludes it at the frame
+ edge, so a captured rightmost window replayed into a middle position would
+ leave the body 1 col short. Body-width is divider-independent and matches
+ what the user sees.
+
+- Height: total-height, not body-height. Every window carries exactly one
+ mode line regardless of position, so total-height has no analog of the
+ divider-position problem -- it is position-independent. Body-height does
+ NOT work here: it subtracts the mode line's *pixel* height, which differs
+ between an active (full-height) and an inactive (theme-shrunk) mode line.
+ Capturing body-height while the window is active and replaying it while the
+ window is displayed inactive then re-measuring active drifts the value down
+ by ~1 line per toggle whenever the inactive mode line is shorter than a text
+ line (e.g. a theme that sets `mode-line-inactive' to a sub-line height).
+ Total-height is identical active or inactive, so the capture/replay
+ round-trip is a fixed point."
(if (memq direction '(right left))
(window-body-width window)
- (window-body-height window)))
+ (window-total-height window)))
(defun cj/cardinal-to-edge-direction (direction)
"Map cardinal DIRECTION to its `display-buffer-in-direction' edge variant.
diff --git a/modules/cj-window-toggle-lib.el b/modules/cj-window-toggle-lib.el
index ba91f5a40..175a1d958 100644
--- a/modules/cj-window-toggle-lib.el
+++ b/modules/cj-window-toggle-lib.el
@@ -44,7 +44,7 @@ No-op when WINDOW is nil or not live."
(if (or (null allowed) (memq dir allowed))
(progn
(set direction-var dir)
- (set size-var (cj/window-body-size window dir)))
+ (set size-var (cj/window-replay-size window dir)))
(set direction-var default-direction)
(set size-var nil)))))
@@ -59,10 +59,12 @@ DEFAULT-SIZE when the stored values are nil. The cardinal direction
is mapped to its frame-edge variant via
`cj/cardinal-to-edge-direction' so the new buffer always lands at
the same frame edge regardless of the selected window. An integer
-size is wrapped in a `(body-columns . N)' / `(body-lines . N)' cons
-so `display-buffer-in-direction' sets the body explicitly,
-divider-independent. A float size passes through as a fraction of
-the new window's parent.
+size is wrapped per axis: a width size as a `(body-columns . N)'
+cons (divider-independent body width), a height size as a plain
+integer total-line count. Height uses total rather than body so the
+capture/replay round-trip is immune to the mode line's pixel height
+(see `cj/window-replay-size'). A float size passes through as a
+fraction of the new window's parent.
Caller-supplied ALIST entries for direction, window-width, or
window-height are stripped before delegating to
@@ -74,15 +76,15 @@ placement; the remaining alist entries are passed through."
(edge-direction (or (cj/cardinal-to-edge-direction direction)
(cj/cardinal-to-edge-direction default-direction)))
(size (or stored-size default-size))
- (size-key (if (memq direction '(right left))
- 'window-width
- 'window-height))
- (body-tag (if (memq direction '(right left))
- 'body-columns
- 'body-lines))
- (size-value (if (integerp size)
- (cons body-tag size)
- size))
+ (width-axis (memq direction '(right left)))
+ (size-key (if width-axis 'window-width 'window-height))
+ ;; A width integer is a body-column count (divider-independent); a
+ ;; height integer is a plain total-line count (mode-line-pixel-
+ ;; independent -- see `cj/window-replay-size'). Floats pass through.
+ (size-value (cond
+ ((not (integerp size)) size)
+ (width-axis (cons 'body-columns size))
+ (t size)))
(filtered (cl-remove-if
(lambda (cell)
(memq (car-safe cell)
diff --git a/modules/custom-case.el b/modules/custom-case.el
index d30ebf942..876226958 100644
--- a/modules/custom-case.el
+++ b/modules/custom-case.el
@@ -49,6 +49,18 @@
(downcase-region (car bounds) (cdr bounds))
(user-error "No symbol at point")))))
+(defun cj/--title-case-capitalize-word-p (word is-first prev-word-end word-skip chars-skip-reset)
+ "Return non-nil when WORD at point should be capitalized in title case.
+Point is at WORD's first character. WORD is capitalized when it is the first
+word (IS-FIRST), is not a minor skip word (in WORD-SKIP), or immediately follows
+a skip-reset character (one of CHARS-SKIP-RESET: : ! ?), reached by skipping
+blanks back to PREV-WORD-END."
+ (or is-first
+ (not (member word word-skip))
+ (save-excursion
+ (and (not (zerop (skip-chars-backward "[:blank:]" prev-word-end)))
+ (memq (char-before (point)) chars-skip-reset)))))
+
(defun cj/title-case-region ()
"Capitalize the region in title case format.
Title case is a capitalization convention where major words are capitalized,
@@ -58,67 +70,53 @@ considered major words. Short (i.e., three letters or fewer) conjunctions,
short prepositions, and all articles are considered minor words."
(interactive)
(let ((beg nil)
- (end nil)
- (prev-word-end nil)
- ;; Allow capitals for skip characters after this, so:
- ;; Warning: An Example
- ;; Capitalizes the `An'.
- (chars-skip-reset '(?: ?! ??))
- ;; Don't capitalize characters directly after these. e.g.
- ;; "Foo-bar" or "Foo\bar" or "Foo's".
-
- (chars-separator '(?\\ ?- ?' ?.))
-
- (word-chars "[:alnum:]")
- (word-skip
- (list "a" "an" "and" "as" "at" "but" "by"
- "for" "if" "in" "is" "nor" "of"
- "on" "or" "so" "the" "to" "yet"))
- (is-first t))
- (cond
- ((region-active-p)
- (setq beg (region-beginning))
- (setq end (region-end)))
- (t
- (setq beg (line-beginning-position))
- (setq end (line-end-position))))
- (save-excursion
- ;; work on uppercased text (e.g., headlines) by downcasing first
- (downcase-region beg end)
- (goto-char beg)
-
- (while (< (point) end)
- (setq prev-word-end (point))
- (skip-chars-forward (concat "^" word-chars) end)
- (when (>= (point) end) ;; no word chars remaining
- (goto-char end))
- (let ((word-end
- (save-excursion
- (skip-chars-forward word-chars end)
- (point))))
-
- (unless (or (>= (point) end)
- (memq (char-before (point)) chars-separator))
- (let* ((c-orig (char-to-string (char-after (point))))
- (c-up (capitalize c-orig)))
- (unless (string-equal c-orig c-up)
- (let ((word (buffer-substring-no-properties (point) word-end)))
- (when
- (or
- ;; Always allow capitalization.
- is-first
- ;; If it's not a skip word, allow.
- (not (member word word-skip))
- ;; Check the beginning of the previous word doesn't reset first.
- (save-excursion
- (and
- (not (zerop
- (skip-chars-backward "[:blank:]" prev-word-end)))
- (memq (char-before (point)) chars-skip-reset))))
- (delete-region (point) (1+ (point)))
- (insert c-up))))))
- (goto-char word-end)
- (setq is-first nil))))))
+ (end nil)
+ (prev-word-end nil)
+ ;; Allow capitals for skip characters after this, so:
+ ;; Warning: An Example
+ ;; Capitalizes the `An'.
+ (chars-skip-reset '(?: ?! ??))
+ ;; Don't capitalize characters directly after these. e.g.
+ ;; "Foo-bar" or "Foo\bar" or "Foo's".
+ (chars-separator '(?\\ ?- ?' ?.))
+ (word-chars "[:alnum:]")
+ (word-skip
+ (list "a" "an" "and" "as" "at" "but" "by"
+ "for" "if" "in" "is" "nor" "of"
+ "on" "or" "so" "the" "to" "yet"))
+ (is-first t))
+ (cond
+ ((region-active-p)
+ (setq beg (region-beginning))
+ (setq end (region-end)))
+ (t
+ (setq beg (line-beginning-position))
+ (setq end (line-end-position))))
+ (save-excursion
+ ;; work on uppercased text (e.g., headlines) by downcasing first
+ (downcase-region beg end)
+ (goto-char beg)
+ (while (< (point) end)
+ (setq prev-word-end (point))
+ (skip-chars-forward (concat "^" word-chars) end)
+ (when (>= (point) end) ;; no word chars remaining
+ (goto-char end))
+ (let ((word-end
+ (save-excursion
+ (skip-chars-forward word-chars end)
+ (point))))
+ (unless (or (>= (point) end)
+ (memq (char-before (point)) chars-separator))
+ (let* ((c-orig (char-to-string (char-after (point))))
+ (c-up (capitalize c-orig)))
+ (unless (string-equal c-orig c-up)
+ (let ((word (buffer-substring-no-properties (point) word-end)))
+ (when (cj/--title-case-capitalize-word-p
+ word is-first prev-word-end word-skip chars-skip-reset)
+ (delete-region (point) (1+ (point)))
+ (insert c-up))))))
+ (goto-char word-end)
+ (setq is-first nil))))))
;; replace the capitalize-region keybinding to call title-case
(keymap-global-set "<remap> <capitalize-region>" #'cj/title-case-region)
diff --git a/modules/custom-comments.el b/modules/custom-comments.el
index cae911061..231a03860 100644
--- a/modules/custom-comments.el
+++ b/modules/custom-comments.el
@@ -109,6 +109,14 @@ inputs. Used by all divider / border helpers below."
decoration-char))
decoration-char)
+(defun cj/--comment-emit-prefix (cmt-start)
+ "Insert CMT-START -- doubled when it is a lone semicolon -- and a trailing space.
+A bare =;= is doubled to =;;= so the line reads as an Emacs-Lisp comment. This
+is the line-opening prologue shared by the divider and inline-border emitters."
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " "))
+
;; ----------------------------- Inline Border ---------------------------------
(defun cj/--comment-inline-border (cmt-start cmt-end decoration-char text length)
@@ -138,10 +146,7 @@ LENGTH is the total width of the line."
(error "Length %d is too small for text '%s' (need at least %d more chars)"
length text (- min-space space-on-each-side)))
;; Generate the line
- (insert cmt-start)
- (when (equal cmt-start ";")
- (insert cmt-start))
- (insert " ")
+ (cj/--comment-emit-prefix cmt-start)
;; Left decoration
(dotimes (_ space-on-each-side)
(insert decoration-char))
@@ -181,48 +186,11 @@ Uses the lesser of `fill-column\\=' or 80 for line length."
CMT-START and CMT-END are the comment syntax strings.
DECORATION-CHAR is the character to use for the divider lines.
TEXT is the comment text.
-LENGTH is the total width of each line."
- (cj/--validate-decoration-char decoration-char)
- (let* ((current-column-pos (current-column))
- (min-length (+ current-column-pos
- (length cmt-start)
- (if (equal cmt-start ";") 1 0) ; doubled semicolon
- 1 ; space after comment-start
- 3 ; minimum decoration chars
- (if (string-empty-p cmt-end) 0 (1+ (length cmt-end))))))
- (when (< length min-length)
- (error "Length %d is too small to generate comment (minimum %d)" length min-length))
- (let* ((available-width (- length current-column-pos
- (length cmt-start)
- (if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))))
- (line (make-string available-width (string-to-char decoration-char))))
- ;; Top line
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " ")
- (insert line)
- (when (not (string-empty-p cmt-end))
- (insert " " cmt-end))
- (newline)
-
- ;; Text line
- (dotimes (_ current-column-pos) (insert " "))
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " " text)
- (when (not (string-empty-p cmt-end))
- (insert " " cmt-end))
- (newline)
+LENGTH is the total width of each line.
- ;; Bottom line
- (dotimes (_ current-column-pos) (insert " "))
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " ")
- (insert line)
- (when (not (string-empty-p cmt-end))
- (insert " " cmt-end))
- (newline))))
+A simple divider is a padded divider with no padding before the text, so it
+delegates to `cj/--comment-padded-divider' with PADDING 0."
+ (cj/--comment-padded-divider cmt-start cmt-end decoration-char text length 0))
(defun cj/comment-simple-divider ()
"Insert a simple divider comment banner.
@@ -276,9 +244,7 @@ PADDING is the number of spaces before the text."
(if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))))
(line (make-string available-width (string-to-char decoration-char))))
;; Top line
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " ")
+ (cj/--comment-emit-prefix cmt-start)
(insert line)
(when (not (string-empty-p cmt-end))
(insert " " cmt-end))
@@ -286,9 +252,7 @@ PADDING is the number of spaces before the text."
;; Text line with padding
(dotimes (_ current-column-pos) (insert " "))
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " ")
+ (cj/--comment-emit-prefix cmt-start)
(dotimes (_ padding) (insert " "))
(insert text)
(when (not (string-empty-p cmt-end))
@@ -297,9 +261,7 @@ PADDING is the number of spaces before the text."
;; Bottom line
(dotimes (_ current-column-pos) (insert " "))
- (insert cmt-start)
- (when (equal cmt-start ";") (insert cmt-start))
- (insert " ")
+ (cj/--comment-emit-prefix cmt-start)
(insert line)
(when (not (string-empty-p cmt-end))
(insert " " cmt-end))
@@ -335,12 +297,12 @@ Prompts for decoration character, text, padding, and length option."
;; -------------------------------- Comment Box --------------------------------
-(defun cj/--comment-box (cmt-start cmt-end decoration-char text length)
- "Internal implementation: Generate a 3-line box comment with centered text.
-CMT-START and CMT-END are the comment syntax strings.
-DECORATION-CHAR is the character to use for borders.
-TEXT is the comment text (centered).
-LENGTH is the total width of each line."
+(defun cj/--comment-box-emit (cmt-start cmt-end decoration-char text length heavy)
+ "Emit a box comment with centered TEXT; the border/text/border skeleton.
+CMT-START and CMT-END are the comment syntax strings. DECORATION-CHAR borders
+the box. LENGTH is the total width of each line. When HEAVY is non-nil, an
+interior blank-bordered line is added above and below the text line (the only
+difference between the plain box and the heavy box)."
(cj/--validate-decoration-char decoration-char)
(let* ((current-column-pos (current-column))
(comment-char (if (equal cmt-start ";") ";;" cmt-start))
@@ -363,11 +325,22 @@ LENGTH is the total width of each line."
(padding-each-side (max 1 (/ (- text-available text-length) 2)))
(right-padding (if (= (% (- text-available text-length) 2) 0)
padding-each-side
- (1+ padding-each-side))))
+ (1+ padding-each-side)))
+ ;; Interior side-border line: repeats the comment prefix and suffix so
+ ;; the blank rows stay valid comments in line-comment languages (elisp,
+ ;; Python). Only inserted for the heavy box.
+ (empty-line (concat comment-char " " decoration-char
+ (make-string (- available-width 2) ?\s)
+ decoration-char " " comment-end-char)))
;; Top border
(insert comment-char " " border-line " " comment-end-char)
(newline)
+ (when heavy
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert empty-line)
+ (newline))
+
;; Centered text line with side borders
(dotimes (_ current-column-pos) (insert " "))
(insert comment-char " " decoration-char " ")
@@ -377,11 +350,24 @@ LENGTH is the total width of each line."
(insert " " decoration-char " " comment-end-char)
(newline)
+ (when heavy
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert empty-line)
+ (newline))
+
;; Bottom border
(dotimes (_ current-column-pos) (insert " "))
(insert comment-char " " border-line " " comment-end-char)
(newline))))
+(defun cj/--comment-box (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate a 3-line box comment with centered text.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for borders.
+TEXT is the comment text (centered).
+LENGTH is the total width of each line."
+ (cj/--comment-box-emit cmt-start cmt-end decoration-char text length nil))
+
(defun cj/comment-box ()
"Insert a 3-line comment box with centered text.
Prompts for decoration character, text, and uses `fill-column' for length."
@@ -404,62 +390,11 @@ Prompts for decoration character, text, and uses `fill-column' for length."
CMT-START and CMT-END are the comment syntax strings.
DECORATION-CHAR is the character to use for borders.
TEXT is the comment text (centered).
-LENGTH is the total width of each line."
- (cj/--validate-decoration-char decoration-char)
- (let* ((current-column-pos (current-column))
- (comment-char (if (equal cmt-start ";") ";;" cmt-start))
- (comment-end-char (if (string-empty-p cmt-end) comment-char cmt-end))
- (min-length (+ current-column-pos
- (length comment-char)
- 2 ; spaces around content
- (length comment-end-char)
- 6))) ; 3 border chars + text space + 3 border chars
- (when (< length min-length)
- (error "Length %d is too small to generate comment (minimum %d)" length min-length))
- (let* ((available-width (- length current-column-pos
- (length comment-char)
- (length comment-end-char)
- 2)) ; spaces around content
- (border-line (make-string available-width (string-to-char decoration-char)))
- (text-available (- available-width 4)) ; 2 side decorations, 2 spaces
- (text-length (length text))
- (padding-each-side (max 1 (/ (- text-available text-length) 2)))
- (right-padding (if (= (% (- text-available text-length) 2) 0)
- padding-each-side
- (1+ padding-each-side)))
- ;; Interior side-border lines repeat the comment prefix and suffix so
- ;; the empty/text rows stay valid comments in line-comment languages
- ;; (elisp, Python). Previously they began with a bare decoration char.
- (empty-line (concat comment-char " " decoration-char
- (make-string (- available-width 2) ?\s)
- decoration-char " " comment-end-char)))
- ;; Top border
- (insert comment-char " " border-line " " comment-end-char)
- (newline)
-
- ;; Empty line with side borders
- (dotimes (_ current-column-pos) (insert " "))
- (insert empty-line)
- (newline)
-
- ;; Centered text line
- (dotimes (_ current-column-pos) (insert " "))
- (insert comment-char " " decoration-char " ")
- (dotimes (_ padding-each-side) (insert " "))
- (insert text)
- (dotimes (_ right-padding) (insert " "))
- (insert " " decoration-char " " comment-end-char)
- (newline)
-
- ;; Empty line with side borders
- (dotimes (_ current-column-pos) (insert " "))
- (insert empty-line)
- (newline)
+LENGTH is the total width of each line.
- ;; Bottom border
- (dotimes (_ current-column-pos) (insert " "))
- (insert comment-char " " border-line " " comment-end-char)
- (newline))))
+A heavy box is a box with an interior blank-bordered line above and below the
+text, so it delegates to `cj/--comment-box-emit' with HEAVY non-nil."
+ (cj/--comment-box-emit cmt-start cmt-end decoration-char text length t))
(defun cj/comment-heavy-box ()
"Insert a heavy box comment with blank lines around centered text.
diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el
index b7e33337e..c86f3d1bf 100644
--- a/modules/dirvish-config.el
+++ b/modules/dirvish-config.el
@@ -119,6 +119,35 @@ through a `../' or absolute path. Pure helper."
(and (not (string-empty-p name))
(not (string-match-p "/" name))))
+(defun cj/--playlist-resolve-target ()
+ "Prompt for a playlist name and return the .m3u path to write under `music-dir'.
+Re-prompt until the name is a safe bare filename (no `/'). When the target
+already exists, ask whether to overwrite, cancel, or rename: overwrite returns
+the path, cancel signals a `user-error', rename re-prompts. Interactive
+prompting only -- the caller does the file write."
+ (let ((base-name nil)
+ (playlist-path nil)
+ (done nil))
+ (while (not done)
+ (setq base-name (cj/--playlist-sanitize-name
+ (read-string "Playlist name (without .m3u): ")))
+ (cond
+ ((not (cj/--playlist-name-safe-p base-name))
+ (message "Playlist name must be a bare filename, without '/'."))
+ (t
+ (setq playlist-path (expand-file-name (concat base-name ".m3u") music-dir))
+ (if (not (file-exists-p playlist-path))
+ (setq done t)
+ (let ((choice (read-char-choice
+ (format "Playlist '%s' exists. [o]verwrite, [c]ancel, [r]ename? "
+ (file-name-nondirectory playlist-path))
+ '(?o ?c ?r))))
+ (cl-case choice
+ (?o (setq done t))
+ (?c (user-error "Cancelled playlist creation"))
+ (?r (setq done nil))))))))
+ playlist-path))
+
(defun cj/dired-create-playlist-from-marked ()
"Create an .m3u playlist file from marked files in Dired (or Dirvish).
Filters for audio files, prompts for the playlist name, and saves the resulting
@@ -131,27 +160,7 @@ Filters for audio files, prompts for the playlist name, and saves the resulting
(if (zerop count)
(user-error "No audio files marked (extensions: %s)"
(string-join cj/audio-file-extensions ", "))
- (let ((base-name nil)
- (playlist-path nil)
- (done nil))
- (while (not done)
- (setq base-name (cj/--playlist-sanitize-name
- (read-string "Playlist name (without .m3u): ")))
- (cond
- ((not (cj/--playlist-name-safe-p base-name))
- (message "Playlist name must be a bare filename, without '/'."))
- (t
- (setq playlist-path (expand-file-name (concat base-name ".m3u") music-dir))
- (if (not (file-exists-p playlist-path))
- (setq done t)
- (let ((choice (read-char-choice
- (format "Playlist '%s' exists. [o]verwrite, [c]ancel, [r]ename? "
- (file-name-nondirectory playlist-path))
- '(?o ?c ?r))))
- (cl-case choice
- (?o (setq done t))
- (?c (user-error "Cancelled playlist creation"))
- (?r (setq done nil))))))))
+ (let ((playlist-path (cj/--playlist-resolve-target)))
(with-temp-file playlist-path
(dolist (af audio-files)
(insert af "\n")))
diff --git a/modules/dwim-shell-config.el b/modules/dwim-shell-config.el
index ad17ea913..230a8532c 100644
--- a/modules/dwim-shell-config.el
+++ b/modules/dwim-shell-config.el
@@ -210,6 +210,41 @@ The timestamp is interpolated here with `format-time-string' so it can't sit
dead inside the shell's single quotes the way a literal =$(date ...)= did."
(format "cp -p '<<f>>' '<<f>>.%s.bak'" (format-time-string "%Y%m%d_%H%M%S")))
+(defun cj/dwim-shell--tar-gzip-command (single-p)
+ "Return the tar-gzip command template.
+SINGLE-P non-nil names the archive after the lone file (=<fne>.tar.gz=);
+otherwise a shared =archive.tar.gz= over all marked files."
+ (if single-p
+ "tar czf '<<fne>>.tar.gz' '<<f>>'"
+ "tar czf '<<archive.tar.gz(u)>>' '<<*>>'"))
+
+(defun cj/dwim-shell--text-to-speech-command (system voice)
+ "Return the text-to-speech command template for SYSTEM using VOICE.
+SYSTEM is a `system-type' symbol: `darwin' synthesizes with `say' and VOICE;
+any other system uses `espeak' (VOICE unused)."
+ (if (eq system 'darwin)
+ (format "say -v %s -o '<<fne>>.aiff' -f '<<f>>'" voice)
+ "espeak -f '<<f>>' -w '<<fne>>.wav'"))
+
+(defun cj/dwim-shell--video-trim-command (trim-type start end)
+ "Return the ffmpeg video-trim command template for TRIM-TYPE.
+TRIM-TYPE is \"Beginning\", \"End\", or \"Both\". START trims that many
+seconds off the front, END off the back (each ignored for the side it does
+not apply to). Signals a `user-error' when a used second count is negative."
+ (pcase trim-type
+ ("Beginning"
+ (when (< start 0) (user-error "Seconds must be non-negative"))
+ (format "ffmpeg -i '<<f>>' -y -ss %d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
+ start))
+ ("End"
+ (when (< end 0) (user-error "Seconds must be non-negative"))
+ (format "ffmpeg -sseof -%d -i '<<f>>' -y -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
+ end))
+ ("Both"
+ (when (or (< start 0) (< end 0)) (user-error "Seconds must be non-negative"))
+ (format "ffmpeg -i '<<f>>' -y -ss %d -sseof -%d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
+ start end))))
+
;; ----------------------------- Dwim Shell Command ----------------------------
(use-package dwim-shell-command
@@ -357,9 +392,8 @@ Otherwise, unzip it to an appropriately named subdirectory "
"Tar gzip all marked files into archive.tar.gz."
(interactive)
(dwim-shell-command-on-marked-files
- "Tar gzip" (if (eq 1 (seq-length (dwim-shell-command--files)))
- "tar czf '<<fne>>.tar.gz' '<<f>>'"
- "tar czf '<<archive.tar.gz(u)>>' '<<*>>'")
+ "Tar gzip" (cj/dwim-shell--tar-gzip-command
+ (eq 1 (seq-length (dwim-shell-command--files))))
:utils "tar"))
(defun cj/dwim-shell-commands-epub-to-org ()
@@ -448,34 +482,18 @@ process list, and the file is removed only after the spawned process exits."
"Trim video with options for beginning, end, or both."
(interactive)
(let* ((trim-type (completing-read "Trim from: "
- '("Beginning" "End" "Both")
- nil t))
- (command (pcase trim-type
- ("Beginning"
- (let ((seconds (read-number "Seconds to trim from beginning: " 5)))
- (when (< seconds 0)
- (user-error "Seconds must be non-negative"))
- (format "ffmpeg -i '<<f>>' -y -ss %d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
- seconds)))
- ("End"
- (let ((seconds (read-number "Seconds to trim from end: " 5)))
- (when (< seconds 0)
- (user-error "Seconds must be non-negative"))
- (format "ffmpeg -sseof -%d -i '<<f>>' -y -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
- seconds)))
- ("Both"
- (let ((start (read-number "Seconds to trim from beginning: " 5))
- (end (read-number "Seconds to trim from end: " 5)))
- (when (or (< start 0) (< end 0))
- (user-error "Seconds must be non-negative"))
- (format "ffmpeg -i '<<f>>' -y -ss %d -sseof -%d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
- start end))))))
- (dwim-shell-command-on-marked-files
+ '("Beginning" "End" "Both")
+ nil t))
+ (start (if (member trim-type '("Beginning" "Both"))
+ (read-number "Seconds to trim from beginning: " 5) 0))
+ (end (if (member trim-type '("End" "Both"))
+ (read-number "Seconds to trim from end: " 5) 0))
+ (command (cj/dwim-shell--video-trim-command trim-type start end)))
+ (dwim-shell-command-on-marked-files
(format "Trim video (%s)" trim-type)
command
:silent-success t
:utils "ffmpeg")))
-
(defun cj/dwim-shell-commands-drop-audio-from-video ()
"Drop audio from all marked videos."
(interactive)
@@ -694,9 +712,7 @@ all marked files rather than once per file."
"en")))
(dwim-shell-command-on-marked-files
"Text to speech"
- (if (eq system-type 'darwin)
- (format "say -v %s -o '<<fne>>.aiff' -f '<<f>>'" voice)
- "espeak -f '<<f>>' -w '<<fne>>.wav'")
+ (cj/dwim-shell--text-to-speech-command system-type voice)
:utils (if (eq system-type 'darwin) "say" "espeak"))))
(defun cj/dwim-shell-commands-remove-empty-directories ()
diff --git a/modules/erc-config.el b/modules/erc-config.el
index c0fa9c325..c89e46bb3 100644
--- a/modules/erc-config.el
+++ b/modules/erc-config.el
@@ -338,16 +338,15 @@ NICK is the sender and MESSAGE is the message text."
:after erc
:hook (erc-mode . erc-nicks-mode))
-;; ------------------------------ ERC Yank To Gist -----------------------------
-;; automatically create a Gist if pasting more than 5 lines
-;; this module requires https://github.com/defunkt/gist
-;; via ruby: 'gem install gist' via the aur: yay -S gist
-
-(use-package erc-yank
- :after erc
- :bind
- (:map erc-mode-map
- ("C-y" . erc-yank)))
+;; -------------------------------- ERC Yank ----------------------------------
+;; The erc-yank package was dropped 2026-06-20: a paste over 5 lines became a
+;; PUBLIC gist (it called `gist -P', the clipboard paste flag, with no
+;; `--private'), behind only a single y-or-n-p and with no guard if the `gist'
+;; binary was absent -- a one-keystroke path to publishing whatever sat on the
+;; system clipboard. No replacement binding is needed: erc-mode-map defines no
+;; C-y of its own, so with erc-yank gone C-y falls through to the ordinary
+;; global `yank' and a paste stays local. Gist a large snippet by hand when
+;; that's actually wanted.
(provide 'erc-config)
;;; erc-config.el ends here
diff --git a/modules/gcmh-config.el b/modules/gcmh-config.el
new file mode 100644
index 000000000..beceb1a01
--- /dev/null
+++ b/modules/gcmh-config.el
@@ -0,0 +1,30 @@
+;;; gcmh-config.el --- Garbage collection strategy via gcmh -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; gcmh (the Garbage Collector Magic Hack) owns `gc-cons-threshold' for the
+;; session. It keeps the threshold very high while you are active so GC never
+;; pauses mid-edit, then drops it and collects on idle, when a pause is
+;; invisible. This replaces the old hand-rolled scheme -- a stock-800KB restore
+;; in early-init.el plus a minibuffer setup/exit bump -- which pinned GC at
+;; 800000 (Emacs's bare-editor default), far too low for a config this size and
+;; the cause of frequent GC pauses during completion, agenda builds, and LSP/AI
+;; activity.
+;;
+;; Kept in its own module, not system-defaults.el: that module is pre-loaded by
+;; the comp-errors test harness, which has no package system, so an `:ensure'
+;; package there errors at load time. early-init.el bumps the threshold to
+;; `most-positive-fixnum' for startup and deliberately does not restore it;
+;; `gcmh-mode' takes ownership from here on.
+
+;;; Code:
+
+(use-package gcmh
+ :ensure t
+ :demand t
+ :config
+ (setq gcmh-idle-delay 'auto ; scale the idle GC delay to GC cost
+ gcmh-high-cons-threshold (* 1 1024 1024 1024)) ; 1 GB during activity
+ (gcmh-mode 1))
+
+(provide 'gcmh-config)
+;;; gcmh-config.el ends here
diff --git a/modules/system-defaults.el b/modules/system-defaults.el
index 0062a82cf..6d9c811a6 100644
--- a/modules/system-defaults.el
+++ b/modules/system-defaults.el
@@ -212,18 +212,13 @@ appears only once per session."
(setq custom-safe-themes t) ;; treat all themes as safe (stop asking)
(setq server-client-instructions nil) ;; I already know what to do when done with the frame
-;; ------------------ Reduce Garbage Collections In Minibuffer -----------------
-
-(defun cj/minibuffer-setup-hook ()
- "Hook to prevent garbage collection while user's in minibuffer."
- (setq gc-cons-threshold most-positive-fixnum))
-
-(defun cj/minibuffer-exit-hook ()
- "Hook to trigger garbage collection when exiting minibuffer."
- (setq gc-cons-threshold 800000))
-
-(add-hook 'minibuffer-setup-hook #'cj/minibuffer-setup-hook)
-(add-hook 'minibuffer-exit-hook #'cj/minibuffer-exit-hook)
+;; ----------------------------- Garbage Collection ----------------------------
+;; GC is managed by gcmh in modules/gcmh-config.el: it keeps gc-cons-threshold
+;; high during activity and collects on idle, replacing the old stock-800KB
+;; scheme (an early-init restore plus a minibuffer setup/exit bump). gcmh lives
+;; in its own module rather than here because system-defaults.el is pre-loaded
+;; by the comp-errors test harness, which has no package system -- an `:ensure'
+;; package loaded here would error at load time and break those tests.
;; ----------------------------- Bookmark Settings -----------------------------
diff --git a/modules/system-utils.el b/modules/system-utils.el
index 7cf958674..254a2f502 100644
--- a/modules/system-utils.el
+++ b/modules/system-utils.el
@@ -102,7 +102,7 @@ detached from Emacs."
(interactive)
(save-some-buffers)
(kill-emacs))
-(keymap-global-set "C-<f10>" #'cj/server-shutdown)
+(keymap-global-set "C-x C" #'cj/server-shutdown)
;;; ---------------------------- History Persistence ----------------------------
diff --git a/modules/term-config.el b/modules/term-config.el
index 33f54d75a..c1c28911d 100644
--- a/modules/term-config.el
+++ b/modules/term-config.el
@@ -246,12 +246,13 @@ run its own project-named tmux session instead of a bare, auto-named one.
;; rebuild is what actually lets the key through to `ghostel-mode-map' / the
;; global map. C-; and F12 are the prefix + toggle; the modified arrows are
;; windmove (S-arrows, focus) and buffer-move (C-M-arrows, swap), which the
- ;; ai-term workflow expects to work from inside an agent buffer. F8, F10 and
- ;; C-F10 are global bindings (org agenda, music-playlist toggle, server
- ;; shutdown) that reach Emacs by falling through to the global map once the
- ;; semi-char map stops forwarding them.
+ ;; 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.)
(with-eval-after-load 'ghostel
- (dolist (key '("C-;" "<f8>" "<f12>" "<f10>" "C-<f10>"
+ (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>"))
(add-to-list 'ghostel-keymap-exceptions key))
@@ -313,8 +314,9 @@ Symbol: right, left, or below. `above' is never stored. nil means use the
default `below' for F12's traditional bottom split.")
(defvar cj/--term-toggle-last-size nil
- "Last user-chosen body size for the F12 terminal display.
-Positive integer: body-cols (right/left) or body-lines (below/above).
+ "Last user-chosen size for the F12 terminal display.
+Positive integer: body-cols (right/left) or total-lines (below/above) -- see
+`cj/window-replay-size' for why the vertical axis uses total, not body.
nil means fall back to `cj/term-toggle-window-height' as a fraction.")
(defun cj/--term-toggle-buffer-p (buffer)