This configuration file serves as the single source of truth for all Emacs customizations. It includes notes on setup and usage. It is continuously updated to reflect my current usage patterns.
1. Emacs
- emacs.d/malb.org at master · malb/emacs.d · GitHub
- Former Emacs maintainer John Wiegley’s config
- GitHub - munen/emacs.d: My emacs configuration documented in literate program…
- Literate Emacs Configuration - Look here for org-export customizations
- GitHub - joseph8th/literatemacs: My literate Emacs config
- Robert Zaremba “Scale it”, Emacs cheatsheet
- “ - str ’(kf-htmlegalize-region skeleton-point (point)) ”
- Quick Help: Emacs as a Text Productivity Platform : emacs
- My Emacs Configuration
- GitHub - karthink/.emacs.d: My personal emacs settings
- From Doom to Vanilla Emacs - blog.dornea.nu
- GitHub - dorneanu/dotemacs: Collection of Emacs configurations
- Two Years of Emacs Solo: 35 Modules, Zero External Packages, and a Full Refac…
- nixos-config/dotfiles/doom/config.org at master · joshuablais/nixos-config · …
- Common config anti-patterns - Configuration - Doom Emacs Discourse
- GitHub - tecosaur/emacs-config: My configuration for Doom Emacs. Mirror of ht…
- dot-doom/doom.org at master · zzamboni/dot-doom · GitHub
- Emacs Configs
Greetings! I currently use Doom Emacs on Windows Subsystem for Linux (WSL). Instructions for installing Emacs on WSL2. Contact me if something is unclear or should you have any questions. Run the following on a new computer to clone my configuration,
# Runtime dependencies of my Emacs config.
sudo apt install mu4e aspell fd-find ripgrep curl hunspell sdcv transmission-daemon
mkdir ~/.ssh
# Download Keepass and SSH keys from Cloud backup.
# Copy over SSH private keys through Windows Explorer =\\wsl$=
# in Windows Explorer address bar.
chmod 600 ~/.ssh/github
chmod 600 ~/.ssh/gitlab
# See heading "Github Tutorial" if you are unfamiliar with git.
# https://github.com/doomemacs/doomemacs?tab=readme-ov-file#install
git clone git@gitlab.com:bhw-foss/.doom.d.git ~/.config/doom
# I will need to setup GPG. Unlock KeePass database for GPG private key.
# Project Maria is encrypted with git-remote-gcrypt.
git clone git@gitlab.com:bhw-foss/project-maria.git ~/project-maria
# Copy over project-jerome manually
scp -r root@oci-a1-flex:/project-jerome ~/
If you are setting up on a new computer then you will have install and configure everything under B. Important in Linux Program Directory. See doom emacs private config
SPC i y to autocomplete paste SPC h r r SPC h k SPC s E SPC h d f SPC h d p c Spacemacs refugee, Winum config, doom-localleader-key, org mode bold highlighting
1.1. Emacs Initialization
Doom Emacs configuration below is ordered in the same order they appear in file:///home/ben/.config/doom/init.el, as that is the order they are loaded in. Individual packages installed by myself are found at the end of the most relevant Doom modules.
Emacs reads the -*- ... -*- file-variable cookie from the first line of a
file and nowhere else, so org’s :comments link tangle link – which lands
above everything else in the file – silently displaced it and loaded every
tangled file with dynamic binding. Each tangle target therefore opens with a
one-line :comments no block holding just its cookie; the body keeps its link
comments, so detangling still works.
;;; init.el -*- lexical-binding: t; -*-
(setf evil-respect-visual-line-mode t)
(doom!
:completion
(corfu +orderless)
vertico
:ui
doom
dashboard
hl-todo
(modeline +light)
(popup +defaults)
(window-select +numbers)
workspaces
:editor
(evil +everywhere)
fold
(format +onsave)
lispy
multiple-cursors
snippets
(whitespace +guess +trim)
word-wrap
:emacs
(dired +dirvish)
electric
eww
tramp
undo
vc
:term
ghostel
:checkers
syntax
(spell +hunspell)
:tools
biblio
(eval +overlay)
lookup
(lsp +eglot)
(magit +forge)
pdf
tree-sitter
:lang
common-lisp
data
emacs-lisp
json
(latex +cdlatex)
ledger
markdown
(org +noter +pandoc +contacts +dragndrop)
plantuml
graphviz
(python +lsp +tree-sitter +uv)
sh
:email
(mu4e +org +mbsync)
:app
(rss +org +youtube)
:config
(default +bindings +smartparens))
1.2. Package Configuration
The cookie sits in its own :comments no block; see Emacs Initialization.
;; -*- no-byte-compile: t; -*-
;;; $DOOMDIR/packages.el
(package! evil-snipe :disable t)
(package! consult-mu
:recipe (:host github :repo "armindarvish/consult-mu" :files (:defaults "extras/*.el")))
(package! ement)
(package! org-node)
;; MELPA's recipe points at https://repo.or.cz/org-contacts.git, which is
;; frequently unreachable and stalls `doom upgrade' with a git fetch timeout.
;; The emacsmirror GitHub mirror carries identical history.
(package! org-contacts
:recipe (:host github :repo "emacsmirror/org-contacts" :branch "master"))
(package! anki-editor)
(package! org-transclusion)
;; Doom's :lang org +noter pins ab9628e (2026-04), which predates
;; https://github.com/org-noter/org-noter/pull/129 -- the fix that makes
;; `org-noter-always-create-frame' nil actually mean "never create a frame".
;; Pinning past it lets us drop the local `make-frame' shadowing advice.
(package! org-noter :pin "feae91ca4ee6fdc7fd2489fa751dd96bc1f3ddf2")
(package! transmission)
(package! lexic)
(package! casual)
(package! biome)
(package! webfeeder)
(package! org-re-reveal)
(package! claude-code-ide
:recipe (:host github :repo "manzaltu/claude-code-ide.el"))
(package! sly-quicklisp :disable t)
;; ghostel/evil-ghostel are declared and pinned by `:term ghostel' itself; the
;; local :pin overrides that used to hold them at 0.49.0 are gone, so the module
;; owns the version. Bump the doom+ submodule to move it.
;; Rebuild pdf-tools' epdfinfo server with CPU-native optimizations
;; (-O2 -march=native), per https://github.com/vedang/pdf-tools/discussions/351.
;; Doom otherwise builds epdfinfo lazily via `pdf-tools-install' without these
;; flags. This :post-build runs after every straight build/update of pdf-tools:
;; it compiles the C server and installs it into the version-stamped build dir
;; where `pdf-info-epdfinfo-program' looks, so the runtime build is skipped.
;; `-D' skips dependency install (already satisfied). Failures are non-fatal
;; (call-process doesn't signal), falling back to the lazy runtime build; see
;; the *pdf-tools-epdfinfo-build* buffer for output.
(package! pdf-tools
:recipe (:post-build
(when (eq system-type 'gnu/linux)
;; :post-build runs in the repo dir; build dir is resolved via
;; straight so we never hardcode the "build-NN.N" version segment.
(let ((default-directory (expand-file-name "server/" default-directory)))
(call-process
"sh" nil (get-buffer-create "*pdf-tools-epdfinfo-build*") t "-c"
(format "CFLAGS='-O2 -march=native' ./autobuild -i %s -D"
(shell-quote-argument
(directory-file-name (straight--build-dir "pdf-tools")))))))))
;; (package! mu4e-send-delay
;; :recipe (:host github :repo "krisbalintona/mu4e-send-delay"))
;; (package! greader)
;; (package! literate-calc-mode)
;; (package! listen)
;; (package! org-fragtog)
(package! org-edna)
1.3. Emacs Configuration
Miscellaneous configuration for features built in to Emacs which do not belong clearly to a well-defined package.
The cookie sits in its own :comments no block; see Emacs Initialization.
;;; $DOOMDIR/config.el -*- lexical-binding: t; -*-
;;---------------------------------------------------------------------------
;; PATH for daemon sessions: Emacs runs as a systemd --user daemon, which does
;; NOT source ~/.profile or ~/.bashrc, so user bin dirs (e.g. ~/.local/bin,
;; where the `claude' CLI lives) are absent from PATH/`exec-path'. Prepend them
;; here so `executable-find' and spawned subprocesses (claude-code-ide, etc.)
;; can locate these executables.
(dolist (dir (list (expand-file-name "~/.local/bin")
(expand-file-name "~/.config/emacs/bin")))
(when (and (file-directory-p dir)
(not (member dir exec-path)))
(add-to-list 'exec-path dir)
(setenv "PATH" (concat dir path-separator (getenv "PATH")))))
(defconst +project-maria-dir+ (expand-file-name "~/project-maria/")
"Absolute path to the project-maria directory.")
(defconst +project-jerome-dir+ (expand-file-name "~/project-jerome/")
"Absolute path to the project-jerome directory.")
;; might have to install xprop and wmctrl. See fullscreen.sh
(add-to-list 'initial-frame-alist '(fullscreen . fullboth))
(add-to-list 'default-frame-alist '(fullscreen . fullboth))
(defun get-authinfo-password (machine login)
(if-let* ((credential (car (auth-source-search :max 1
:host machine
:user login
:require '(:secret))))
(secret (plist-get credential :secret)))
(if (functionp secret) (funcall secret) secret)
(message "No password found for %s@%s" login machine)))
(load! "private-packages/personal-info.el")
(defun bhw/always-save-advice (oldfn &optional arg)
"Overwrite `yes-or-no-p' in OLDFN.
The new temporary function will return non-nil, when the message
wants to save modified buffers, without querying the user.
Otherwise the original behaviour is preserves, and ARG is passed
on to OLDFN."
(cl-letf* ((real-yes-or-no-p (symbol-function 'yes-or-no-p))
((symbol-function 'yes-or-no-p)
(lambda (msg)
(or (string= msg "Modified buffers exist; exit anyway? ")
(funcall real-yes-or-no-p msg)))))
(funcall oldfn arg)))
(advice-add #'save-buffers-kill-emacs :around #'bhw/always-save-advice)
(setf org-directory +project-maria-dir+
auto-save-interval 300
auto-revert-interval 30
auto-save-timeout nil
browse-url-generic-program "/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"
browse-url-browser-function #'browse-url-generic
initial-major-mode 'org-mode)
1.3.1. Config Test Harness
doom/reload wraps the re-evaluation of config.el in with-demoted-errors
and prints “Config successfully reloaded!” unconditionally, so a mid-file
error silently leaves the session half-configured. This harness records when
a reload starts, then runs the ERT suite tangled from Config Test Suite on
doom-after-reload-hook — which sits inside doom/reload’s unwind-protect,
so the tests run even when config.el blew up partway: exactly the moment
they are most needed. The stamp variables pair with the Config Load Sentinel
block at the very end of this configuration. This section sits near the top
of config.el so the harness survives partial loads.
;;---------------------------------------------------------------------------
;; Test harness: record reload boundaries; run the ERT suite after every
;; `doom/reload'. The tests live in tests/config-tests.el, (re)loaded on
;; demand so a broken test file can never break startup or reload.
(defvar bhw/config-load-stamp nil
"Time config.el last finished loading, set by its final form.
nil, or a value older than `bhw/config-reload-start-time', means the last
(re)load died partway through: `doom/reload' demotes config errors and
prints \"Config successfully reloaded!\" regardless.")
(defvar bhw/config-reload-start-time nil
"Time the last `doom/reload' began, per `doom-before-reload-hook'.")
(defvar bhw/config-tests-last-report nil
"(TIME . SUMMARY) from the most recent harness ERT run.
Read by the doom-reload-status MCP tool to report test outcomes.")
(defun bhw/config--lexical-probe ()
"Return a closure over a lexical variable, for the config test suite.
Calling the returned function signals (void-variable bhw--lexical-probe)
when config.el was loaded with dynamic binding, which is what happens if
anything ever displaces the `lexical-binding' cookie from line 1 again."
(let ((bhw--lexical-probe t))
(lambda () bhw--lexical-probe)))
(defun bhw/config-tests-record-reload-h ()
"Record when a reload starts, for `bhw/config-test--load-stamp'."
(setq bhw/config-reload-start-time (float-time)))
(defun bhw/config-tests-run-h ()
"Load and run the config test suite. Never signals: reload must survive."
(condition-case-unless-debug err
(let ((file (expand-file-name "tests/config-tests.el" doom-user-dir)))
(if (not (file-exists-p file))
(message "bhw/config-tests: missing %s" file)
(load file nil 'nomessage)
(bhw/config-tests--run-and-report)))
(error (message "bhw/config-tests: harness error: %S" err))))
(add-hook 'doom-before-reload-hook #'bhw/config-tests-record-reload-h)
(add-hook 'doom-after-reload-hook #'bhw/config-tests-run-h 99)
1.3.2. Completion Module Config
C-; to embark act. w afterwards to copy file path.
Emacs Carnival Feb 2026 wrap-up: Completion :: Sacha Chua
;;---------------------------------------------------------------------------
(after! vertico
(map! :map vertico-map
"C-d" #'vertico-quick-jump))
(setq completion-ignore-case t
read-file-name-completion-ignore-case t
read-buffer-completion-ignore-case t)
(map! :n ";" #'consult-line
:leader "SPC" #'consult-buffer)
Consult Config
;;---------------------------------------------------------------------------
(after! consult
(require 'org-node)
;; `abbreviate-file-name' recompiles `directory-abbrev-alist' regexps on
;; every call: 4.0s over the ~164k candidates a common bigram like "do"
;; produces. All Find candidates are rooted at $HOME, so a literal prefix
;; swap is equivalent and ~100x cheaper.
(defconst bhw/consult--home-prefix (expand-file-name "~/")
"Expanded $HOME prefix that `bhw/consult--abbrev-home' swaps for ~/.")
(defun bhw/consult--abbrev-home (path)
"Replace a literal `bhw/consult--home-prefix' in PATH with ~/."
(if (string-prefix-p bhw/consult--home-prefix path)
(concat "~/" (substring path (length bhw/consult--home-prefix)))
path))
(defun bhw/consult--unabbrev-home-input (input)
"Expand a leading ~/ in every whitespace-separated token of INPUT.
`bhw/consult--abbrev-home' abbreviates only the *display* string; `fd'
still filters the unabbreviated absolute paths it emits, so a token typed
as ~/dir/file (e.g. copied back out of the candidate list) could never
match. Rewrite it to the expanded prefix before the builder compiles the
input into `--and' patterns."
(replace-regexp-in-string
(rx (or bos (any " \t")) (group "~/"))
bhw/consult--home-prefix input t t 1))
(defvar bhw/consult-source-filesystem
`(:name "Find"
:narrow ?f
:category file
:face consult-file
:history file-name-history
:action ,#'consult--file-action
:state ,#'consult--file-preview
;; Async: `fd' filters by the typed input, so nothing is materialized
;; until you type >= 3 chars (`:min-input' below — Doom lowers the
;; global `consult-async-min-input' to 2, and common bigrams match
;; six-figure candidate sets against `--full-path'). `--max-results'
;; bounds the worst-case flood consult must ingest on the main thread.
;; `--no-ignore-vcs' because `fd' otherwise honors every enclosing
;; .gitignore: ~/project-maria/.gitignore lists *.pdf/*.tex/*.bbl, which
;; made ~28 real files in that tree unreachable from `consult-buffer'
;; while `consult-recent-file' (plain `recentf-list') still found them.
;; It does NOT disable the global ignore file (~/.config/fd/ignore), so
;; Trash and /mnt/{c,e} stay excluded -- only `-I' would drop those too.
;; The `let*' bakes our flags into the builder closure WITHOUT mutating
;; the global `consult-fd-args' (so plain `M-x consult-fd' is
;; unaffected). `--search-path' yields absolute paths;
;; `bhw/consult--abbrev-home' restores the ~/ display and
;; `bhw/consult--unabbrev-home-input' undoes it on the way back in.
:async
,(let* ((consult-fd-args
'((if (executable-find "fdfind" 'remote) "fdfind" "fd")
"--full-path --color=never --hidden --follow --type f --exclude .git --no-ignore-vcs --max-results=5000"))
(builder (consult--fd-make-builder (list (expand-file-name "~")))))
(consult--process-collection
(lambda (input)
(funcall builder (bhw/consult--unabbrev-home-input input)))
:transform (consult--async-map #'bhw/consult--abbrev-home)
:highlight t
:file-handler t
:min-input 3))))
(defvar bhw/consult-source-project-maria
`(:name "Project-Maria rg"
:narrow ?m
:category consult-grep
:history consult--grep-history
:state ,#'consult--grep-state
:action ,(lambda (c) (consult--jump (consult--grep-position c)))
:async
;; Content grep over prose: a 2-char input like "th" returns ~71k
;; match lines (12MB) for the main thread to parse and propertize.
;; Require 4 chars and cap matches per file; the `let*' shadows
;; `consult-ripgrep-args' only for this builder (args are read at
;; builder-creation time), leaving `M-x consult-ripgrep' untouched.
,(let* ((consult-ripgrep-args (concat consult-ripgrep-args " --max-count=10"))
(builder (consult--ripgrep-make-builder (list +project-maria-dir+))))
(consult--process-collection builder
:transform (consult--grep-format builder)
:file-handler t
:min-input 4))))
;; `mapatoms' over the full obarray (~128k symbols) building ~13k command-name
;; strings costs ~85ms and triggers GC on EVERY consult-buffer open. The
;; command set is effectively static between `load's, so cache the list and
;; invalidate it only when new code is loaded (rare after startup) via a
;; *named* `after-load-functions' hook (named so `doom/reload' re-adds it
;; idempotently instead of leaking duplicate anonymous closures).
(defvar bhw/consult--emacs-commands-cache nil
"Cached list of command-name strings for `bhw/consult-source-emacs-commands'.")
(defun bhw/consult--emacs-commands ()
"Return all command names as strings, rebuilding the cache on demand."
(or bhw/consult--emacs-commands-cache
(setq bhw/consult--emacs-commands-cache
(let (cmds)
(mapatoms (lambda (s) (when (commandp s) (push (symbol-name s) cmds))))
cmds))))
(defun bhw/consult--invalidate-emacs-commands-cache (&rest _)
"Drop the cached Emacs-commands list so it is rebuilt after new code loads."
(setq bhw/consult--emacs-commands-cache nil))
(add-hook 'after-load-functions #'bhw/consult--invalidate-emacs-commands-cache)
(defvar bhw/consult-source-emacs-commands
(list :name "Emacs Commands"
:narrow ?e
:category 'command
:items #'bhw/consult--emacs-commands
:action (lambda (cmd-str) (command-execute (intern-soft cmd-str))))
"Source for Emacs commands, used in `consult-buffer'.")
(defvar org-node-history nil
"History list for org-node selections in `consult-buffer'. The `:history 'org-node-history` property in `bhw/consult-source-org-node` tells `consult` to record selections into the variable `org-node-history`. But that variable was never declared with `defvar`, so it was void. When `consult--multi` tried to call `(add-to-history org-node-history \"Ecclesiastes 3:1\")`, Emacs raised a `void-variable` error.")
(defvar bhw/consult-source-org-node
(list :name "Org Node"
:narrow ?n
:category 'org-node
:face 'consult-file
:history 'org-node-history
:items (lambda ()
(org-node-cache-ensure)
(hash-table-keys org-node--candidate<>entry))
:action (lambda (cand)
(require 'org-id)
(let ((node (gethash cand org-node--candidate<>entry)))
(if node
(org-node-goto node)
;; Fallback if the user somehow selects a non-existent item
;; though :new usually handles creation
(org-node-create cand (org-id-new)))))
:new (lambda (cand)
(require 'org-id)
;; Handle blank input for creation specifically
(let ((title (if (string-blank-p cand)
(funcall org-node-blank-input-title-generator)
cand)))
(org-node-create title (org-id-new)))))
"Source for `org-node' to be used in `consult-buffer'.")
;; Replacing `consult-buffer-sources' wholesale (below) dropped the recentf
;; source, which is why `consult-recent-file' could find files `SPC SPC'
;; could not. Put it back: it is the only source that reaches files outside
;; $HOME, and it costs ~200 candidates built with the *fast* abbreviator.
;; Upstream narrows it with ?f, already claimed by the Find source, so use ?r
;; on a shallow copy -- `plist-put' then writes to our cons cells, leaving the
;; global `consult-source-recent-file' untouched for other consult commands.
(defvar bhw/consult-source-recent-file
(plist-put (copy-sequence consult-source-recent-file) :narrow ?r)
"Copy of `consult-source-recent-file' narrowed with ?r instead of ?f.")
(setf consult-buffer-sources '(consult-source-buffer
bhw/consult-source-recent-file
bhw/consult-source-org-node
bhw/consult-source-project-maria
bhw/consult-source-filesystem
bhw/consult-source-emacs-commands))
;; Perl split routes plain input to the async source only, leaving sync sources unfiltered.
;; Named advice so re-`load'ing this config replaces it instead of stacking
;; anonymous duplicates (same rationale as the named hook above).
(defun bhw/consult-buffer-no-async-split-a (orig &rest args)
"Call ORIG (`consult-buffer') with `consult-async-split-style' disabled."
(let ((consult-async-split-style 'none))
(apply orig args)))
(advice-add 'consult-buffer :around #'bhw/consult-buffer-no-async-split-a))
1.3.3. User Interface Config
;;---------------------------------------------------------------------------
(dotimes (i 10)
(define-key doom-leader-map (number-to-string i)
(intern (format "winum-select-window-%s"
(if (= i 0) "0-or-10" (number-to-string i))))))
(setf doom-font (font-spec :family "IosevkaTermSlab Nerd Font Mono" :size 26.0 :weight 'medium)
doom-theme 'doom-gruvbox
doom-gruvbox-brighter-comments t
doom-gruvbox-dark-variant "soft"
doom-modeline-height 14
display-line-numbers-type nil
confirm-kill-emacs nil
confirm-kill-processes nil)
;; Doom core remaps `delete-frame' to `doom/delete-frame-with-prompt', which
;; asks "Close frame?" before closing. Undo that remap so closing a frame
;; (SPC q f, C-x 5 0) is immediate. The remap is installed in doom-emacs.el,
;; already loaded by the time config.el runs, so this needs no `after!' guard
;; (the old `after! doom-ui' wrapper never fired -- doom-ui isn't a feature).
(global-set-key [remap delete-frame] nil)
(add-to-list 'default-frame-alist '(inhibit-double-buffering . t))
(after! nerd-icons
(setf nerd-icons-font-family "IosevkaTermSlab Nerd Font Mono"))
(dolist (fn '(+dashboard-widget-banner
+dashboard-widget-loaded
+dashboard-widget-footer))
(remove-hook '+dashboard-functions fn))
1.3.4. Smooth Scrolling Config
https://old.reddit.com/r/emacs/comments/1vz1uc4/configuring_emacs_scrolling_for_better/
Native pixel-level smooth scrolling (Emacs 29+, no external package), tuned to
prioritize trackpad responsiveness over smoothness while keeping both where
they don’t conflict. Under WSLg the trackpad arrives as discrete X11 wheel
events, so the defaults animate every notch — momentum (kinetic drift after
lift), per-notch interpolation, and progressive acceleration compound into
perceptible lag. Disabling those gives 1:1 finger tracking; genuine
pixel-precision deltas are still scrolled smoothly, and interpolate-page keeps
keyboard scroll-up/down-command (C-v / M-v, evil C-f / C-b) smooth
since that path never touches the trackpad.
;;---------------------------------------------------------------------------
;; Pixel-level smooth scrolling for the mouse wheel / trackpad (Emacs 29+,
;; native — no external package).
;;
;; Tuned to prioritize trackpad responsiveness over smoothness (keeping both
;; where they don't conflict). Interpolation/momentum add perceptible lag under
;; WSLg, where trackpad gestures arrive as discrete X11 wheel events:
;; - no momentum -> scrolling stops the instant you lift; no kinetic drift.
;; - no mouse interpolation -> each wheel notch scrolls immediately instead of
;; animating over `...-interpolation-total-time'; genuine pixel-precision
;; deltas (if the device sends them) are still scrolled smoothly.
;; - no progressive speed -> scroll distance stays proportional, no overshoot.
;; `interpolate-page' stays on: it smooths keyboard `scroll-up/down-command'
;; (C-v / M-v, evil C-f / C-b), which never touches the trackpad path.
(pixel-scroll-precision-mode 1)
(setf pixel-scroll-precision-use-momentum nil
pixel-scroll-precision-interpolate-mice nil
pixel-scroll-precision-interpolate-page t
mouse-wheel-progressive-speed nil)
;; Keyboard-driven scrolling: keep point off the window edge and scroll one
;; line at a time instead of recentering with a jump. (Doom already sets
;; `scroll-preserve-screen-position' and `auto-window-vscroll'.)
(setf scroll-margin 3
scroll-conservatively 101)
1.3.5. Editor Config
<- Searching - File Content <- Windows 10 Reference Manual
For a guide, see Github - Noctuid Evil Guide
joaotavora/yasnippet#998 expand with no trigger key
;;---------------------------------------------------------------------------
(+global-word-wrap-mode +1)
(dolist (pair '(([?\(] . [?\[]) ([?\[] . [?\(])
([?\)] . [?\]]) ([?\]] . [?\)])))
(define-key key-translation-map (car pair) (cdr pair)))
;; Bind `s` in both motion and normal state maps for avy. Normal state
;; needs an explicit binding so modes with `s` as a prefix (e.g. dirvish)
;; can still override it locally.
(dolist (m (list evil-motion-state-map evil-normal-state-map))
(define-key m (kbd "s") 'avy-goto-word-or-subword-1))
(define-key evil-normal-state-map (kbd "q") 'kill-current-buffer)
(map! :n
"," nil ; See `doom-localleader-key' below
"C-j" #'+evil/insert-newline-below
"C-k" #'+evil/insert-newline-above)
(setf doom-localleader-key ","
avy-all-windows t
+word-wrap-extra-indent nil)
;; `doom-after-modules-init-hook' installs general's `define-key' advice
;; (modules/doom/compat/+keybinds.el:53), which is what lets `map!' turn an
;; already-bound key into a prefix instead of signalling "Key sequence a e
;; starts with non-prefix key a". `doom/reload' re-runs that hook and then
;; unconditionally removes the advice again in its unwind cleanup
;; (lisp/lib/config.el:99-102), so every reload leaves the session without it.
;; Put it back afterwards -- the cleanup runs before `doom-after-reload-hook'.
(add-hook 'doom-after-reload-hook #'general-auto-unbind-keys)
Savehist Config (unused)
For more session management solutions. spacemacs - High CPU/memory usage and abnormally large savehist file? - Emacs…
If you recreate this situation, and the file is really massive, opening it in Emacs to check might not be a great idea. I would use grep to find out the offset for each variable, and just look for any really big jumps:
$ grep -E -b -o '^\(setq [^ ]+' ~/.config/emacs/.local/cache/savehist
(Or whatever your savehist-file path is – the standard filename is ~/.emacs.d/history but I presume ~/.emacs.d/.cache/savehist is what Spacemacs configures.)
(setf history-length 25
savehist-save-minibuffer-history nil
savehist-autosave-interval nil
kill-ring-max 200
savehist-mode nil)
(delq 'mark-ring savehist-additional-variables)
(delq 'global-mark-ring savehist-additional-variables)
(delq 'search-ring savehist-additional-variables)
(delq 'regexp-search-ring savehist-additional-variables)
(delq 'extended-command-history savehist-additional-variables)
(delq 'kill-ring savehist-additional-variables)
(put 'bibtex-completion-cache 'history-length 10)
(push 'bibtex-completion-cache savehist-additional-variables)
(push 'helm-ff-history savehist-additional-variables)
(push 'org-clock-history savehist-additional-variables)
;; Emacs profiler shows `savehist-autosave' is very performance intensive.
(add-hook 'kill-emacs-hook #'savehist-save) ; Savehist only on exit.
1.3.6. Emacs Config
;;---------------------------------------------------------------------------
;; Re-apply Doom's gcmh tuning that its 2026-06-13 core refactor silently
;; dropped: it registers gcmh via `use-package! gcmh-mode', but the package
;; provides the feature `gcmh' (not `gcmh-mode'), so the :config form that sets
;; `gcmh-high-cons-threshold' to 64mb never runs. gcmh then keeps its 1GB
;; upstream default, letting the Lisp heap balloon until every GC is a ~1.3s
;; freeze that surfaces as GUI input lag. Keyed on the correct feature; drop
;; this once Doom fixes the `use-package!' declaration upstream.
;;
;; Deviations from Doom's defaults, tuned for long-lived (multi-day) sessions
;; where each full GC measures ~0.5-0.7s: raise the active threshold to 256mb
;; so heavy scrolling rarely trips a collection mid-motion, and use a fixed 15s
;; idle delay (instead of `auto', which fired ~5s after every pause) so the
;; unavoidable freeze lands when genuinely idle rather than between keystrokes.
;; NOTE: this only changes GC *timing/frequency*; per-GC duration scales with
;; heap size, so a periodic Emacs restart remains the real fix for the lag.
(after! gcmh
(setq gcmh-idle-delay 15
gcmh-high-cons-threshold (* 256 1024 1024))) ; 256mb
1.3.7. Dired Config
;;---------------------------------------------------------------------------
(after! dirvish
(setq dirvish-hide-details t))
(map! :after dirvish ; Doom overrides Dired with Dirvish.
:map dirvish-mode-map
:n ";" #'consult-line
:n "A" #'bhw/org-attach-visit-headline-from-dired)
;; Doom's dired module binds `s` as a prefix (ss/sS/sh → symlink ops) in
;; dirvish-mode-map's normal-state auxiliary keymap. evil-local-set-key
;; sets a buffer-local binding that has higher priority, so pressing `s`
;; in dired gives avy instead of waiting for a second key.
(defun bhw/dired-avy-wdired-keys-h ()
"Buffer-locally bind `s' to avy and `e' to wdired in Dired."
(evil-local-set-key 'normal (kbd "s") #'avy-goto-word-or-subword-1)
(evil-local-set-key 'normal (kbd "e") #'wdired-change-to-wdired-mode))
(add-hook 'dired-mode-hook #'bhw/dired-avy-wdired-keys-h)
1.3.8. EWW Config
;;---------------------------------------------------------------------------
(after! eww
(add-hook! 'eww-after-render-hook #'eww-readable)
(map! :map eww-mode-map
:n "Y" #'+org/yank-link))
1.3.9. Term Config
| Key | Effect |
|---|---|
| M-! | Shell-command. Prefix with C-u (SPC-u for spacemacs) to insert output at point |
| M-S-\ | Shell-command-on-region. Pipe region into shell command |
| M-: | Eval s-exp |
Shell Tricks That Actually Make Life Easier (And Save Your Sanity) | Larvitz …
Terminal emulation now runs on Doom’s :term ghostel module
(~/.config/emacs/sources/doom+/modules/term/ghostel/, README), so this section
holds only the deltas against it. What used to live here — a hand-ported
+ghostel/toggle / +ghostel/here pair, the two ghostel-mode hooks, the
SPC o t bindings, a popup rule, an evil-ghostel use-package! — is all
upstream:
term/ghostel/autoload.elautoloads+ghostel/toggleand+ghostel/here. Its buffer names (*doom:ghostel-popup:PROJECT<persp>*) scope by project and perspective where the local port scoped by perspective alone, and the popup window is marked dedicated. It drops thePROOTenvvar the:term vtermoriginal set; nothing in this setup’s shell rc reads it.term/ghostel/config.elsupplies bothghostel-modehooks,doom-real-buffer-modes, a direnv re-init fixup, the persp-mode rename HACK (ghostel-buffer-name-function→ nil under:ui workspaces— which is what the localghostel-set-title-functionsetq was doing through an obsolete alias), a solaire-mode face fix, andevil-ghostel, gated on:editor evil +everywhere.:config default +bindingsbindsSPC o t/SPC o Tbehind(modulep! :term ghostel), and:ui popupalready rules^\*doom:\(?:v?term\|e?shell\|ghostel\)-popup.
The +everywhere flag is deliberately off: it would reroute compilation-start,
comint, and eshell visual commands through ghostel, which is a much larger
behavioural change than a popup terminal. evil-ghostel does not depend on it
(it keys off :editor evil +everywhere, which is enabled).
Upstream ghostel has since absorbed three of the old local workarounds:
ghostel-mode sets hscroll-margin to 0 itself (ghostel.el:4954), palette
resync is wired to enable-theme-functions at load time on Emacs 29+
(ghostel.el:3524), and ghostel-kill-buffer-on-exit already defaults to t.
The vterm-era evil remaps (0, dd, , ESC) are obsolete too: evil-ghostel
remaps the evil commands — evil-delete-line → evil-ghostel-delete-line,
evil-first-non-blank → evil-ghostel-first-non-blank, and so on — clamping
operators to the shell’s input region and replaying them over the PTY, and it
routes insert-state ESC to the app while it’s on the alt screen (C-c C-r
toggles, C-c <escape> forces normal state once). Blindly sending C-a or
C-e C-u fights that machinery.
Three things still worth knowing. The module’s +ghostel/toggle called
(ghostel t), and a non-numeric arg means create a fresh instance, never
reuse — so every SPC o t spawned another …-popup<N> buffer while the
toggle-off test only ever looked for the unnumbered name. Fixed upstream in
2fd592bfe ((ghostel t) → (ghostel)), which arrives with the next doom+
submodule bump. Its C-u “recreate” branch carries a second, still-unfixed
bug — it reads an unbound buffer-name variable
where it means ghostel-buffer-name — so prefix-arg toggling signals
void-variable; plain SPC o t is unaffected. And ghostel’s 16
ghostel-color-* ANSI faces inherit from ansi-color-* with ghostel-default
inheriting default, so the terminal palette should be re-checked after any
theme switch away from ef-owl/modus-themes.
;;---------------------------------------------------------------------------
;; Set before ghostel loads, not in `after!': `ghostel.el' calls
;; `ghostel--load-module' at the top level (ghostel.el:919) and that resolves
;; the .so through `ghostel-module-directory'. Setting it afterwards leaves the
;; load-time lookup pointing at the package's own resource root, which has no
;; module, and defers loading to the first `M-x ghostel'. Keeping the .so under
;; `doom-data-dir' also means `doom sync'/upgrades can't delete it out from
;; under a running Emacs and force a re-download (cf. vterm's elpa-dir caveat).
(setq ghostel-module-directory (expand-file-name "ghostel/" doom-data-dir)
ghostel-module-auto-install 'download) ; prebuilt binary, no zig toolchain
(after! ghostel
;; Default is `auto' (query only between the OSC 133 command start/finish
;; markers); never query at all. ~ confirm-kill-processes nil.
;; `ghostel-max-scrollback' defaults to 5MB (~ vterm's 5000 lines); left alone.
(setq ghostel-query-before-killing nil)
;; Same as `:ui popup's ghostel rule but a shorter window. Popup plists don't
;; merge with each other -- a partial rule falls back to `+popup-defaults'
;; (:quit t :select ignore :ttl 5), not to the module's rule -- so the other
;; keys are restated verbatim. `set-popup-rule!' pushes, and config.el runs
;; after module config, so this entry is matched first.
(set-popup-rule! "^\\*doom:ghostel-popup"
:vslot -5 :size 0.25 :select t :modeline nil :quit nil :ttl nil)
;; Bind into the *input-mode* map, not `ghostel-mode-map'. Under the default
;; `semi-char' input mode `ghostel-mode-map' is not in `current-active-maps'
;; at all -- `ghostel-semi-char-mode-map' shadows it wholesale (it binds every
;; printable key plus C-<key> straight to `ghostel--send-event') -- so
;; major-mode-map bindings are silently inert. Line/emacs/char modes swap in
;; their own maps and are unaffected by these two.
(map! :map ghostel-semi-char-mode-map
;; ghostel binds `ghostel-send-next-key' to C-c C-q; C-q is quicker and
;; `quoted-insert' has no use in a terminal. In the semi-char map C-q
;; would otherwise be passed through to the app as literal C-q (XOFF).
"C-q" #'ghostel-send-next-key
;; Reverse-i-search from normal state. A literal key binding, so it wins
;; over evil-ghostel's [remap evil-change-whole-line] ->
;; `evil-ghostel-substitute-line': `evil-ghostel-mode-map' has no literal
;; "S", lookup falls through to this normal-state auxiliary map, and the
;; remap never gets a command to rewrite.
:n "S" (cmd! (ghostel-send-key "r" "ctrl"))))
1.3.10. Checkers Config
;;---------------------------------------------------------------------------
;; Emacs 30 hangs `ispell-completion-at-point' off every `text-mode' derivative
;; (org included) whenever this is non-nil. It completes out of
;; `ispell-complete-word-dict' or, failing that, `ispell-alternate-dictionary'
;; below -- a raw hunspell .dic, whose lines are WORD/AFFIXFLAGS. So the capf
;; offers "hello/SM" and "apple/MS" verbatim. A plain wordlist would fix it,
;; but /usr/share/dict/words is empty here (no wamerican/wbritish installed),
;; and spell-fu already handles spelling, so drop the capf entirely.
(setf text-mode-ispell-word-completion nil)
;; Still the fallback for an explicit `M-x ispell-complete-word'.
(setf ispell-alternate-dictionary "/usr/share/hunspell/en_CA.dic")
;; Org 9.7+ returns `org-lint' line positions as propertized strings, but
;; flycheck's org-lint checker still passes them straight to
;; `flycheck-error-new-at', which expects a number-or-marker. Result: every
;; save of an org file errors out. Disable until flycheck#2024 is fixed.
;;
;; NB: `flycheck-disabled-checkers' is `make-variable-buffer-local', so the bare
;; `add-to-list' this used to be only ever wrote a buffer-local value into
;; whichever buffer happened to be current when flycheck loaded -- the global
;; default stayed nil and org-lint kept running for years. It is a *synchronous*
;; generic checker: profiled at 1.2s of blocking main-thread time on hq.org
;; (455 KB), fired on `save' and 1s after every `idle-change'. Set the default
;; explicitly, and idempotently so `doom/reload' cannot grow the list.
(after! flycheck
(setq-default flycheck-disabled-checkers
(cons 'org-lint
(remq 'org-lint
(default-value 'flycheck-disabled-checkers)))))
(defun +spell/correct-previous-highlight ()
"Jump to previous error, correct it, then return to the original position in insert mode."
(interactive)
;; 1. Save the current position with a Marker
(let ((origin (point-marker)))
;; 2. Ensure marker stays at the end if we are typing at the very end of the line
(set-marker-insertion-type origin t)
(when (featurep 'spell-fu)
;; 3. Go to error and correct
(spell-fu-goto-previous-error)
(+spell/correct))
;; 4. Jump back to the marker (the original position)
(goto-char origin)
;; 5. Clean up the marker to free memory
(set-marker origin nil)
;; 6. Force Evil into Insert Mode so you can keep typing immediately
(evil-insert 1)))
(map! :g "C-s" nil
:nim "C-s" #'+spell/correct-previous-highlight)
1.3.11. Tools Config
Projectile Config
<- Emacs Navigation & Searching
Deleted project directories used to linger in the SPC p p
(projectile-switch-project) candidate list until
projectile-cleanup-known-projects was run by hand. Running cleanup on a
timer would be cheaper per prompt but cannot guarantee freshness — a project
deleted after the last sweep would still be offered. The only way to never
see a stale entry is to check at prompt time, so the right optimization is
making that check cheap, which upstream projectile already did:
projectile-auto-cleanup-known-projects prunes dead projects whenever the
candidate list is built, costing one file-readable-p stat per local
project while skipping remote (TRAMP) projects entirely.
(after! projectile
;; Prune stale (deleted) projects whenever a switch-project prompt builds
;; its candidate list, so they can never appear. Cheap: one
;; `file-readable-p' per local project; remote projects are skipped.
;; M-x projectile-cleanup-known-projects remains for manual runs.
(setq projectile-auto-cleanup-known-projects t))
Pdf-Tools Config
PDF Tools uses a C library to convert PDF (which are images) to PNG format to
store in Emacs memory. Installation: for the first time run, please make sure to
M-x pdf-tools-install
pdf-occur notes: lines matching PRCE means Perl Compatible Regular Expressions. See regex list here: PCRE Regular Expression Cheatsheet - Debuggex
;; If bash $top shows high emacs memory usage, try the following.
;; Not sure if they work tbh.
(pdf-cache-clear-data)
(garbage-collect)
;;---------------------------------------------------------------------------
;; Disable evil-collection's pdf bindings so we have full control over
;; pdf-view-mode-map. Must run before `evil-collection-init', so we use
;; Doom's `+evil-collection-disabled-list' rather than mutating
;; `evil-collection-mode-list' inside `after!' (which fires too late —
;; the `eval-after-load 'pdf-view' hook is already registered, and
;; evil-collection-pdf-setup later tries to bind `sb' under `s', which
;; we've rebound to `avy-goto-word-or-subword-1').
(add-to-list '+evil-collection-disabled-list 'pdf)
(map! :after pdf-tools
:map pdf-view-mode-map
:n "i" #'org-noter-insert-note
:n "M-i" #'bhw/org-noter-insert-precise-quote
:n "d" #'pdf-view-scroll-up-or-next-page
:n "u" #'pdf-view-scroll-down-or-previous-page
:n "s" #'avy-goto-word-or-subword-1
:n "f" #'pdf-view-set-slice-from-bounding-box
:n ";" #'pdf-occur
:n "q" #'bhw/org-noter-quit
:n "gt" #'pdf-view-goto-page
:n "w" #'bhw/pdf-view-fit-width
:n "y" #'bhw/pdf-view-yank
:n [down-mouse-1] #'bhw/pdf-view-mouse-set-region
:n [C-down-mouse-1] #'pdf-view-mouse-extend-region
:n [M-down-mouse-1] #'pdf-view-mouse-set-region-rectangle)
;; Prevent Evil from entering visual-mode when pdf-view activates the
;; mark for text selection. Without this, Evil hijacks the region and
;; tries to select the PDF image object, breaking click-drag selection.
;; (Mirrors evil-collection-pdf-disable-visual-mode.)
(add-hook! 'pdf-view-mode-hook
(pdf-view-midnight-minor-mode)
(run-at-time "0.1 sec" nil (lambda () (when (derived-mode-p 'pdf-view-mode)
(pdf-view-redisplay t))))
(remove-hook 'activate-mark-hook 'evil-visual-activate-hook t))
(defun bhw/pdf-view-fit-width ()
"Fit PDF page width, re-displaying first to avoid stale image errors."
(interactive)
(pdf-view-redisplay t)
(pdf-view-fit-width-to-window))
(defun bhw/pdf-view-mouse-set-region (event)
"Start a PDF text selection, re-displaying first to avoid stale image errors."
(interactive "@e")
(pdf-view-redisplay t)
(pdf-view-mouse-set-region event))
(defun bhw/pdf-view-yank ()
"Yank the text of the active PDF region into the kill ring."
(interactive)
(pdf-view-assert-active-region)
(let ((txt (pdf-view-active-region-text)))
(pdf-view-deactivate-region)
(kill-new (mapconcat #'identity txt "\n"))
(message "Yanked %d characters." (length (car kill-ring)))))
;; Fix: `pdf-view-image-size' passes the raw display property to the C
;; primitive `image-size' when DISPLAYED-P is nil. After slicing
;; (`pdf-view-set-slice-from-bounding-box'), the display property
;; becomes ((slice X Y W H) (image …)) — a compound form that the C
;; primitive `image-size' cannot handle (it expects (image …)). This
;; advice unwraps the image spec from the sliced form so `image-size'
;; receives a bare (image …) descriptor.
(defadvice! bhw/pdf-view-image-size-handle-slice-a (fn &optional displayed-p window page)
:around #'pdf-view-image-size
(if displayed-p
;; `image-display-size' already handles the sliced compound form.
(funcall fn displayed-p window page)
;; Replicate the display-prop lookup that `pdf-view-image-size' does
;; internally, but unwrap any slice wrapper before calling `image-size'.
(let ((display-prop (if pdf-view-roll-minor-mode
(let ((w (if (windowp window) window (selected-window))))
(overlay-get (pdf-roll-page-overlay
(or page (pdf-view-current-page w)) w)
'display))
(image-get-display-property))))
(image-size (if (eq (car-safe (car display-prop)) 'slice)
(cadr display-prop) ; ((slice …) (image …)) → (image …)
display-prop) ; already (image …)
t))))
(after! pdf-tools
(setf pdf-view-use-scaling nil
pdf-view-max-image-width 4800
pdf-cache-image-limit 512
pdf-cache-prefetch-delay 0.3
;; see also `pdf-view-midnight-minor-mode'
pdf-view-midnight-invert nil
pdf-tools-enabled-modes
'(pdf-view-dark-minor-mode
pdf-history-minor-mode
pdf-isearch-minor-mode
pdf-links-minor-mode
pdf-misc-minor-mode
pdf-misc-size-indication-minor-mode
pdf-occur-global-minor-mode))
(pdf-cache-prefetch-minor-mode -1))
Old config:
(setf
pdf-view-use-scaling t
pdf-view-display-size 'fit-width
pdf-view-resize-factor 1.1
image-cache-eviction-delay 128
pdf-cache-image-limit 128
pdf-view-restore-filename "~/.emacs.d/.cache/.pdf-view-restore")
;; Custom function to allow double page scrolling by calling
;; my-pdf-view-double-scroll-horizontal-view
(defun my-pdf-view-double-scroll-up-or-next-page (&optional arg)
"Scroll page up ARG lines if possible, else go to the next page.
When `pdf-view-continuous' is non-nil, scrolling upward at the
bottom edge of the page moves to the next page. Otherwise, go to
next page only on typing SPC (ARG is nil)."
(interactive "P")
(if (or pdf-view-continuous (null arg))
(let ((hscroll (window-hscroll))
(cur-page (pdf-view-current-page)))
(when (or (= (window-vscroll) (image-scroll-up arg))
;; Workaround rounding/off-by-one issues.
(memq pdf-view-display-size
'(fit-height fit-page)))
(pdf-view-next-page 2)
(when (/= cur-page (pdf-view-current-page))
(image-bob)
(image-bol 1))
(set-window-hscroll (selected-window) hscroll)))
(image-scroll-up arg)))
(defun my-pdf-view-double-scroll-horizontal-view ()
(interactive)
(my-pdf-view-double-scroll-up-or-next-page)
(other-window 1)
(my-pdf-view-double-scroll-up-or-next-page)
(other-window 1))
;; add spacemacs major mode keybind
(spacemacs/set-leader-keys-for-major-mode 'pdf-view-mode "d" 'my-pdf-view-double-scroll-horizontal-view)
;; Allow rotating of sheet music in pdfs
(defun pdf-view--rotate (&optional counterclockwise-p page-p)
"Rotate PDF 90 degrees. Requires pdftk to work.\n
Clockwise rotation is the default; set COUNTERCLOCKWISE-P to
non-nil for the other direction. Rotate the whole document by
default; set PAGE-P to non-nil to rotate only the current page.
\nWARNING: overwrites the original file, so be careful!"
;; error out when pdftk is not installed
(if (null (executable-find "pdftk"))
(error "Rotation requires pdftk")
;; only rotate in pdf-view-mode
(when (eq major-mode 'pdf-view-mode)
(let* ((rotate (if counterclockwise-p "left" "right"))
(file (format "\"%s\"" (pdf-view-buffer-file-name)))
(page (pdf-view-current-page))
(pages (cond ((not page-p) ; whole doc?
(format "1-end%s" rotate))
((= page 1) ; first page?
(format "%d%s %d-end"
page rotate (1+ page)))
((= page (pdf-info-number-of-pages)) ; last page?
(format "1-%d %d%s"
(1- page) page rotate))
(t ; interior page?
(format "1-%d %d%s %d-end"
(1- page) page rotate (1+ page))))))
;; empty string if it worked
(if (string= "" (shell-command-to-string
(format (concat "pdftk %s cat %s "
"output %s.NEW "
"&& mv %s.NEW %s")
file pages file file file)))
(pdf-view-revert-buffer nil t)
(error "Rotation error!"))))))
(defun pdf-view-rotate-clockwise (&optional arg)
"Rotate PDF page 90 degrees clockwise. With prefix ARG, rotate
entire document."
(interactive "P")
(pdf-view--rotate nil (not arg)))
(defun pdf-view-rotate-counterclockwise (&optional arg)
"Rotate PDF page 90 degrees counterclockwise. With prefix ARG,
rotate entire document."
(interactive "P")
(pdf-view--rotate :counterclockwise (not arg)))
(define-key spacemacs-pdf-view-mode-map (kbd "R") 'pdf-view-rotate-clockwise)
Magit Config
;;---------------------------------------------------------------------------
(after! magit
(map! :map magit-status-mode-map
"SPC" #'doom/leader
"j" #'evil-next-line
"k" #'evil-previous-line
"h" #'evil-backward-char
"l" #'evil-forward-char
"p" #'magit-push
"v" #'evil-visual-line
"V" #'evil-visual-line
"gg" #'evil-goto-first-line
"G" #'evil-goto-line))
GitHub - anticomputer/gh-notify: Veneer for the Magit/Forge GitHub porcelain git - Magit: how remove push branch? - Emacs Stack Exchange Cheatsheet · magit/magit Wiki · GitHub git - Add change to a previous commit with Magit - Emacs Stack Exchange To perform code review on Github from emacs, see https://github.com/charignon/github-review, and another iteration on top of it,GitHub - wandersoncferreira/code-review: Code Reviews in Emacs
| Key | Effect |
|---|---|
| + | Widen/Narrow hunk scope |
| M-n/M-p | In a commit buffer, go back to previous commit messages |
| \ or C-t | Switch to text mode in a magit buffer |
Thanks for linking the HN discussion, good stuff. The comments mention this functionality is already available in magit-blame, though not very well advertised.
- `M-x RET magit-blame RET m` (or `b`) inside a source code buffer to activate the magit blame minor mode and then move the pointer around. There should be blame context in the minibuffer.
- `M-x RET magit-blame-cycle-style` (`c` when in magit-blame mode). This is awesome. Might just replace `SPC g f l (magit-log-buffer-file)` and `C-x v g (vc-annotate)` for me.
(“<remap> <vc-diff>” . magit-diff-buffer-file) (“<remap> <vc-print-log>” . magit-log-buffer-file) (“<remap> <vc-print-root-log>” . magit-log-all) (“<remap> <vc-annotate>” . magit-blame-addition)
(setf forge-owned-accounts '(("your-gh-username" :remote-name "origin"))
magit-save-repository-buffers 'dontask)
Magit Layer
use magit-file-rename to rename files already tracked by git. Once again, Git in Spacemacs/Emacs with Magit - YouTube .
| Key | Effect |
|---|---|
| SPC g s | Open Git Status |
| s | to stage selected file SPC-u S to stage all changes. |
| c | to commit |
| ,, | confirm final commit |
| SPC g f l | Find history of all commits that affected the highlighted region |
| C-x v g | To complement SPC g f l |
# Git is also garbage collected through other commands
git gc --aggressive
Github Tutorial
<- Dotfiles & Configuration Files
- Git Fork Workflow Using Rebase. Here is a suggested git forking… | by Ruth M….
- Working with Git and patches in Emacs
- The advantages of an email-driven git workflow
- Pro Git Free Book
- Karl Broman Github Tutorial
- Version Control (Git) · Missing Semester
Setting up SSH
Get a github account. Download and install git.
sudo apt install gitVerify that ~/.gitconfig exists and the contents match Dotfiles & Configuration Files
Look to see if you have files ~/.ssh/idrsa and ~/.ssh/idrsa.pub. If not, create such public/private keys: Open a terminal/shell and type:
ssh-keygen -o -t ed25519 -C "your_email@example.com" # Github login email.Copy your public key (the contents of the newly-created idrsa.pub file) into your clipboard. Paste your ssh public key into your github account settings.
Go to your github Account Settings Click “SSH Keys” on the left. Click “Add SSH Key” on the right. Add a label (like “My laptop”) and paste the public key into the big text box.
In a terminal/shell, type the following to test it:
ssh -T git@github.comIf it says something like the following, it worked:
Hi username! You’ve successfully authenticated, but Github does not provide shell access.
Now clone your repositories.
Reduce the size of the git folder:
git repack -a -d --depth=250 --window=250Signing commits with gpg
Assuming you already have a key pair created and Github knows about it.
# Search for "GPG" inside KeePass and write to private-key.asc. gpg --import private-key.asc # https://www.bhw.name/contact to find and write to public-key.asc. gpg --import ~/common-lisp/project-isidore/assets/pubkey.asc # Verify that ~/.gnupg/gpg-agend.conf exists, see dotfile.org. # Edit gpg key to raise trust level to 5 - ultimate. gpglogin # see .bashrc.git - How to automatically sign commits with magit? - Emacs Stack Exchange
- Syncing forked project
Add the remote (original repo that you forked) and call it “upstream”
git remote add upstream https://github.com/original-repo/goes-here.git
Fetch (not pull, remember that a pull = fetch and merge in git parlance) all branches of remote upstream
git fetch upstream
Make sure you are on the correct branch
git checkout master
Rewrite your master with upstream’s master using git rebase. Will preserve your commits on master, to replay those commits on top of the current upstream/master.
git rebase upstream/master
Rewrite your master with upstream’s master using git reset. Will NOT preserve your commits on master
git reset –hard upstream/master
Push your updates to master. You may need to force the push with “–force”, as rebasing recreates new versions of each commit. So if those same commits already exist on your origin repo, which is likely, git will not recognize them and assume your origin is ahead of local and ask you to pull before pushing. the “–force” flag overrides this behaviour.
git push origin master –force
- Read Pro Git At some point you will have to read https://git-scm.com/book/en/v2 pro git.
- Learn to use Ediff to resolve conflicts https://www.sentia.com.au/blog/ediff-for-the-brainically-challenged
- Cleanup Github Forks
Install
curlandjq. Use the following command line to get the names of all your repositories on your Github account. Paste list into new filerepo-delete-list.txtsudo apt install jq # curl is probably installed by default # Note page number in URL. If number of repo > 100, change it. curl "https://api.github.com/users/YOUR_GITHUB_ACCOUNT/repos?per_page=100&page=1" | jq -r '.[] | .name'- Add YOURGITHUBACCOUNT/ to the beginning of each line. From
example-repotoyour-gh-username/example-repo. - Register a new personal access token with a deleterepo permission at https://github.com/settings/tokens/new and save it.
Execute the command in shell.
cd /path/to/repo-delete-list.txt while read repo; do curl -X DELETE -H "Authorization: token YOUR_TOKEN" "https://api.github.com/repos/$repo"; done < repo_list_deleting.txt sudo apt purge jqOn Windows:
get-content C:\repo_list_deleting.txt | ForEach-Object { Invoke-WebRequest -Uri https://api.github.com/repos/$_ -Method “DELETE” -Headers @{“Authorization”=”token Your_TOKEN”} }
Keeps my commit history tidy. Able to be used as extracted changelogs. Like essay or citation conventions.
| Feat | A new feature |
| Fix | A bug fix |
| Docs | Documentation only changes |
| Style | Changes that do not affect the meaning of the code (white-space formatting) |
| Refactor | Refactoring A code change that neither fixes a bug nor adds a feature |
| Perf | A code change that improves performance |
| Tests | Adding missing tests or correcting existing tests |
| Build | Changes that affect the build system or external dependencies (quicklisp) |
| CI | Changes to our Continous Integration/Continous Delivery configuration files and scripts |
| Chore | Other changes that don’t modify src or test files |
| Revert | Reverts a previous commit |
Magit Forged
Magit Forged is a way to view/and compose issues and pull requests of a particular git repository while remaining in Emacs.
- Setup
- Make sure to generate a personal access token from Github
Edit whatever file is designated as your .authinfo file. You can check this via
M-x describe-variable RET auth-sources. The relevant entry for magit forged is,machine api.github.com login “insertusernamehere”forge password “insertyourpasswordhere”
- You may need to restart emacs.
- Run
M-x forge-pullwhile inside a buffer of the relevant project. - By pressing
SPC g sto invokemagit-statuswe can then press?to see that Forge commands are listed under@
Claude Code IDE Config
How to pick which Claude model to optimize for efficiency? https://deepswe.datacurve.ai/
Beginner’s Guide to Claude Code The Advanced Claude Code Setup Guide | Reading.sh https://github.com/affaan-m/everything-claude-code?tab=readme-ov-file Claude Code Cheat Sheet
- What work to do. Which task, in what order, at what scope.
- What context to build before doing the work. Which files to read, what to research, what background to provide.
- How to describe the work. Clear enough that Claude doesn’t waste tokens figuring out what you meant.
- How to verify the output. What does good look like, and how will you check.
- Explore: Ask Claude to read relevant files. Be explicit: “Read the authentication module and the user model. Don’t write any code yet.”
Plan: Ask for a plan. “Think hard about how to implement password reset. What files need to change? What edge cases should we handle?”
think think hard think harder ultrathink
Give good feedback on the plan. Be explicit about what not to do. Provide examples of desired input and output!
- Code: Once you’ve confirmed the plan, ask Claude to implement it. “Implement the plan. Verify each change compiles before moving on.”
- “Write tests for [feature] based on these requirements. Don’t implement the feature yet — I want the tests to fail initially.”
- Confirm the tests are comprehensive.
- “Now implement the feature to make the tests pass. Don’t modify the tests.”
Review:
- Claude A implements a feature
- /clear or start Claude B in another terminal
- Claude B reviews A’s work, identifies issues
- Claude A (or a fresh instance) addresses the feedback
The separation produces better results than single-Claude self-review.
Ctrl+V to paste images into Claude Code:
- UI mockups as implementation references
- Screenshots of errors or unexpected behaviour
- Diagrams explaining desired architecture
File References with Tab Completion Type @ followed by a path and use tab completion to reference files:
Visual Iteration
- For frontend work, give Claude a way to see its output:
- Set up the Puppeteer MCP server (or similar)
- Provide a mockup image “Implement this design. After each change, take a screenshot and compare it to the mockup. Iterate until they match.”
Escape to Interrupt Press Escape during any phase—thinking, tool execution, file editing. Context is preserved. You can redirect: “Stop. Let’s try a different approach.”
Double-Escape to Rewind Press Escape twice to jump back in conversation history. Select an earlier point and continue from there, discarding everything after. Useful when Claude went down a wrong path several turns ago.
/clear for Fresh Context /compact for Compression /btw for side questions For an index of all interactive commands, see Interactive mode - Claude Code Docs
Custom Slash Commands Create reusable prompts as markdown files in .claude/commands/:
Now /project:review runs this workflow. The $ARGUMENTS keyword passes parameters: /project:fix-issue 1234 with a command containing $ARGUMENTS substitutes the issue number.
Hooks Hooks are shell commands that run at specific lifecycle points: PreToolUse: Before Claude executes any tool PostToolUse: After successful tool execution Stop: When Claude completes a task Example: Run linters automatically after every file edit. Configure via /hooks.
JSON Snippet (~/.claude/settings.json):
{
"permissions": {
"defaultMode": "bypassPermissions"
}
}
Use the above with: https://github.com/justi/claude-code-project-boundary
https://github.com/gsd-build/get-shit-done https://github.com/garrytan/gstack
https://www.reddit.com/r/LocalLLaMA/comments/1nwx1rx/the_most_important_ai_paper_of_the_decade_no/ https://www.reddit.com/r/LocalLLaMA/comments/1oakwgs/stanford_just_dropped_55hrs_worth_of_lectures_on/
Discussion on how to use gptel with org mode buffers. https://mentat.za.net/blog/2026/01/26/gemini-for-code-patches-in-emacs/?utm_source=atom_feed
Background: {1hr Talk} Intro to Large Language Models - YouTube Spreadsheets are all you need.ai – A low-code way to learn AI
I can install a Large Language Model (LLM) locally on our own computer or on a server. I chose the easiest solution to install, the open source LLM created by Meta and packaged by Justine and the open source community: LLaMAfile.
To run on my local WSL system, note the pitfalls and workarounds.
To install on my OCI server,
ssh oci-a1-flex
mkdir /opt/llava
cd /opt/llava
curl "DOWNLOAD_LINK" -L -O
chmod +x llava-v1.5-7b-q4.llamafile
# https://github.com/Mozilla-Ocho/llamafile?tab=readme-ov-file#gotchas
sudo wget -O /usr/bin/ape https://cosmo.zip/pub/cosmos/bin/ape-$(uname -m).elf
sudo chmod +x /usr/bin/ape
sudo sh -c "echo ':APE:M::MZqFpD::/usr/bin/ape:' >/proc/sys/fs/binfmt_misc/register"
sudo sh -c "echo ':APE-jart:M::jartsr::/usr/bin/ape:' >/proc/sys/fs/binfmt_misc/register"
Create /etc/systemd/system/llamafile.service with the following contents,
[Unit]
Description=Run Large Language Model locally through llama.cpp and Cosmopolitan Libc.
[Service]
Type=exec
User=root
WorkingDirectory=/opt/llava
ExecStart=/opt/llava/llava-v1.5-7b-q4.llamafile -ngl 9999 --nobrowser --port 8090 --embedding
Restart=on-failure
RestartSec=30s
[Install]
WantedBy=multi-user.target
Modify caddy reverse proxy to redirect requests to server. E.g.
https://github.com/karthink/gptel/issues/237
Right now I can’t get it to run in the server so I’m running it locally as a systemd service. Praise the Lord, and all the people working on open science.
https://chat.lmsys.org/ to use Claude3-opus and test other models.
https://www.reddit.com/r/LocalLLaMA/ to check up on the latest news.
;;---------------------------------------------------------------------------
(use-package! claude-code-ide
:defer t
:init
;; The binding lives in :init so it exists before the package loads;
;; `claude-code-ide-menu' is autoloaded, and first use runs :config below,
;; which registers the MCP tools before any session starts.
(map! :leader
:desc "claude-code-ide-menu" "d" #'claude-code-ide-menu)
:config
;; Use the ghostel terminal backend (installed below) instead of the
;; default 'vterm, whose native module isn't compiled in this setup.
(setq claude-code-ide-terminal-backend 'ghostel)
;; Register the 5 built-in Emacs MCP tools (xref-find-references,
;; xref-find-apropos, project-info, imenu-list-symbols, treesit-info)
;; and set `claude-code-ide-enable-mcp-server' to t.
(claude-code-ide-emacs-tools-setup)
;; --- Workspace-scoped session directory ----------------------------------
(defun bhw/claude-code-ide--workspace-root (stamped buffers)
"Resolve a workspace's project root from STAMPED and BUFFERS.
STAMPED is the workspace's `+workspace-project' persp parameter and
wins when it still names a live directory; a workspace outlives the
directory it was opened on. BUFFERS is the workspace's buffer list,
from which the most widely shared project root is taken, recency
breaking ties. Returns nil when neither yields a project."
(when-let* ((root
(or (and (stringp stamped) (file-directory-p stamped) stamped)
(let ((tally (make-hash-table :test #'equal))
(roots nil))
;; `buffer-list' is MRU-ordered and a persp's buffer
;; list can hold killed buffers, so walk the
;; intersection rather than BUFFERS itself.
(dolist (buf (buffer-list))
(when (and (memq buf buffers)
(or (buffer-file-name buf)
(provided-mode-derived-p
(buffer-local-value 'major-mode buf)
'dired-mode)))
(when-let* ((r (doom-project-root
(buffer-local-value 'default-directory buf))))
(unless (gethash r tally) (push r roots))
(puthash r (1+ (gethash r tally 0)) tally))))
;; `sort' is stable, so equal counts keep MRU order.
(car (sort (nreverse roots)
(lambda (a b)
(> (gethash a tally) (gethash b tally)))))))))
(expand-file-name (file-name-as-directory root))))
(defun bhw/claude-code-ide--workspace-project-root ()
"Return the project root of the current Doom workspace, or nil.
Prefers the `+workspace-project' persp parameter that
`+workspaces-switch-to-project-h' stamps onto a workspace opened from
a project switch; hand-made workspaces carry no such parameter and are
resolved from their buffers. Returns nil outside persp-mode and in a
workspace with no project (an empty `main', say), leaving upstream's
buffer-local lookup in charge."
(when-let* (((bound-and-true-p persp-mode))
((fboundp 'get-current-persp))
(persp (get-current-persp)))
(bhw/claude-code-ide--workspace-root
(persp-parameter '+workspace-project persp)
(persp-buffers persp))))
(defadvice! bhw/claude-code-ide-workspace-dir-a (&rest _)
"Key Claude sessions to the workspace's project, not the current buffer.
Workspaces are per-project here, so opening a file from another
project inside one must not start (or address) a second session; the
workspace outranks whatever buffer happens to be current."
:before-until #'claude-code-ide--get-working-directory
(bhw/claude-code-ide--workspace-project-root))
;; --- Shared tool helpers -------------------------------------------------
(defun bhw/claude-mcp--user-buffer ()
"The buffer the user is working in, never the Claude terminal.
The session's :last-active-buffer is only maintained for file buffers
inside the session's project dir, so fall back to the most recently
used window showing neither the terminal nor a minibuffer, then to
frame MRU buffer order. Returns nil if nothing qualifies."
(let* ((context (claude-code-ide-mcp-server-get-session-context))
(terminal (plist-get context :buffer))
(last (plist-get context :last-active-buffer)))
(or (and last (buffer-live-p last) last)
(cl-loop for win in (sort (window-list nil 'no-minibuf)
(lambda (a b)
(> (window-use-time a)
(window-use-time b))))
for buf = (window-buffer win)
unless (or (eq buf terminal) (minibufferp buf))
return buf)
(cl-loop for buf in (buffer-list (selected-frame))
unless (or (eq buf terminal) (minibufferp buf)
(string-prefix-p " " (buffer-name buf)))
return buf))))
(defun bhw/claude-mcp--brief (value)
"Render VALUE for tool output: short, propertyless, no raw lisp objects."
(let ((s (cond ((stringp value) (substring-no-properties value))
((markerp value)
(if (marker-buffer value)
(format "%s:%d" (buffer-name (marker-buffer value))
(marker-position value))
"(marker: no buffer)"))
((or (bufferp value) (framep value) (windowp value)
(processp value) (compiled-function-p value))
(format "#<%s>" (type-of value)))
(t (format "%S" value)))))
(if (> (length s) 200) (concat (substring s 0 200) "…") s)))
(defun bhw/claude-mcp--join-capped (lines max)
"Join LINES, capping at MAX with an explicit \"(+N more)\" indicator."
(let ((n (length lines)))
(if (<= n max)
(string-join lines "\n")
(concat (string-join (seq-take lines max) "\n")
(format "\n(+%d more; pass a larger limit to see all)"
(- n max))))))
(defun bhw/claude-mcp--match-all-p (tokens haystack)
"Non-nil when every literal token in TOKENS occurs in HAYSTACK."
(cl-every (lambda (tok) (string-match-p (regexp-quote tok) haystack))
tokens))
(defun bhw/claude-mcp-register (&rest slots)
"Register an MCP tool, replacing any existing spec with the same :name.
`claude-code-ide-make-tool' dedupes via `add-to-list' by whole-spec
`equal', so editing a tool then `doom/reload' would otherwise leave two
specs advertising the same name, and tools/list would list both."
(let ((name (plist-get slots :name)))
(setq claude-code-ide-mcp-server-tools
(cl-remove-if (lambda (spec)
(and (keywordp (car-safe spec))
(equal (plist-get spec :name) name)))
claude-code-ide-mcp-server-tools)))
(apply #'claude-code-ide-make-tool slots))
;; --- Custom MCP tools ----------------------------------------------------
;; Editor state
(defun bhw/claude-mcp-current-buffer-info ()
"Return file, mode, position, modified and region state of the user's buffer.
The session context's :buffer is the ghostel terminal running Claude
itself, so resolution goes through `bhw/claude-mcp--user-buffer'."
(let ((buffer (bhw/claude-mcp--user-buffer)))
(if (not buffer)
"ERROR: no user buffer identified (only the Claude terminal is visible)"
(with-current-buffer buffer
(format "Buffer: %s\nFile: %s\nMode: %s\nLine: %d\nColumn: %d\nModified: %s\nRegion active: %s"
(buffer-name)
(or (buffer-file-name) "(no file)")
major-mode
(line-number-at-pos)
(current-column)
(if (buffer-modified-p) "yes" "no")
(if (use-region-p) "yes" "no"))))))
(defun bhw/claude-mcp-current-selection ()
"Return active region text from the user's buffer, or a no-region message."
(let ((buffer (bhw/claude-mcp--user-buffer)))
(if (not buffer)
"ERROR: no user buffer identified (only the Claude terminal is visible)"
(with-current-buffer buffer
(if (use-region-p)
(buffer-substring-no-properties (region-beginning) (region-end))
(format "No active region in %s" (buffer-name)))))))
(defun bhw/claude-mcp-list-buffers (&optional include_non_file limit)
"Return open buffers, most recently used first, one record per line."
(let* ((max (or limit 50))
(lines
(cl-loop for buf in (buffer-list (selected-frame)) ; MRU order
for name = (buffer-name buf)
for file = (buffer-file-name buf)
when (and (not (string-prefix-p " " name))
(or file include_non_file))
collect (format "%s | %s | %s%s"
name
(or file "(no file)")
(buffer-local-value 'major-mode buf)
(if (and file (buffer-modified-p buf))
" | MODIFIED" "")))))
(if lines
(bhw/claude-mcp--join-capped lines max)
"No matching buffers")))
;; Org-node (ID nodes via the public org-mem API)
(defun bhw/claude-mcp-org-node-find (query &optional limit)
"Match org-node entries whose title contains every token of QUERY.
Case-insensitive; returns `title | file | id' lines, LIMIT (default 20)."
(require 'org-node)
(org-node-cache-ensure)
(let* ((tokens (split-string (downcase (or query "")) "[ \t]+" t))
(max (or limit 20))
(matches
(cl-loop for entry in (org-mem-all-id-nodes)
for title = (or (org-mem-entry-title entry) "")
when (bhw/claude-mcp--match-all-p tokens (downcase title))
collect (format "%s | %s | %s"
title
(org-mem-entry-file entry)
(or (org-mem-entry-id entry) "")))))
(if matches
(bhw/claude-mcp--join-capped matches max)
(format "No org-node matches for %S" query))))
;; Org agenda / clock
(defun bhw/claude-mcp--org-todo-row ()
"Format the heading at point as one agenda-todo record, or nil to skip."
(let ((state (org-get-todo-state)))
(when state
(let ((heading (org-get-heading t t nil t)) ; keep the priority cookie
(path (org-get-outline-path))
(tags (org-get-tags))
(sched (org-entry-get nil "SCHEDULED"))
(dl (org-entry-get nil "DEADLINE"))
(effort (org-entry-get nil "EFFORT")))
(concat
(format "%s:%d | %s %s"
(or (buffer-file-name) (buffer-name))
(line-number-at-pos)
(substring-no-properties state)
(substring-no-properties heading))
(and path (format " | path: %s" (string-join path " > ")))
(and tags (format " | tags: %s"
(mapconcat #'substring-no-properties tags ":")))
(and sched (format " | sched: %s" sched))
(and dl (format " | deadline: %s" dl))
(and effort (format " | effort: %s" effort)))))))
(defun bhw/claude-mcp-org-agenda-todos (&optional keywords limit)
"List agenda entries by todo state, one record per line.
KEYWORDS is a space/comma-separated list of todo keywords, \"all\", or
nil/\"not-done\" (the default) for every not-done state in one pass."
(require 'org)
(let* ((wanted (cond ((member keywords '(nil "" "not-done")) 'not-done)
((equal keywords "all") 'all)
(t (split-string keywords "[ ,]+" t))))
(max (or limit 100))
(rows (delq nil
(org-map-entries
(lambda ()
(let ((state (org-get-todo-state)))
(when (pcase wanted
('not-done (org-entry-is-todo-p))
('all state)
(_ (member state wanted)))
(bhw/claude-mcp--org-todo-row))))
nil 'agenda))))
(if rows
(bhw/claude-mcp--join-capped rows max)
(format "No matching entries (keywords=%s)" (or keywords "not-done")))))
(defun bhw/claude-mcp-org-clock-status ()
"Return the current org clock with its location, or the last history entry."
(require 'org-clock)
(cond
((org-clocking-p)
(let* ((elapsed (float-time (time-since org-clock-start-time)))
(h (floor (/ elapsed 3600)))
(m (floor (/ (mod elapsed 3600) 60)))
(buf (marker-buffer org-clock-marker))
(where (and (buffer-live-p buf)
(with-current-buffer buf
(save-excursion
(goto-char org-clock-marker)
(format "%s:%d%s"
(or (buffer-file-name) (buffer-name))
(line-number-at-pos)
(if-let* ((id (org-entry-get nil "ID")))
(format " (id: %s)" id)
"")))))))
(format "Clocked in: %s\nStarted: %s\nElapsed: %dh%02dm%s"
(substring-no-properties (or org-clock-current-task ""))
(format-time-string "%F %T" org-clock-start-time)
h m
(if where (format "\nAt: %s" where) ""))))
((and (boundp 'org-clock-history) org-clock-history)
(let* ((m (car org-clock-history))
(buf (and (markerp m) (marker-buffer m))))
(if (buffer-live-p buf)
(with-current-buffer buf
(save-excursion
(goto-char m)
(format "No clock running. Last task: %s at %s:%d%s"
(substring-no-properties (org-get-heading t t t t))
(or (buffer-file-name) (buffer-name))
(line-number-at-pos)
(if-let* ((id (org-entry-get nil "ID")))
(format " (id: %s)" id)
""))))
"No clock running. History markers point to dead buffers.")))
(t "No clock active and history empty")))
;; Citar bibliography
(defun bhw/claude-mcp-citar-lookup (query &optional limit)
"Match bibliography entries containing every token of QUERY.
Case-insensitive over citekey, author, and title; LIMIT default 20."
(require 'citar)
(let* ((tokens (split-string (downcase (or query "")) "[ \t]+" t))
(max (or limit 20))
(matches '()))
(maphash
(lambda (key entry)
(let* ((title (or (cdr (assoc "title" entry)) ""))
(author (or (cdr (assoc "author" entry)) ""))
(year (or (cdr (assoc "year" entry))
(cdr (assoc "date" entry)) ""))
(haystack (downcase (concat key " " author " " title))))
(when (bhw/claude-mcp--match-all-p tokens haystack)
(push (format "@%s | %s | %s | %s" key author year title)
matches))))
(citar-get-entries))
(if matches
(bhw/claude-mcp--join-capped (nreverse matches) max)
(format "No bibliography matches for %S" query))))
;; Introspection
(defun bhw/claude-mcp-describe-symbols (symbols)
"Report feature, function, and variable state for each name in SYMBOLS."
(mapconcat
(lambda (name)
(let ((sym (intern-soft name)))
(if (null sym)
(format "%s: not interned%s" name
(if-let* ((lib (locate-library name)))
(format "; library at %s (not loaded)" lib)
""))
(string-join
(list
(format "%s:" name)
(format " feature: %s"
(cond ((featurep sym) "loaded")
((locate-library name)
(format "not loaded (library: %s)"
(locate-library name)))
(t "no such feature/library")))
(format " function: %s"
(cond ((not (fboundp sym)) "void")
((autoloadp (symbol-function sym))
"autoload (not yet loaded)")
(t (let (adv)
(advice-mapc (lambda (f _) (push f adv)) sym)
(if adv
(format "defined; advised by %s"
(mapconcat #'bhw/claude-mcp--brief
adv ", "))
"defined")))))
(format " variable: %s"
(if (boundp sym)
(format "bound = %s"
(bhw/claude-mcp--brief (symbol-value sym)))
"void")))
"\n"))))
(split-string symbols "[ ,]+" t)
"\n"))
(defun bhw/claude-mcp-buffer-sync (file &optional action)
"Report whether FILE's buffer is modified/stale; ACTION \"revert\" syncs it.
Refuses to revert a buffer with unsaved edits."
(let ((buf (get-file-buffer (expand-file-name file))))
(if (null buf)
(format "%s: not visited by any buffer; disk contents are authoritative"
file)
(with-current-buffer buf
(let ((modified (buffer-modified-p))
(stale (not (verify-visited-file-modtime (current-buffer)))))
(cond
((not (equal action "revert"))
(format "%s: %s in Emacs; %s disk" file
(if modified "MODIFIED (unsaved edits)" "unmodified")
(if stale "STALE against" "in sync with")))
(modified
(format "ERROR: %s has unsaved edits in Emacs; refusing to revert. Ask the user to save or discard first."
file))
((not stale)
(format "%s: already in sync; no revert needed" file))
(t
(revert-buffer 'ignore-auto 'noconfirm 'preserve-modes)
(when (derived-mode-p 'org-mode)
(org-element-cache-reset))
(format "%s: reverted from disk%s" file
(if (derived-mode-p 'org-mode)
" (org-element cache reset)"
"")))))))))
(defun bhw/claude-mcp-key-lookup (key &optional mode state)
"Resolve KEY per keymap layer, optionally in MODE and evil STATE.
Uses an existing live buffer in MODE; never instantiates the mode."
(let ((keyseq (condition-case nil (kbd key) (error nil))))
(if (null keyseq)
(format "ERROR: cannot parse key %S with kbd" key)
(let* ((mode-sym (and mode (intern-soft mode)))
(buf (if mode-sym
(cl-find-if (lambda (b)
(eq (buffer-local-value 'major-mode b)
mode-sym))
(buffer-list))
(bhw/claude-mcp--user-buffer))))
(cond
((and mode-sym (null buf))
(format "ERROR: no live buffer in %s; open one and retry" mode))
((null buf) "ERROR: no user buffer to resolve against")
(t
(with-current-buffer buf
(let ((state-sym (and state (intern-soft state)))
lines)
(push (format "effective in %s (%s%s): %s"
(buffer-name) major-mode
(if (bound-and-true-p evil-state)
(format ", evil %s" evil-state)
"")
(bhw/claude-mcp--brief (key-binding keyseq)))
lines)
(when (and state-sym (fboundp 'evil-get-auxiliary-keymap))
(when-let* ((aux (and (current-local-map)
(evil-get-auxiliary-keymap
(current-local-map) state-sym))))
(push (format "evil %s + local map: %s" state
(bhw/claude-mcp--brief
(lookup-key aux keyseq)))
lines))
(when-let* ((gmap (evil-state-property state-sym :keymap t)))
(push (format "evil %s global: %s" state
(bhw/claude-mcp--brief
(lookup-key gmap keyseq)))
lines)))
(push (format "local map: %s"
(bhw/claude-mcp--brief
(and (current-local-map)
(lookup-key (current-local-map) keyseq))))
lines)
(push (format "global map: %s"
(bhw/claude-mcp--brief
(lookup-key global-map keyseq)))
lines)
(string-join (nreverse lines) "\n")))))))))
;; Reload / diagnostics
(defvar bhw/claude-mcp--reload-requested-at nil
"When the last doom-reload MCP call kicked off, for status polling.")
(defun bhw/claude-mcp-doom-reload (&optional tangle)
"Start `doom/reload' (optionally tangling dotemacs.org first); async.
Returns immediately; poll the doom-reload-status tool for the outcome."
(catch 'bhw/done
(when tangle
(let* ((org-file (expand-file-name "~/project-maria/blog/dotemacs.org"))
(buf (get-file-buffer org-file)))
(when (and buf (buffer-modified-p buf))
(throw 'bhw/done
"ERROR: dotemacs.org has unsaved edits in Emacs; refusing to tangle over them"))
(when (and buf (not (verify-visited-file-modtime buf)))
(with-current-buffer buf
(revert-buffer 'ignore-auto 'noconfirm)))
(let ((org-confirm-babel-evaluate nil))
(with-current-buffer (or buf (find-file-noselect org-file))
(org-babel-tangle)))))
(setq bhw/claude-mcp--reload-requested-at (float-time))
(doom/reload) ; async: `doom sync' compiles in *compilation*, hooks follow
(format "%sdoom/reload started (doom sync running in *compilation*). Poll doom-reload-status in ~30s."
(if tangle "tangled dotemacs.org; " ""))))
(defun bhw/claude-mcp-doom-reload-status ()
"Report the outcome of the last doom-reload kicked off via MCP."
(let ((t0 bhw/claude-mcp--reload-requested-at))
(cond
((null t0) "no reload requested via MCP this session")
((and bhw/config-load-stamp (> bhw/config-load-stamp t0))
(format "reload OK (config.el load stamp fresh); tests: %s"
(if (and bhw/config-tests-last-report
(> (car bhw/config-tests-last-report) t0))
(cdr bhw/config-tests-last-report)
"no test report yet")))
((and bhw/config-reload-start-time (> bhw/config-reload-start-time t0))
(format "reload FAILED partway: reload ran but the load stamp is stale; tests: %s. Check *Messages*."
(if (and bhw/config-tests-last-report
(> (car bhw/config-tests-last-report) t0))
(cdr bhw/config-tests-last-report)
"no test report")))
((let ((b (get-buffer "*compilation*")))
(and b (get-buffer-process b)))
"doom sync still running")
(t "doom sync finished but reload hooks never ran -- sync likely FAILED; check *compilation*"))))
(defun bhw/claude-mcp-messages-tail (&optional n errors_only)
"Return the last N lines of *Messages* (default 40).
With ERRORS_ONLY, keep only lines that look like errors or warnings."
(with-current-buffer (messages-buffer)
(let* ((lines (split-string (buffer-substring-no-properties
(point-min) (point-max))
"\n" t))
(lines (if errors_only
(seq-filter
(lambda (l)
(string-match-p
"\\b\\(?:[Ee]rror\\|[Ww]arning\\|failed\\|FAILED\\|void\\)"
l))
lines)
lines))
(tail (last lines (or n 40))))
(if tail (string-join tail "\n") "no matching messages"))))
(defun bhw/claude-mcp-last-backtrace ()
"Return the head of the *Backtrace* buffer, if one exists."
(if-let* ((buf (get-buffer "*Backtrace*")))
(with-current-buffer buf
(let ((end (min (point-max) (+ (point-min) 8000))))
(concat (buffer-substring-no-properties (point-min) end)
(when (< end (point-max)) "\n(truncated at 8000 chars)"))))
"no *Backtrace* buffer exists"))
;; --- Tool registrations --------------------------------------------------
;; All through `bhw/claude-mcp-register' so `doom/reload' replaces rather
;; than duplicates specs. The schema emitter drops :enum, so argument
;; vocabularies must be spelled out in :description text.
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-current-buffer-info
:name "current-buffer-info"
:description "Get the buffer the user is working in (never the Claude terminal): file path, major mode, line, column, modified flag, and whether a region is active. Use this to learn what the user is looking at right now."
:args nil)
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-current-selection
:name "current-selection"
:description "Return the text of the user's active region (selection) in Emacs, or a no-region message. Use this when the user references \"this\" or \"the selection\"."
:args nil)
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-list-buffers
:name "list-buffers"
:description "List the user's open Emacs buffers, most recently used first, as `name | file | mode' lines; modified file buffers are flagged MODIFIED. Truncated output ends with a `(+N more)' line."
:args '((:name "include_non_file"
:type boolean
:description "Also include buffers not visiting a file (default false)"
:optional t)
(:name "limit"
:type integer
:description "Maximum lines to return (default 50)"
:optional t)))
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-org-node-find
:name "org-node-find"
:description "Search the user's org-node knowledge base (org ID nodes). Every whitespace-separated token of the query must occur in the node title (case-insensitive, any order). Returns `title | file | id' lines; truncated output ends with `(+N more)'."
:args '((:name "query"
:type string
:description "Tokens to match against node titles")
(:name "limit"
:type integer
:description "Maximum results (default 20)"
:optional t)))
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-org-agenda-todos
:name "org-agenda-todos"
:description "List todo entries across the user's org agenda files as one `file:line | STATE heading | path | tags | sched | deadline | effort' record per line. keywords: space/comma-separated todo states (TODO PROJ APPT PROG WAIT DONE CXLD) or \"all\"; default is every not-done state in a single call. Truncated output ends with `(+N more)'."
:args '((:name "keywords"
:type string
:description "Todo states to include, e.g. \"TODO\", \"TODO WAIT\", or \"all\". Default: all not-done states."
:optional t)
(:name "limit"
:type integer
:description "Maximum entries to return (default 100)"
:optional t)))
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-org-clock-status
:name "org-clock-status"
:description "Return the user's currently clocked-in org task with elapsed time and its file:line (plus ID when present), or the most recent clock history entry if no clock is running."
:args nil)
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-citar-lookup
:name "citar-lookup"
:description "Search the user's citar bibliography (project-jerome.bib). Every whitespace-separated token of the query must occur in the citekey, author, or title (case-insensitive). Returns `@citekey | author | year | title' lines; truncated output ends with `(+N more)'."
:args '((:name "query"
:type string
:description "Tokens to match against citekey, author, and title")
(:name "limit"
:type integer
:description "Maximum results (default 20)"
:optional t)))
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-describe-symbols
:name "describe-symbols"
:description "Inspect Emacs symbols without writing elisp: for each name, report whether the feature/library is loaded, the function state (void, autoload, or defined, plus any advice), and the variable's current value (abbreviated). Prefer this over executeCode for featurep/boundp/fboundp/advice probes."
:args '((:name "symbols"
:type string
:description "Space or comma-separated symbol names")))
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-buffer-sync
:name "buffer-sync"
:description "Check whether a file's Emacs buffer has unsaved edits or is stale against disk, and optionally revert it. action \"check\" (the default) only reports; action \"revert\" reverts a stale, unmodified buffer (org buffers also get their org-element cache reset) and refuses when there are unsaved edits. Call before editing a file the user may have open in Emacs."
:args '((:name "file"
:type string
:description "Absolute or ~-relative path of the file")
(:name "action"
:type string
:description "\"check\" (default) or \"revert\""
:optional t)))
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-key-lookup
:name "key-lookup"
:description "Resolve a key sequence (kbd syntax, e.g. \"C-c C-c\" or \"; s\") layer by layer: effective binding, evil auxiliary/global maps for a given state, local map, global map. Optional mode (e.g. \"org-agenda-mode\"; resolved in an existing live buffer of that mode) and evil state (\"normal\", \"insert\", \"visual\", \"motion\", \"emacs\")."
:args '((:name "key"
:type string
:description "Key sequence in kbd syntax")
(:name "mode"
:type string
:description "Major mode name to resolve in (needs a live buffer in that mode)"
:optional t)
(:name "state"
:type string
:description "Evil state name (normal, insert, visual, motion, emacs)"
:optional t)))
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-doom-reload
:name "doom-reload"
:description "Kick off doom/reload (asynchronous: doom sync, then config re-eval and the ERT suite) and return immediately. With tangle=true, org-babel-tangle dotemacs.org first (refuses if its buffer has unsaved edits). Poll doom-reload-status for the outcome. Redefines live tool functions mid-session. Prefer this over hand-rolled tangle/reload elisp."
:args '((:name "tangle"
:type boolean
:description "Tangle ~/project-maria/blog/dotemacs.org before reloading (default false)"
:optional t)))
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-doom-reload-status
:name "doom-reload-status"
:description "Report the outcome of the last doom-reload tool call: still running, reload OK with the ERT summary (\"N/N passed\"), failed partway (stale load stamp), or doom sync failure. Poll ~30s after doom-reload."
:args nil)
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-messages-tail
:name "messages-tail"
:description "Return the last N lines of the *Messages* buffer (default 40), optionally filtered to error/warning-looking lines. Use this instead of dumping *Messages* via executeCode."
:args '((:name "n"
:type integer
:description "How many lines (default 40)"
:optional t)
(:name "errors_only"
:type boolean
:description "Keep only lines containing error/warning/failed/void (default false)"
:optional t)))
(bhw/claude-mcp-register
:function #'bhw/claude-mcp-last-backtrace
:name "last-backtrace"
:description "Return the most recent Emacs *Backtrace* buffer contents (first 8000 chars), or a message if none exists."
:args nil))
;; --- Upstream MCP HTTP transport fixes -------------------------------------
;; `claude-code-ide-mcp-http-server.el' signals `json-rpc-error' in three places
;; but never defines it. An undefined error symbol has no `error-conditions',
;; so the (error ...) clause of the `condition-case' in --handle-post cannot
;; match it and the signal escapes the web-server process filter as
;; "error in process filter: ... peculiar error: -32601, \"Method not found\"".
;; The request then gets no reply and its connection is never closed.
;;
;; The server is required lazily by `claude-code-ide-mcp-server.el', so `after!'
;; installs both fixes before the first --dispatch call.
(after! claude-code-ide-mcp-http-server
(define-error 'json-rpc-error "JSON-RPC error" 'error)
;; Only initialize/tools-list/tools-call are implemented upstream, but Claude
;; Code probes resources and prompts after initialize. Answer with empty
;; sets instead of falling through to "Method not found". Empty vector and
;; hash-table rather than nil, since `json-encode' renders nil as "null".
(defadvice! bhw/claude-code-ide-mcp-answer-probes-a (fn method params)
"Answer the MCP capability probes upstream's `--dispatch' doesn't implement."
:around #'claude-code-ide-mcp-http-server--dispatch
(pcase method
("resources/list" '((resources . [])))
("resources/templates/list" '((resourceTemplates . [])))
("prompts/list" '((prompts . [])))
("ping" (make-hash-table :test 'equal))
(_ (funcall fn method params))))
;; Upstream parses tool-call bodies with `json-parse-string' and no
;; :false-object, so JSON false arrives as the truthy keyword :false and
;; null as :null; a boolean argument explicitly passed as false would
;; otherwise read as t. Normalize at the one choke point every tool
;; call's arguments pass through.
(defadvice! bhw/claude-mcp-decode-json-atoms-a (args)
"Map JSON false/null atoms to nil in validated tool arguments."
:filter-return #'claude-code-ide-mcp-http-server--validate-args
(mapcar (lambda (v) (if (memq v '(:false :null)) nil v)) args)))
Lexic Config
https://web.archive.org/web/20230622042529/http://download.huzheng.org/
mkdir /home/ben/.stardict/
ln -s /home/ben/project-jerome/400-philology/420-english-anglosaxon-languages/.stardict/dic /home/ben/.stardict/
;;---------------------------------------------------------------------------
(map! :leader
:desc "lexic-search-word-at-point" "sx" #'lexic-search-word-at-point
:desc "lexic-search" "sX" #'lexic-search)
(after! lexic
(map! :map lexic-mode-map
"SPC" #'doom/leader
"j" #'evil-next-line
"k" #'evil-previous-line
"h" #'evil-backward-char
"l" #'evil-forward-char
"q" #'lexic-return-from-lexic
"RET" #'lexic-search-word-at-point
"a" #'outline-show-all
"o" #'lexic-toggle-entry
"d" #'lexic-next-entry
"u" #'lexic-previous-entry
"p" #'lexic-search-history-backwards
"n" #'lexic-search-history-forwards)
(setf lexic-dictionary-specs '
(("Webster's Revised Unabridged Dictionary (1913)"
:short "===========================================================\n Webster's Revised Unabridged Dictionary (1913)\n==========================================================="
:formatter lexic-format-webster
:priority 1)
("Soule's Dictionary of English Synonyms"
:short "===========================================================\n Soule's Dictionary of English Synonyms (1871)\n==========================================================="
:formatter lexic-format-soule
:priority 2)
("Online Etymology Dictionary"
:short "===========================================================\n Online Etymology Dictionary (2000)\n==========================================================="
:formatter lexic-format-online-etym
:priority 3)
("Oxford English Dictionary 2nd Ed P1"
:short "===========================================================\n Oxford English Dictionary 2nd Ed. (1989)\n==========================================================="
:formatter lexic-format-online-etym
:priority 4)
("Oxford English Dictionary 2nd Ed P2"
:short "===========================================================\n Oxford English Dictionary 2nd Ed. (1989)\n==========================================================="
:formatter lexic-format-online-etym
:priority 5)
("latin-english"
:short "===========================================================\n Latin > English\n==========================================================="
:formatter lexic-format-online-etym
:priority 6))))
Lexic Installation & Background
You’re probably using the wrong dictionary. The first place I looked was Oleh Krehel’s defined word package. It does not allow you to query multiple dictionaries/thesaurus. A couple of other Emacs users were similiarly inspired: Webster and Emacs | Irreal. Similarly, these two later posts are also related: The Webster 1913 Dictionary | Irreal Zamansky 56: Dictionaries and Thesauri | Irreal. Following Zamansky’s advice, if you go to Melpa.org and search for the word dictionary a couple of packages will pop up.Out of the 12 the pop-up, only a couple are relevant to our particular use case.Let’s first go to the process of elimination and list a couple of unsuitable packages.
Helm dictionary is a bit limited in its scope, as we can see per issue number insert link, that it does not support the DICT format for dictionaries. The package that requires the least fiddling around with DV dictionary package. There is just one minor problem, this dictionary package out-of-the-box is configured to query the online server DICT.org for results. If you are okay with being connected to the Internet, then your search would stop here. However if you insist on having an off-line dictionary as I do, will have to go a bit further. You have 2 options. Install you own DICT server, or use SDCV. DICT standard specification can be found at:RFC 2229 - A Dictionary Server Protocol, and the website the dictionary.el tool queries is dict.org. SDCV main website can be found StarDict - The best dictionary program in linux and windows . The downloads can be found here StarDict Dictionaries – 星际译王词库. It is much easier to use SDCV and it does not require configuring your own server.
There is a fork of the DICT standard format dictionary called StarDICT. Installing the pre-built package on Debian would be
sudo apt install sdcv
Download https://tecosaur.com/resources/config/stardict.tar.gz and extract it to ~/.stardict/dic/. You can test your sdcv installation at this point in the terminal with
sdcv word
- Construct lexic-dictionary-specs Make the dictionary known to lexic.el
Now inside the newly extracted directory (you may need to give yourself read/write permissions) will be the .ifo file.
Lets install lexic and customize lexic-dictionary-specs.
It should be noted that dictionaries DO NOT need to have a format function. You can most definitely use the dictionary without. i.e.
The currently pre-defined format functions are:
If your use case is not included in the above, feel free to take look into lexic.el and then open an Issue or submit a pull request.
- Lexic.el uses outline.el (think org-mode), so here are the usage commands inside lexic mode
Endnotes: recent article on stardict https://owenh.net/stardict Thank you to: removed ;; Quotations (“Oxford Dictionary of Quotations” :short “From Oxford Dictionary of Quotations” :priority 10) ;; For Learning. Because I am always learning (“Oxford Advanced Learner’s Dictionary 8th Ed.” :short “From the Oxford Advanced Learner’s Dictionary 8th Ed.” :priority 5) (“Oxford Collocations Dictionary 2nd Ed. (En-En)” :short “From the Oxford Collocations Dictionary 2nd Ed.” :priority 6)
Check out lexic-dictionary-help
Lexic Order of Dictionaries
- The golden mean, the dictionary that has the first say, the lovingly crafted life’s work of Noah Webster: Webster Unabridged 1913
- Thesauri - Synonyms
- Soule’s. For the organized hierarchy
- Moby Thesaurus II. Remarkable compilation of a single man. Claims to be the largest thesaurus.
- WordNet 3.0. Leverages the power of computing to map synonyms or word relations. No word, or human, is born into the world alone/self-sufficient. That perfection is reserved God alone. A systemic way of approaching linguistics. Yes, designed for machines so the essence of a definition may remain, but the hard to describe humanity (artistic flavor? humanness?) of a word is lost.
- The accepted authority on the English language, the dictionary that has the last say, the magnum opus: The Oxford English Dictionary 2nd Ed. 1989
Encyclopedia Taken from Dictionary vs. encyclopedia The term lexicon is ambiguous both in prescientific and in linguistic usage, since it may mean either a dictionary or an encyclopedia. The latter two terms are employed in linguistics whenever the distinction matters.
A dictionary provides information on expressions typically words of a language, while an encyclopedia tells one what is known about an object, or objects of a certain kind.1 The contrast is brought out in the following table: Dictionary vs. encyclopedia criterion dictionary encyclopedia object linguistic properties of linguistic units represented by lemmas properties of objects designated by lemmas describes use of linguistic units world knowledge lemmas any word class only nouns
A dictionary gives information on all linguistic aspects of its lemmas, on their significans, significatum, grammatical properties and aspects of usage. Focusing on the purely semantic aspect of a lexicon, we may say that a dictionary gives information about the sigificata of its lemmas, while an encyclopedia gives information on their denotata.
The lemmas of an encyclopedia are nouns. However, contrary to a terminological dictionary, not only common nouns, but also proper nouns can be lemmas.
The bilingual dictionary is particularly apt to illustrate the difference between the two kinds of information provided by a dictionary and an encyclopedia: If you encounter the German word Reiher and don’t know what it means, you consult a German-English dictionary. It tells you that Reiher means ‘heron’. From that you may infer that, mutatis mutandis, the German word Reiher is used like the English word heron. You have not been told what a Reiher (or a heron) is. If you don’t know, the dictionary will not help you; you will have to consult an encyclopedia.
In the individual mind, the two kinds of information may, to some extent, be independent. Suppose you have never seen a heron and have no knowledge about it except that it is a large bird. So much (knowing a hyperonym) would be purely linguistic knowledge. It would enable you to actively and passively use the word heron without arousing anybody’s attention; unless of course you mix in ornithological circles.
An example of an English dictionary is The New Merriam-Webster Dictionary (Springfield, MA: Merriam-Webster). An example of an English encyclopedia is the Encyclopaedia Britannica (London: International). Orthographic dictionaries, i.e. ones that only show how a word is spelt, may be the most widespread kind of dictionary, but are nevertheless untypical of the linguistic concept of dictionary since they lack most kinds of information that make up knowledge of the language in question.
The distinction between dictionary and encyclopedia is a theoretically-based distinction that is practically useful: If I want to know what Reiher means, I do not want to find an article on herons; and if I need information on herons, I do not need to be told that heron is a common noun whose plural may be herons or heron. However, the psychological reality of the distinction, i.e. whether it has a neat counterpart in the mental lexicon, is less clear. Much of what we know about the meaning of a word is probably intertwined with knowledge about the object it designates. There are therefore hybrid forms between dictionary and encyclopedia, sometimes explicitly called ‘encyclopedic dictionary’.
- The Britannica Concise (2006)
- Specialized encyclopedic dictionaries
- Bouvier’s Law
- Elements Database
Biblio Config
- <–Project Jerome
- Citations in org-mode: Org-cite and Citar | Kristoffer Balintona
- Org-Cite 2021 Release Notes
- Citation handling in Emacs
;;---------------------------------------------------------------------------
(map! :leader
:desc "citar-open" "s SPC" #'citar-open)
(after! citar
(setf
citar-bibliography (list (concat +project-maria-dir+ "project-jerome.bib"))
citar-notes-paths (list (concat +project-maria-dir+ "bibtex-notes"))
citar-library-paths
;; Excluded trees are pruned DURING the walk (the PREDICATE arg stops
;; recursion), not filtered after it: email-archive alone holds tens of
;; thousands of files that the old post-hoc filter walked and then threw
;; away. A pruned directory is still returned when INCLUDE-DIRECTORIES is
;; set, hence the final filter also checks membership.
(let* ((base (expand-file-name "~/project-jerome"))
(excluded (mapcar (lambda (d)
(directory-file-name (expand-file-name d)))
'("~/project-jerome/email-archive"
"~/project-jerome/org-attach-data"
"~/project-jerome/keepass-database")))
(prune (lambda (d)
(not (member (directory-file-name (expand-file-name d))
excluded)))))
(cons base
(cl-delete-if-not
(lambda (f) (and (funcall prune f) (file-directory-p f)))
(directory-files-recursively base "" t prune))))))
;;---------------------------------------------------------------------------
;; Org Cite + biblatex-chicago (CMOS 17 Notes & Bibliography)
(after! oc
(setf org-cite-global-bibliography
(list (concat +project-maria-dir+ "project-jerome.bib"))
org-cite-export-processors
'((latex biblatex)
(t basic))
org-cite-insert-processor 'citar
org-cite-follow-processor 'citar
org-cite-activate-processor 'citar))
(after! ox-latex
;; Per-document opt-in: #+LATEX_CLASS: chicago-nb
(add-to-list
'org-latex-classes
'("chicago-nb"
"\\documentclass[12pt]{article}
[NO-DEFAULT-PACKAGES]
\\usepackage{graphicx}
\\usepackage{longtable}
\\usepackage{wrapfig}
\\usepackage{rotating}
\\usepackage[normalem]{ulem}
\\usepackage{amsmath}
\\usepackage{amssymb}
\\usepackage{capt-of}
\\usepackage[margin=1in]{geometry}
\\usepackage{setspace}\\doublespacing
\\usepackage{fontspec}
\\setmainfont{TeX Gyre Termes}
\\usepackage{csquotes}
\\usepackage[notes,backend=biber,babel=other,autolang=hyphen,strict]{biblatex-chicago}
\\addbibresource{/home/ben/project-maria/project-jerome.bib}
\\usepackage{hyperref}
\\makeatletter
\\let\\@course\\@empty
\\newcommand{\\course}[1]{\\gdef\\@course{#1}}
\\renewcommand{\\maketitle}{%
\\begin{titlepage}%
\\thispagestyle{empty}%
\\begin{center}
\\vspace*{0.28\\textheight}
\\@title\\par
\\vspace*{\\stretch{2}}
\\@author\\par
\\ifx\\@course\\@empty\\else\\vspace{1em}\\@course\\par\\fi
\\vspace{1em}\\@date\\par
\\vspace*{\\stretch{1}}
\\end{center}%
\\end{titlepage}}
\\makeatother
\\defbibheading{bibliography}[\\bibname]{%
\\clearpage
\\begin{center}\\textbf{#1}\\end{center}
\\markboth{#1}{#1}}
[NO-PACKAGES]
[EXTRA]"
("\\section{%s}" . "\\section*{%s}")
("\\subsection{%s}" . "\\subsection*{%s}")
("\\subsubsection{%s}" . "\\subsubsection*{%s}")
("\\paragraph{%s}" . "\\paragraph*{%s}")
("\\subparagraph{%s}" . "\\subparagraph*{%s}")))
(defun bhw/org-latex-export-to-pdf-chicago-nb (orig-fn &rest args)
"Run lualatex+biber when the current org buffer uses LATEX_CLASS chicago-nb.
The hook variant runs inside the export copy buffer, so a `setq-local'
there is discarded before `org-latex-compile' reads the variable.
A dynamic `let' binding here propagates through the whole export pipeline."
(if (save-excursion
(goto-char (point-min))
(re-search-forward
"^#\\+LATEX_CLASS:[ \t]+chicago-nb\\b" nil t))
(let ((org-latex-pdf-process
'("latexmk -f -pdflua -interaction=nonstopmode -output-directory=%o %f"
"latexmk -c -output-directory=%o %f")))
(apply orig-fn args))
(apply orig-fn args)))
(advice-add 'org-latex-export-to-pdf :around
#'bhw/org-latex-export-to-pdf-chicago-nb))
1.3.12. Languages Config
Ledger Config
<- ledgerrc <- Personal Economics
- org babel and ledger
- conquering your finances with emacs and ledger
- CSV Import · ledger/ledger Wiki · GitHub
- My Emacs Ledger reporting configuration - philnewton.net
- GitHub - captainflasmr/bank-buddy: Your Financial Analysis Companion for Emacs
hledger vs ledger: https://www.reddit.com/r/plaintextaccounting/comments/1381lfo/hledger_equivalent_to_ledger_payee_subdirective/
https://daryl.wakatara.com/tracking-your-finances-with-reckon-and-ledger/
Command line interface (CLI) double entry accounting system.
- Install Ledger CLI
- Install Reckon to convert
.csvstatements to Ledger’s journal format. - Add
financelayer to the dotspacemacs file. - Start by reading the Documentation. Sections 1-4.
- Refer to the .ledgerrc configuration file.
- Download
.csvfile from your bank or financial institution and runReckonagainst it. - Copy over the journal entries printed to stdout into your ledger journal.
How ledger-autosync does payee matching and how ledger can use regular expressions to match unknown payee fields. Say you are paying off loans that are classed under liabilities but also want to include it in an expense, you can look at Virtual Postings.
Quick reports to run:
# See expenses divided by month and accounts
ledger -M register Income:* Expenses:*
# See expenses divided by month and accounts
ledger balance --real
Common Lisp Config
<- Common Lisp Environment Setup
;;---------------------------------------------------------------------------
(after! sly
(setq sly-contribs (delq 'sly-quicklisp sly-contribs)))
(setf common-lisp-hyperspec-root
(concat "file://" +project-jerome-dir+
"000-generalities-information-computers/000-computer-science/HyperSpec/"))
;; https://emacs.stackexchange.com/questions/62536/what-does-making-browse-url
;; -browser-function-local-to-eww-while-let-bound-m
(advice-add 'hyperspec-lookup
:around
(lambda (orig-fun &rest args)
(setq-local browse-url-browser-function 'eww-browse-url)
(apply orig-fun args)))
Org Mode Config
- Org Mode Workshop Example Config
- Particle Physics Researcher - Advanced Emacs org-mode examples and cookbook
- Worg - Org Mode Community
- Org Babel & Org mode cookbook
- Setup Emacs on WSL 2
- Phil Newton Org mode use cases
- Official Org-Mode Documentation
- Org-mode features You May Not Know - Bastien Guerry
- Time Clocking See Org Manual 8.6, Timers (The Org Manual). We can insert notes relative to an arbitrary timer so starting both the audio (lecture) recording at the same time as the org timer allows us to take notes with reference to where exactly in a lecture it was found. See org-timer-item.
- Tree Manipulation Look at options under
, swhile in org mode for tree options. Of note is org-kill-note-or-show-branches (bound to C-c C-k). See also Org Manual 2.5 Sparse trees. This next tip is amazing for creating multiple subtrees with timestamps shifted. Try callingorg-clone-subtree-with-time-shift. The first thing that comes to mind, is the creation of routine events. - Internal linking hyperlinks - Radio targets in external org mode file, for glossary applicatio… Org-mode Hidden Gems - 03 Hyperlinks
It is worth noting that packages like org gcal, which do not come in a pre-configured spacemacs layer, are kept separate from this org mode config tree. Packages which do contain some boilerplate configuration, such as org re reveal, can be found as a subtree to this heading. As well as org-plus-contrib packages.
Text manipulation > or < to indent visually selected text M-RET to create next list item.
;;---------------------------------------------------------------------------
(after! evil-org
(remove-hook 'org-tab-first-hook #'+org-cycle-only-current-subtree-h))
(after! org
(add-hook! 'org-mode-hook (electric-indent-local-mode -1))
(add-hook! 'org-mode-hook
(add-hook 'completion-at-point-functions #'cape-file -10 t))
(advice-remove #'org-mark-ring-push #'doom-set-jump-a)
(setf org-adapt-indentation nil
org-startup-indented nil
;; Be strict about extensions: match ".org" and ".org.gpg" only.
org-id-extra-files (directory-files-recursively +project-maria-dir+ "\\.org\\(\\.gpg\\)?$"))
(require 'cl-lib)
;; Adapted snippet (originally `afs/org-replace-link-by-link-description').
(defun bhw/org-replace-link-by-link-description ()
"Replace an org link by its description or if empty its address"
(interactive)
(if (org-in-regexp org-link-bracket-re 1)
(save-excursion
(let ((remove (list (match-beginning 0) (match-end 0)))
(description
(if (match-end 2)
(org-match-string-no-properties 2)
(org-match-string-no-properties 1))))
(apply 'delete-region remove)
(insert description)))))
(map! :leader
:desc "Agenda" "a" #'bhw/custom-agenda))
Org Agenda Config
- Getting things done: the art of stress-free productivity
- org-gtd.el/org-gtd.org at master · Trevoke/org-gtd.el · GitHub
- Read Getting things Done by David Allen
- Orgmode for GTD
- Org Mode - Organize Your Life In Plain Text! This is how the configuration is structured.
- GitHub - et2010/org-gtd: Private spacemacs layer for GTD.
- Get Things Done with Emacs
- GitHub - juanmanuelferrera/gtd-E
| Key | Effect |
|---|---|
| SPC m s r | org-agenda-refile |
.org in project maria that I consider to be agenda files:
- hq.org
- contacts.org
- someday.org
From this, things are pretty self explanatory. Remember Meetings are given an active time stamp NOT with a :scheduled: date-stamp. Org Gcal solves calendar synchronization between laptop and phone.
Noteworthy Todo Keyword is PROG (formerly IN-MOTION) for IN-PROGRESS. Author has found this a nice feature for two reasons:
- Obvious reason is that it reminds you what you were working on in that
particular place/context.
- The interesting reason is that it restricts the amount of open loops,
as per little’s law. Credit must be given here:. What qualifies as “too many” open loops is an individual quality, but a lower time is better.
There exists 3 priorities, A > B > C.
- If an item has a real deadline = B priority. Real deadline means not a self-imposed deadline. Those are meaningless.
- If an item needs to be done As Soon As Possible (ASAP) = A priority.
- Otherwise = C priority.
Eisenhower matrix can be imitated with a combination of deadlines, scheduled and priorities.
Double-booking is caught at the moment the timestamp is written rather than
later by eye. C-c C-c on an active timestamp runs
org-conflict-check-timestamp-or-range, which compares the time against
everything in org-agenda-files and, on an overlap, opens a review prompt
offering the earliest non-conflicting slot.
| Key | Effect |
|---|---|
C-c C-c |
check the timestamp at point for conflicts |
C-u 0 C-c C-c |
bypass the check, normalize the timestamp only |
, d s |
schedule, then check the new SCHEDULED stamp |
, d d |
set a deadline, then check the new DEADLINE stamp |
C-u , d s, C-u , d d |
remove the stamp, no check |
C-u 50 M-x org-conflict |
check an arbitrary time, assuming a 50 minute event |
org-schedule and org-deadline write into the entry’s planning line and
never run org-ctrl-c-ctrl-c-hook, so for a long time , d s and , d d
silently escaped the check that C-c C-c applies to a body timestamp — the one
path by which most appointments actually get booked.
org-conflict-after-schedule and org-conflict-after-deadline close
that hole with :after advice that walks to the freshly written stamp and runs
the same check. Keying that advice on this-command does not work: the date
prompt’s minibuffer runs a nested command loop whose every keystroke overwrites
this-command (and real-this-command), so both read as exit-minibuffer by
the time the advice fires. The advice instead inspects the arguments
org-schedule / org-deadline received — an explicit TIME means a
programmatic call, a C-u ARG means stamp removal, both skipped.
Rescheduling a single entry from the agenda via org-agenda-schedule /
org-agenda-deadline passes TIME nil, so it is checked like any other
booking. Agenda bulk actions (B s, B d) read the date once and hand it
down as an explicit TIME, so a bulk reschedule stays silent instead of popping
a review prompt per marked entry. One cosmetic wrinkle: when a conflict found
from the agenda is resolved by replacing the stamp, the agenda line still
shows the time first entered — refresh with g to see the resolved stamp.
org-conflict excludes only the single stamp under point from its own search, so
an entry carrying both a timed SCHEDULED and a timed DEADLINE would otherwise
report itself as its own conflict. org-conflict-drop-same-entry filters
any hit whose agenda marker falls inside the entry being checked.
Only active timestamps carrying a time-of-day participate — bare
<2026-08-08 Sat> and inactive [...] stamps are invisible to it, and a
date-only , d s or , d d is skipped outright. A rangeless stamp like
<2026-08-08 Sat 10:30> has no duration, so the check prompts for one; that is
deliberate and mirrors the org-agenda-default-appointment-duration of 0 that
bhw/org-conflict-agenda-bindings-a binds around every scan.
Scheduling with an explicit range — 10:30-11:15 at the org-schedule date
prompt — answers that question up front and skips the duration prompt.
The package is org-conflict.el by Thomas Plass (emacs-orgmode, 2019-04-04, v0.9).
It was never published to ELPA or MELPA, so it is vendored into
private-packages/ as a mailing-list attachment; the provenance header in that
file records the source, the checksum, the handful of local edits made for
Emacs 30 / Org 9.8, and the local additions — the planning-line checks and the
self-conflict filter above live in that file, only their activation here.
Unlike org-agenda-find-free-time.el, it does not scrape
the rendered agenda buffer — it binds its own prefix format and calls
org-agenda-get-day-entries directly, so bhw/org-agenda-format-date-aligned
and the custom org-agenda-prefix-format below cannot break it.
;;---------------------------------------------------------------------------
(after! org
(org-clock-persistence-insinuate)
;; `org-clock-persistence-insinuate' only writes the save file from
;; `kill-emacs-hook'. A daemon that goes down with the WSL VM, to `pkill',
;; or to a crash never runs it, so `org-clock-history' comes back empty --
;; and an empty history stays empty, because the next graceful exit then has
;; nothing to save either. Write after every clock event instead: the file
;; is a few hundred bytes, so the cost is noise next to the clock-in itself.
(defun bhw/org-clock-save-h ()
"Persist clock history (and any running clock) after a clock event.
`org-clock-persist-query-save' is pinned off: a save that prompts would
turn every clock-in into a question."
(let ((org-clock-persist-query-save nil))
(with-demoted-errors "bhw/org-clock-save-h: %S" (org-clock-save))))
(dolist (hook '(org-clock-in-hook org-clock-out-hook org-clock-cancel-hook))
(add-hook hook #'bhw/org-clock-save-h))
(load! "private-packages/org-agenda-find-free-time.el")
(defun bhw/org-mode-ask-effort ()
"Ask for an effort estimate when clocking in if none exists."
(unless (org-entry-get (point) "Effort")
(let ((effort
(completing-read
"Effort: "
(org-property-get-allowed-values (point) "Effort"))))
(unless (equal effort "")
(org-set-property "Effort" effort)))))
(add-hook 'org-clock-in-prepare-hook #'bhw/org-mode-ask-effort)
(defun bhw/org--read-duration (prompt &optional allow-empty)
"Read and validate an org duration string (e.g. \"1:30\", \"30:00\").
Re-prompts on invalid input. If ALLOW-EMPTY is non-nil, empty input
returns nil instead of re-prompting."
(catch :done
(while t
(let ((input (string-trim (read-string prompt))))
(cond
((string-empty-p input)
(when allow-empty (throw :done nil))
(message "Enter a duration like 30:00 (C-g to abort)")
(sit-for 1))
((org-duration-p input) (throw :done input))
(t (message "%S is not a valid duration (use H:MM)" input)
(sit-for 1)))))))
(defun bhw/org--entry-marker ()
"Return a marker to the org heading for the entry at point.
Works in org-mode buffers and in agenda buffers (via `org-hd-marker')."
(cond
((derived-mode-p 'org-agenda-mode)
(or (org-get-at-bol 'org-hd-marker)
(org-agenda-error)))
((derived-mode-p 'org-mode)
(org-with-wide-buffer
(org-back-to-heading t)
(point-marker)))
(t (user-error "Not in an org-mode or org-agenda buffer"))))
(defun bhw/org-schedule-with-effort ()
"Set a day-chunk `Effort' (and optionally `TOTAL_EFFORT'), then schedule.
`Effort' holds the chunk planned for the scheduled day; `TOTAL_EFFORT'
holds the whole-task estimate. Shows total, clocked-so-far, and
remaining time in the prompt, asks for the day chunk (completing over
Effort_ALL), then invokes the normal interactive date picker
\(`org-schedule', or `org-agenda-schedule' in agenda buffers so the
agenda line updates)."
(interactive)
(let* ((agenda-p (derived-mode-p 'org-agenda-mode))
(marker (bhw/org--entry-marker)))
(with-current-buffer (marker-buffer marker)
(org-with-wide-buffer
(goto-char marker)
(let* ((total (or (org-entry-get nil "TOTAL_EFFORT")
(when (y-or-n-p "No TOTAL_EFFORT. Set one? ")
(let ((d (bhw/org--read-duration
"Total effort (H:MM, empty to skip): " t)))
(when d (org-entry-put nil "TOTAL_EFFORT" d))
d))))
(clocked (org-clock-sum-current-item))
(status
(if total
(let ((rem (- (org-duration-to-minutes total) clocked)))
(format "total %s, clocked %s, remaining %s"
total
(org-duration-from-minutes clocked)
(if (< rem 0)
(format "-%s (over!)"
(org-duration-from-minutes (- rem)))
(org-duration-from-minutes rem))))
(format "no total, clocked %s"
(org-duration-from-minutes clocked))))
(chunk (completing-read
(format "Day-chunk Effort (%s): " status)
(org-property-get-allowed-values (point) "Effort")
nil nil nil nil
(org-entry-get nil "Effort"))))
(when (org-string-nw-p chunk)
(unless (org-duration-p chunk)
(user-error "%S is not a valid duration (use H:MM)" chunk))
(org-entry-put nil "Effort" chunk)))))
(if agenda-p
(call-interactively #'org-agenda-schedule)
(call-interactively #'org-schedule))))
;;; Clocking. `bhw/clock-in-dwim' is the only clock command bound: bare, it does
;;; the obvious thing for point; with a prefix it opens the full menu.
(defvar bhw/org-clock-stale-hours 3
"Hours after which a running clock is treated as forgotten.
Past this `bhw/clock-in-dwim' resolves the clock interactively rather than
clocking out, so a clock left running overnight cannot quietly record
its whole span as worked time.")
(defvar bhw/org-clock-confirm-switch t
"When non-nil, confirm before moving the clock to the task at point.
Clocking a new task closes the running task's CLOCK line, so a stray
keypress on the wrong heading writes a real, wrong record.")
(defun bhw/org-clock-zombie-p ()
"Non-nil when Org's clock display has outlived the clock itself.
`org-clock-out' can die partway through: the clocking buffer is killed
or replaced underneath it \(a Syncthing conflict resolution, a revert,
a rename), it signals \"Clock start time is gone\", and the teardown
below that point never runs. `org-clock-marker' is left pointing at a
dead buffer, so `org-clocking-p' is nil and every later
`org-clock-out' answers \"No active clock\" -- while the mode-line
string and its update timer keep ticking, and the file keeps an
unclosed CLOCK line.
A clean clock-out cancels those timers and pulls `org-mode-line-string'
out of `global-mode-string', so finding them still installed with no
live clock is the signature. `org-clock-start-time' and
`org-mode-line-string' both survive a normal clock-out and cannot be
used for this."
(and (not (org-clocking-p))
(or (memq 'org-mode-line-string global-mode-string)
(timerp org-clock-mode-line-timer)
(timerp org-clock-idle-timer)
(org-string-nw-p org-clock-current-task))))
(defun bhw/org-clock-clear-zombie ()
"Tear down clock display state left behind by a failed `org-clock-out'.
Cancels the mode-line and idle timers, unhooks `org-mode-line-string'
from `global-mode-string', blanks the clock markers, and returns the
stale heading \(or nil). This is the cleanup half of
`org-clock-cancel' minus its `user-error'; it edits no file, so the
dangling CLOCK line still has to be closed with `org-resolve-clocks'."
(let ((heading (org-string-nw-p org-clock-heading)))
(when (timerp org-clock-mode-line-timer)
(cancel-timer org-clock-mode-line-timer))
(when (timerp org-clock-idle-timer)
(cancel-timer org-clock-idle-timer))
(setq org-clock-mode-line-timer nil
org-clock-idle-timer nil
org-clock-current-task nil
org-mode-line-string ""
global-mode-string (delq 'org-mode-line-string global-mode-string))
(move-marker org-clock-marker nil)
(move-marker org-clock-hd-marker nil)
(force-mode-line-update t)
(and heading (substring-no-properties heading))))
(defun bhw/org-clock--task-at-point ()
"Return a marker to the Org task at point, or nil when there is none.
Soft counterpart of `bhw/org--entry-marker': agenda lines and headings
answer with a marker; a non-Org buffer, or text above the first
heading, answers nil instead of signalling."
(let ((marker (ignore-errors (bhw/org--entry-marker))))
(and (markerp marker) (marker-buffer marker) marker)))
(defun bhw/org-clock--clocking-task-p (marker)
"Non-nil when MARKER points at the heading the clock is running on.
Compared through `org-base-buffer' so an indirect buffer counts as the
same task."
(and (markerp marker)
(marker-buffer marker)
(org-clocking-p)
(marker-buffer org-clock-hd-marker)
(eq (org-base-buffer (marker-buffer marker))
(org-base-buffer (marker-buffer org-clock-hd-marker)))
(= (marker-position marker) (marker-position org-clock-hd-marker))))
(defun bhw/org-clock--stale-p ()
"Non-nil when the running clock has run past `bhw/org-clock-stale-hours'."
(and (org-clocking-p)
(>= (float-time (time-since org-clock-start-time))
(* bhw/org-clock-stale-hours 60 60))))
(defun bhw/org-clock--elapsed ()
"Return the running clock's elapsed time as an H:MM string, or nil."
(and (org-clocking-p)
(org-duration-from-minutes
(floor (/ (float-time (time-since org-clock-start-time)) 60)))))
(defun bhw/org-clock--heading-at (marker)
"Return MARKER's heading text without properties, or nil."
(and (markerp marker)
(marker-buffer marker)
(org-with-point-at marker
(substring-no-properties (org-get-heading t t t t)))))
(defun bhw/org-clock-clear-stale-display (&optional resolve)
"Clear a clock display that outlived its clock \(`bhw/org-clock-zombie-p').
With RESOLVE -- always, when called interactively -- follow up with
`org-resolve-clocks', which is what actually closes the CLOCK line the
failed clock-out left dangling in the file."
(interactive (list t))
(if (not (bhw/org-clock-zombie-p))
(message "Clock display is already clean")
(let ((heading (bhw/org-clock-clear-zombie)))
(message "Stale clock display cleared%s%s"
(if heading (format " (%s)" heading) "")
(if resolve " -- resolving dangling clocks..." ""))
(when resolve (org-resolve-clocks)))))
(defun bhw/org-clock-in-at-point ()
"Clock into the Org task at point.
A running clock is closed first, by `org-clock-in' itself; that switch
is confirmed unless `bhw/org-clock-confirm-switch' is nil. In the
agenda this goes through `org-agenda-clock-in' so the line updates in
place."
(interactive)
(let ((marker (bhw/org-clock--task-at-point)))
(unless marker (user-error "No Org task at point"))
(when (and (org-clocking-p)
bhw/org-clock-confirm-switch
(not (bhw/org-clock--clocking-task-p marker))
(not (y-or-n-p
(format "Clocked into \"%s\" for %s; switch to \"%s\"? "
(substring-no-properties org-clock-heading)
(bhw/org-clock--elapsed)
(bhw/org-clock--heading-at marker)))))
(user-error "Clock left on \"%s\""
(substring-no-properties org-clock-heading)))
(if (derived-mode-p 'org-agenda-mode)
(org-agenda-clock-in)
(org-with-point-at marker (org-clock-in)))))
(defun bhw/org-clock-pick ()
"Clock in by picking from recent clock history."
(interactive)
(org-clock-in '(4)))
(defun bhw/org-clock-out-dwim ()
"Clock out, through the agenda command when point is in the agenda."
(interactive)
(unless (org-clocking-p)
(user-error "No active clock%s"
(if (bhw/org-clock-zombie-p)
" (stale display -- clear it with `bhw/clock-in-dwim')"
"")))
(if (derived-mode-p 'org-agenda-mode)
(org-agenda-clock-out)
(org-clock-out)))
(defun bhw/org-clock-cancel-dwim ()
"Cancel the running clock, discarding its time, after confirming.
With no live clock but a stale display, clear the display instead --
that is the case `org-clock-cancel' answers with a bare `user-error'."
(interactive)
(cond
((bhw/org-clock-zombie-p) (bhw/org-clock-clear-stale-display t))
((not (org-clocking-p)) (user-error "No active clock"))
((not (yes-or-no-p (format "Discard %s clocked on \"%s\"? "
(bhw/org-clock--elapsed)
(substring-no-properties org-clock-heading))))
(message "Clock kept"))
((derived-mode-p 'org-agenda-mode) (org-agenda-clock-cancel))
(t (org-clock-cancel))))
(defun bhw/org-clock--menu-description ()
"Current clock state, as the header line of `bhw/org-clock-menu'."
(cond
((bhw/org-clock-zombie-p)
(format "STALE DISPLAY -- %s -- no live clock (z, then r)"
(or (org-string-nw-p org-clock-heading) "unknown task")))
((org-clocking-p)
(format "%s on \"%s\"%s"
(bhw/org-clock--elapsed)
(substring-no-properties org-clock-heading)
(let ((effort (org-with-point-at org-clock-hd-marker
(org-entry-get nil "Effort"))))
(if effort (format " of %s" effort) ""))))
(t
(let ((last (bhw/org-clock--heading-at (car org-clock-history))))
(if last (format "No clock running -- last: \"%s\"" last)
"No clock running")))))
;; Defined behind `with-eval-after-load' rather than inline: the body has to
;; macroexpand `transient-define-prefix', and doing that at config-load time
;; would drag transient into startup just to define a menu that is rarely
;; opened. `bhw/clock-in-dwim' requires transient at the point of use.
(with-eval-after-load 'transient
(transient-define-prefix bhw/org-clock-menu ()
"Every Org clock action on one screen."
[:description bhw/org-clock--menu-description
["Clock"
("i" "in -- task at point" bhw/org-clock-in-at-point)
("h" "in -- pick from history" bhw/org-clock-pick)
("l" "in -- resume last" org-clock-in-last)
("o" "out" bhw/org-clock-out-dwim)
("c" "cancel -- discard time" bhw/org-clock-cancel-dwim)]
["Task"
("g" "goto running or last" org-clock-goto)
("e" "modify effort estimate" org-clock-modify-effort-estimate)]
["Repair & report"
("r" "resolve dangling clocks" org-resolve-clocks)
("z" "clear stale display" bhw/org-clock-clear-stale-display)
("R" "insert clock report" org-clock-report)
("d" "display times in buffer" org-clock-display)
("a" "analyze clocks" org-clock-analyzer)
("p" "project forecast at point" org-clock-analyzer-project-at-point)]]))
(defun bhw/clock-in-dwim (&optional arg)
"The only Org clock command: with no prefix, do what point implies.
- Stale display and no live clock \(`bhw/org-clock-zombie-p'): clear
the display, then resolve the CLOCK line left dangling in the file.
- Clock running past `bhw/org-clock-stale-hours': resolve it, rather
than record a span nobody actually worked. Checked before the task
at point, so switching tasks cannot bank a forgotten clock.
- Point on an Org task other than the running one \(heading or agenda
line): clock into it, closing the running clock first.
- Clock running: clock out.
- Nothing running, no task at point: pick from clock history.
With any prefix ARG, open `bhw/org-clock-menu' instead, which reaches
the rest: resume last, goto, cancel, resolve, effort, report."
(interactive "P")
(cond
(arg
(unless (require 'transient nil t)
(user-error "transient is unavailable; cannot open the clock menu"))
(call-interactively #'bhw/org-clock-menu))
((bhw/org-clock-zombie-p)
(bhw/org-clock-clear-stale-display t))
((bhw/org-clock--stale-p)
(message "Clock has been running over %s hours -- resolving..."
bhw/org-clock-stale-hours)
(org-resolve-clocks))
((let ((marker (bhw/org-clock--task-at-point)))
(and marker (not (bhw/org-clock--clocking-task-p marker))))
(bhw/org-clock-in-at-point))
((org-clocking-p) (bhw/org-clock-out-dwim))
(t (bhw/org-clock-pick))))
(defun bhw/verify-refile-target ()
"Exclude todo keywords with a done state from refile targets"
(not (member (nth 2 (org-heading-components)) org-done-keywords)))
(defun bhw/org-agenda-format-date-aligned (date)
"Format DATE for the agenda as \"Monday July 27 2026 W31\".
Like `org-agenda-format-date-aligned', but with the month name before
the day number. Field widths are kept fixed so date headers stay
column-aligned; the ISO week is only appended on Mondays."
(require 'cal-iso)
(let* ((dayname (calendar-day-name date))
(day (cadr date))
(day-of-week (calendar-day-of-week date))
(monthname (calendar-month-name (car date)))
(year (nth 2 date))
(iso-week (org-days-to-iso-week
(calendar-absolute-from-gregorian date)))
(weekstring (if (= day-of-week 1)
(format " W%02d" iso-week)
"")))
(format "%-10s %-9s %2d %4d%s"
dayname monthname day year weekstring)))
;; Doom empties `org-modules' for startup speed, so nothing pulls in
;; org-habit and every `:STYLE: habit' entry is treated as an ordinary
;; repeating scheduled TODO -- `org-agenda-get-scheduled' gates all of its
;; habit handling behind (fboundp 'org-is-habit-p). Loading it here is what
;; gives the habit settings below (and the Calendar exclusion) any effect.
(require 'org-habit)
;; Press t to change task todo state
(setf
org-agenda-format-date #'bhw/org-agenda-format-date-aligned
org-agenda-files
(list (concat +project-maria-dir+ "hq.org"))
org-agenda-start-day "+0d"
org-use-fast-todo-selection t
org-treat-S-cursor-todo-selection-as-state-change t
;; Require exit notes for modifying a scheduled for deadline date
org-log-reschedule 'time
org-log-redeadline 'note
org-log-done 'time
org-todo-keywords
'((sequence "TODO(t)" "PROJ(p)" "APPT(a)" "PROG(i)"
"WAIT(w@/!)" "|" "DONE(d)" "CXLD(c@/!)"))
org-todo-keyword-faces
'(("PROJ" :foreground "DarkSlateBlue" :weight bold)
("TODO" :foreground "tomato1" :weight bold)
("WAIT" :foreground "orchid3" :weight bold)
("PROG" :foreground "DeepSkyBlue3" :weight bold)
("DONE" :foreground "SpringGreen3" :weight bold)
("APPT" :foreground "tomato3" :weight bold)
("CXLD" :foreground "sienna" :weight bold))
org-agenda-dim-blocked-tasks t
org-habit-graph-column 80
org-agenda-skip-scheduled-if-deadline-is-shown t
org-agenda-skip-deadline-prewarning-if-scheduled 'pre-scheduled
org-agenda-skip-scheduled-if-done t
org-agenda-skip-deadline-if-done t
org-agenda-todo-ignore-scheduled 'future
org-agenda-todo-ignore-deadlines t
org-deadline-warning-days 7
org-enforce-todo-dependencies nil
;; 6) Adding New Tasks Quickly with Org Capture
;; Capture templates for: TODO tasks, Notes, appointments, phone calls, meetings, and org-protocol
;; \n is newline in the template. Functions as RET would in insert mode
;; placing a backslash before " in TRIGGER below to have the string not end
org-capture-templates
`(("t" "Todo Task" entry (file+headline ,(concat +project-maria-dir+ "hq.org") "Inbox") "* TODO [#C] %^{Title}\n:PROPERTIES:\n:EFFORT: %^{0:00|0:10|0:30|1:00|1:30|2:00|2:30|3:00}\n:ASSIGNED: %U\n:END:\n%?" :empty-lines 1)
("a" "Appointment" entry (file+headline ,(concat +project-maria-dir+ "hq.org") "Inbox") "* APPT %^{Title}\nSCHEDULED: %^T\n:PROPERTIES:\n:LOCATION: %^{LOCATION|TBD}\n:EFFORT: %^{0:00|0:10|0:30|1:00|1:30|2:00|2:30|3:00}\n:ASSIGNED: %U\n:END:\n%?" :empty-lines 1)
("j" "Journal Entry" entry (file+headline ,(concat +project-maria-dir+ "hq.org") "Inbox")"* TODO [#C] JOURNAL ENTRY %<Y%YW%V%B%d>\n:PROPERTIES:\n:EFFORT: 0:10\n:ASSIGNED: %U\n:END:\n%?" :empty-lines 1)
("h" "Habit" entry (file+headline ,(concat +project-maria-dir+ "hq.org") "Inbox")"* TODO %?\nSCHEDULED: %t\n:PROPERTIES:\n:STYLE: habit\n:REPEAT_TO_STATE: TODO\n:ASSIGNED: %U\n:END:" :empty-lines 1)
("c" "Contacts" entry (file ,(concat +project-maria-dir+ "contacts.org")) "* %(org-contacts-template-name)\n:PROPERTIES:\n:PHONE: %?\n:EMAIL:\n:ADDRESS:\n:BIRTHDAY:\n:NOTE: Added on: %U\n:END:" :empty-lines 1)
("p" "Project" entry (file ,(concat +project-maria-dir+ "hq.org")) "* PROJ %? [/] [%] %^G\n:PROPERTIES:\n:ASSIGNED: %U\n:END:\n** TODO [#C]\n:PROPERTIES:\n:EFFORT: %^{0:00|0:10|0:30|1:00|1:30|2:00|2:30|3:00}\n:ASSIGNED: %U\n:END:\n" :empty-lines 1))
;; **** 9) Clocking
org-clock-in-switch-to-state "PROG"
org-clock-out-remove-zero-time-clocks t
org-clock-out-when-done t
org-clock-persist t
org-clock-in-resume t
org-clock-persist-query-resume nil
org-clock-auto-clock-resolution 'when-no-clock-is-running
org-clock-report-include-clocking-task t
org-time-stamp-rounding-minutes '(1 1)
org-agenda-clockreport-parameter-plist
'(:link t :maxlevel 10 :fileskip0 t :stepskip0 t :compact t :narrow 80)
org-log-into-drawer t
org-clock-history-length 35
;; **** 7) Refiling Tasks
org-refile-targets '((nil :maxlevel . 9)
(org-agenda-files :maxlevel . 9))
org-outline-path-complete-in-steps nil
org-refile-use-outline-path 'file
org-refile-target-verify-function 'bhw/verify-refile-target
;; **** 11) Context Tags with fast selection keys
org-tag-alist '(;; Sets geo-spatial and context tags
;; Startgroup and endgroup make tags mutually
;; exclusive (:startgroup)
("home" . ?h)
("office" . ?o)
("errand" . ?e)
;; (:endgroup)
;; Person(s) can be contexts too.
;; ("father" . ?d)
;; ("workteam1" . ?d)
;; ("docket" . ?d)
)
org-fast-tag-selection-single-key 'expert
org-tags-column 0
;; For tag searches ignore tasks with scheduled and deadline dates
org-agenda-tags-todo-honor-ignore-options t
;; **** 14) Stuck Projects
org-stuck-projects '("+TODO=\"PROJ\"" ("TODO" "PROG" "WAIT") nil nil)
;; **** 15) Archiving
org-archive-default-command 'org-archive-subtree
org-archive-location
(concat +project-maria-dir+
"archived-tasks/taskings-"
(format-time-string "%Y") ".org::datetree/")
org-archive-save-context-info '(time category olpath ltags itags)
org-habit-show-habits t
;; Habits render on today's date only. `org-agenda-get-scheduled' drops a
;; habit on any day but today while this is non-nil, which is what keeps the
;; daily 06:00 fixtures (Daily Cardio, Personal Prayer) out of the Calendar
;; blocks' future days. Left at its default value deliberately, and pinned
;; here because the agenda layout now depends on it. Conflict scans do have
;; to see future occurrences; `bhw/org-conflict-agenda-bindings-a' binds it off.
org-habit-show-habits-only-for-today t
;; To speed up org agenda generation
org-agenda-inhibit-startup t
org-agenda-ignore-properties '(ASSIGNED LAST_REPEAT)
;; Sticky agenda buffers turn a repeat `SPC a' into a buffer switch (~1 ms)
;; instead of a full rebuild (~0.9 s here; 87% of that is the 30-day
;; Calendar block, which re-scans the whole of hq.org once per day per
;; entry type -- ~25 ms/day). The price is staleness, so
;; `bhw/custom-agenda' rebuilds anything older than
;; `bhw/agenda-max-age' rather than relying on you to press `g'.
org-agenda-sticky t)
;; Timing-conflict detection for new and edited timestamps. Vendored from
;; https://lists.gnu.org/archive/html/emacs-orgmode/2019-04/msg00035.html
;; Deliberately loaded *after* the `setf' above: org-conflict inspects
;; `org-agenda-files' at load time.
(load! "private-packages/org-conflict.el")
(setf
;; Present the overlaps for review and offer the earliest free slot.
;; `resolve' skips straight to the proposal, `replace' rewrites in place.
org-conflict-conflict-action 'review
;; 0 = prompt for a duration on a rangeless timestamp, matching the
;; `org-agenda-default-appointment-duration' that
;; `bhw/org-conflict-agenda-bindings-a' binds around every scan. (The
;; global value of that variable is nil, for display; see the note there.)
org-conflict-default-event-duration 0
;; Minutes of breathing room required between consecutive events.
org-conflict-event-minimum-interval 0
;; Days to search forward before giving up on finding a free slot.
org-conflict-resolution-horizon-days 30)
(add-hook 'org-ctrl-c-ctrl-c-hook #'org-conflict-check-timestamp-or-range)
;; `org-schedule' and `org-deadline' write into the planning line and never
;; run `org-ctrl-c-ctrl-c-hook', so the C-c C-c check above cannot see
;; them. The advised functions are local additions inside the vendored
;; file; see its Provenance header.
(advice-add 'org-schedule :after #'org-conflict-after-schedule)
(advice-add 'org-deadline :after #'org-conflict-after-deadline)
;; org-conflict finds overlaps by running `org-agenda-get-day-entries' over
;; the day under test, and that scan honours `org-habit-show-habits-only-for-today'.
;; With that left on -- which is exactly what keeps habits out of the Calendar
;; blocks -- a habit is invisible on every day but today, so scheduling
;; something on top of a future habit occurrence would never be reported.
;; Bind it off for the duration of a check instead. Advising the top-level
;; predicate rather than `org-conflict-get-conflicts' also covers the
;; review/resolve UI, which builds its own day view via `org-agenda-list'.
;;
;; The same advice pins `org-agenda-default-appointment-duration' to 0, which
;; the scan needs and the agenda no longer sets globally. org-conflict does
;; not read the `duration' text property; it re-parses the *rendered* time
;; cell out of its own format-controlled day listing, and
;; `org-conflict-parse-daylisting-for-conflicts' only builds a span when that
;; cell yielded an end time (its final `cond' clause is bare `end'). With
;; the global value nil a rangeless appointment renders as "08:00......",
;; parses to a nil end, and is silently dropped from conflict detection. A 0
;; duration renders it as "08:00-08:00", giving the zero-width span that
;; still collides with anything starting at 08:00.
(defun bhw/org-conflict-agenda-bindings-a (fn &rest args)
"Apply FN to ARGS with the agenda settings org-conflict's scan assumes."
(let ((org-habit-show-habits t)
(org-habit-show-habits-only-for-today nil)
(org-agenda-default-appointment-duration 0))
(apply fn args)))
(advice-add 'org-conflict-conflict-p :around #'bhw/org-conflict-agenda-bindings-a)
(defun bhw/org-agenda-calculate-efforts (limit)
"Sum the efforts of scheduled entries up to LIMIT in the agenda buffer."
(let ((total-minutes 0))
(save-excursion
(while (< (point) limit)
(when (member (org-get-at-bol 'type) '("scheduled" "past-scheduled" "timestamp"))
(let* ((marker (org-get-at-bol 'org-hd-marker))
(effort (when marker (org-entry-get marker "EFFORT"))))
(when effort
(setq total-minutes (+ total-minutes (org-duration-to-minutes effort))))))
(forward-line)))
(org-duration-from-minutes total-minutes)))
(defun bhw/org-agenda-insert-efforts ()
"Insert the efforts for each day inside the agenda buffer.
Idempotent: each total is tagged with `bhw/agenda-day-total' and a header
that already carries one is left alone. `org-agenda-finalize' can run
more than once over the same buffer -- the idle `bhw/org-agenda-prewarm-h'
build and a later `org-agenda-redo' on the same sticky buffer is the easy
way to see it -- and appending unconditionally rendered the day header as
\"W36 (8:15) (8:15)\"."
(save-excursion
(let (pos)
(while (setq pos (text-property-any
(point) (point-max) 'org-agenda-date-header t))
(goto-char pos)
(end-of-line)
(unless (get-text-property (max (point-min) (1- (point)))
'bhw/agenda-day-total)
(insert-and-inherit
(propertize
(concat " ("
(bhw/org-agenda-calculate-efforts
(or (text-property-any
(point) (point-max) 'org-agenda-date-header t)
(point-max)))
")")
'bhw/agenda-day-total t)))
(forward-line)))))
(add-hook 'org-agenda-finalize-hook #'bhw/org-agenda-insert-efforts)
(defface bhw/stale-assigned-face
'((t :background "#4d3028" :extend t))
"Face for TODO items assigned over a month ago (Priority C, no schedule/deadline).")
(defun bhw/highlight-stale-assigned-todos ()
"Highlight agenda TODO items assigned over a month ago.
Only applies to Priority C items with no scheduled date or deadline.
Batches source-buffer lookups to minimize buffer switching."
(let ((one-month-ago (time-subtract (current-time) (days-to-time 30)))
(agenda-buf (current-buffer))
candidates)
;; Pass 1: collect candidate lines (agenda-local checks only)
(save-excursion
(goto-char (point-min))
(while (not (eobp))
(let ((marker (or (org-get-at-bol 'org-hd-marker)
(org-get-at-bol 'org-marker))))
(when (and marker
(equal (org-get-at-bol 'todo-state) "TODO")
(let ((pri (org-get-at-bol 'priority)))
(or (null pri) (= pri 0))))
(push (list marker (line-beginning-position) (line-end-position))
candidates)))
(forward-line)))
;; Pass 2: group by source buffer, single switch per buffer
(let ((by-buffer (make-hash-table :test 'eq)))
(dolist (c candidates)
(let ((buf (marker-buffer (car c))))
(when buf
(push c (gethash buf by-buffer)))))
(maphash
(lambda (buf entries)
(with-current-buffer buf
(dolist (entry entries)
(let ((marker (nth 0 entry))
(bol (nth 1 entry))
(eol (nth 2 entry)))
(goto-char marker)
(let ((scheduled (org-get-scheduled-time (point)))
(deadline (org-get-deadline-time (point)))
(assigned-str (org-entry-get (point) "ASSIGNED")))
(when (and (null scheduled)
(null deadline)
assigned-str
(time-less-p (org-time-string-to-time assigned-str)
one-month-ago))
(let ((ov (make-overlay bol eol agenda-buf)))
(overlay-put ov 'face 'bhw/stale-assigned-face)
(overlay-put ov 'bhw/stale-assigned t))))))))
by-buffer))))
(add-hook 'org-agenda-finalize-hook #'bhw/highlight-stale-assigned-todos)
;; Tags and habit consistency graphs both want the right margin, and the
;; graph wins destructively: `org-agenda-finalize' right-aligns tags first
;; and only then calls `org-habit-insert-consistency-graphs', which does a
;; `delete-char' at `org-habit-graph-column' before writing. With
;; `org-agenda-tags-column' left at `auto' -- i.e. the window's right edge --
;; a habit's tag string is laid down exactly where the graph is about to go
;; and comes back chewed down to a stray ":".
;;
;; Doom's `+org-habit-resize-graph-h' picks the graph column from the window
;; width, so the safe tags column is not a constant; derive it from whatever
;; that hook decided. Depth 90 is what puts this after it -- both live on
;; `org-agenda-mode-hook', which runs long before finalize, so the value is
;; in place by the time either writer runs. Doom passes nil when the window
;; is too narrow for a graph at all; nothing is written then, and `auto' is
;; right again.
(defun bhw/org-agenda-keep-tags-clear-of-habit-graph-h ()
"Right-align agenda tags just left of the habit consistency graph."
(setq-local org-agenda-tags-column
(if (natnump (bound-and-true-p org-habit-graph-column))
(- (1- org-habit-graph-column))
'auto)))
(add-hook 'org-agenda-mode-hook
#'bhw/org-agenda-keep-tags-clear-of-habit-graph-h
90)
(defvar bhw/agenda-max-age 900
"Seconds before a sticky agenda buffer is rebuilt on re-entry.
See `bhw/custom-agenda'.")
(defvar-local bhw/agenda-built-at nil
"`float-time' at which this agenda buffer last finished generating.")
(defvar-local bhw/agenda-built-width nil
"Window width this agenda buffer was laid out for.
See `bhw/custom-agenda'.")
(defun bhw/org-agenda-stamp-build-time ()
"Record when, and at what width, this agenda buffer was built.
Read back by `bhw/custom-agenda' to decide whether a sticky
buffer is stale."
(setq bhw/agenda-built-at (float-time)
bhw/agenda-built-width (window-width)))
(add-hook 'org-agenda-finalize-hook #'bhw/org-agenda-stamp-build-time)
(defun bhw/custom-agenda (&optional refresh)
"Open the default (\"d\") agenda, rebuilding it when stale.
With `org-agenda-sticky' on, re-entering an existing agenda is a plain
buffer switch: it costs nothing but shows whatever was generated last
time. Rebuild instead when the buffer is older than
`bhw/agenda-max-age', or unconditionally with a prefix argument REFRESH.
A fresh build stamps itself via `bhw/org-agenda-stamp-build-time', so
the first entry of a session never redoes work it just did.
Width counts as staleness too. Two columns are baked into the buffer
text at generate time from whatever window was current then:
`org-agenda-align-tags' pads out to `org-agenda-tags-column', which is
`auto' here and so resolves to the window width, and Doom's
`+org-habit-resize-graph-h' derives a buffer-local
`org-habit-graph-column' the same way before
`org-habit-insert-consistency-graphs' writes the graphs at it. A sticky
buffer carried into a narrower window keeps both -- tags stop
right-aligning, and the habit graph, sized for the old window, lands
inside the item text and overwrites the tail of the tag string. Neither
survives a plain buffer switch, so re-generate when the width moved.
`bhw/org-agenda-prewarm-h' makes this load-bearing rather than
theoretical: it builds the first agenda of the session inside a
`save-window-excursion', at whatever width happened to be selected 30 s
into the session."
(interactive "P")
(org-agenda nil "d")
(when (and (derived-mode-p 'org-agenda-mode)
(or refresh
(null bhw/agenda-built-at)
(> (- (float-time) bhw/agenda-built-at) bhw/agenda-max-age)
(not (eql bhw/agenda-built-width (window-width)))))
(org-agenda-redo)))
(defun bhw/org-capture-set-priority-on-deadline ()
"Set the priority of an org-capture entry to [#B] if a deadline exists.
This function is intended to be used with `org-capture-before-finalize-hook`."
(save-excursion
(goto-char (point-min))
;; Check if a DEADLINE: timestamp exists in the entry
(when (re-search-forward "^[ \t]*DEADLINE:" nil t)
;; If a deadline is found, set the priority to 'B'
(org-priority ?B))))
(add-hook 'org-capture-before-finalize-hook #'bhw/org-capture-set-priority-on-deadline)
(defun bhw/org-schedule-on-wait ()
"Prompt for a follow-up date whenever an entry switches to WAIT.
A delegated item is invisible until something drags it back into view,
so entering WAIT always asks when to chase it, through the normal
`org-schedule' date picker. `org-log-reschedule' is muted for the
call: the `w@/!' spec already queues a state note, and
`org-add-log-setup' keeps only one pending note per command, so a
reschedule stamp queued here would silently displace it. C-g declines
the date and leaves the state change standing."
(when (and (equal org-state "WAIT")
(not noninteractive))
(condition-case nil
(let ((org-log-reschedule nil))
(org-schedule nil))
(quit (message "WAIT: no follow-up date set")))))
(add-hook 'org-after-todo-state-change-hook #'bhw/org-schedule-on-wait)
(defun bhw/org-agenda-deadline-for-prefix ()
"Return the deadline relative to today (e.g. 'In 5 d.'), formatted to 6 chars.
Returns 6 spaces if no deadline exists."
(let ((deadline-time (org-get-deadline-time (point))))
(if deadline-time
(let* ((days (- (org-time-string-to-absolute
(format-time-string "%Y-%m-%d" deadline-time))
(org-today)))
(result-string
(cond
((< days 0) (format "%dd ago" (abs days))) ;; Overdue: "2d ago"
((= days 0) "Today") ;; Due today
(t (format "%d d." days))))) ;; Future: "5 d."
;; Format to exactly 6 characters, left-aligned
(format "%-6s" result-string))
;; If no deadline, return 6 spaces to maintain alignment
(make-string 6 ?\s))))
(defun bhw/org-agenda-effort-for-prefix ()
"Return the effort estimate formatted as '[HH:MM] ', or spacers if no effort."
(let ((effort (org-entry-get (point) "EFFORT")))
(if effort
(format "[%-4s] " effort) ;; Result: "[0:30] "
" "))) ;; 7 spaces to match length of "[0:30] "
(defun bhw/org-agenda-skip-proj-descendants ()
"Agenda skip function: skip entries that are subheadings of a PROJ parent.
When any ancestor heading carries the PROJ todo keyword, return the end of the
current subtree so the entry and its children are excluded from the view;
otherwise return nil. PROJ headings themselves are never skipped."
(when (save-excursion
(org-back-to-heading t)
(let (proj)
(while (and (not proj) (org-up-heading-safe))
(when (equal (org-get-todo-state) "PROJ")
(setq proj t)))
proj))
(save-excursion (org-end-of-subtree t))))
(setf
org-agenda-block-separator 61
org-agenda-breadcrumbs-separator " | "
;; https://stackoverflow.com/questions/58820073/s-in-org-agenda-prefix-format-doesnt-display-dates-in-the-todo-view
;;
;; Fixed-width prefix, so every row in an agenda block starts its todo
;; keyword in the same column. Two fields vary in width and both are
;; pinned here:
;;
;; %-11t The time cell. `org-agenda-format-item' pads the *start* time
;; to five columns with "%5s" but leaves the end time ragged, so
;; " 7:00-8:15" came out one column short of "17:30-18:30".
;; `org-agenda-time-leading-zero' below fixes the widths at five
;; per side ("07:00-08:15"); 11 is then also the width of the
;; start-only form "07:00" + `org-agenda-time-grid's six trailing
;; characters. Giving the field an explicit width additionally
;; covers rows with no time at all -- untimed TODOs used to
;; collapse the cell to nothing and start at column 1.
;; %6s The scheduled/deadline/timerange leader. Right-aligned rather
;; than left so the colons in " D: ", " D 2: " and "OD 2: " line
;; up; six is the width of the longest leader below.
org-agenda-prefix-format
'((agenda . "%-11t %6s")
(todo . "%s")
(tags . "%s")
(search . "%s"))
;; " 7:00-8:15" -> "07:00-08:15". See the prefix-format note above: this is
;; what makes the time cell a constant 11 columns instead of 10 or 11
;; depending on whether the hours happen to be two digits.
org-agenda-time-leading-zero t
org-agenda-deadline-leaders '("D: " "D%2d: " "OD%2d: ")
org-agenda-scheduled-leaders '("" "S%2d: ")
;; A multi-day timestamp range -- a course running to the end of term, a
;; conference -- is rendered by `org-agenda-get-blocks' on every day it
;; covers, and its "which day of how many" counter arrives as the same
;; `extra' argument the leaders above use, i.e. in the %6s field. Org's
;; default "(%d/%d): " runs 7 to 9 columns wide, overflowed the field, and
;; was the one leader that shoved its own row's todo keyword out of the
;; column every other row agrees on. Both numbers are padded here so the
;; result is a constant six ("%2d" and "%-2d" plus "/" and a trailing
;; space), which costs the parentheses and the colon but moves nothing
;; else. Two digits per number, on the same assumption "OD%2d: " already
;; makes; a range longer than 99 days would widen the field again.
org-agenda-timerange-leaders '("" "%2d/%-2d ")
org-agenda-time-grid '((daily today remove-match)
(0600 0900 1200 1500 1800 2100)
"......" "----------------")
;; ATTACH is bookkeeping, not context: org-attach adds it to every entry
;; that owns an attachment directory, so it appears in the agenda's tag
;; string next to real tags like :school: or :home: and pushes them around
;; for no information. Hidden here rather than via `org-tag-alist' so the
;; tag still exists in hq.org and `org-attach' keeps working.
org-agenda-hide-tags-regexp (rx bos "ATTACH" eos)
org-columns-default-format-for-agenda "%75ITEM(Task) %DEADLINE %10Effort(Estim){:} %10TOTAL_EFFORT(Total){:} %10CLOCKSUM(ActTime)"
org-columns-default-format "%75ITEM(Task) %DEADLINE %10Effort(Estim){:} %10TOTAL_EFFORT(Total){:} %10CLOCKSUM(ActTime)"
org-global-properties '(("Effort_ALL" . "0:00 0:10 0:30 1:00 1:30 2:00 2:30 3:00 4:00 5:00 6:00 7:00 8:00")
("STYLE_ALL" . "habit"))
org-agenda-columns-add-appointments-to-effort-sum t
;; nil, not 0. With 0 every rangeless appointment got an end time equal to
;; its start and rendered as "08:00-08:00" -- a zero-length meeting, which
;; is not a thing. nil leaves it "08:00......", the same start-only form
;; the time-grid rows use, which is what it actually means: a start, no
;; known end. Nothing is lost in the effort sums either -- the day total
;; comes from `bhw/org-agenda-calculate-efforts' (EFFORT property) and
;; `org-agenda-columns-add-appointments-to-effort-sum' was only ever adding
;; 0:00 for these. org-conflict *does* need the 0 and gets it from
;; `bhw/org-conflict-agenda-bindings-a'; see the note there before changing
;; this back.
org-agenda-default-appointment-duration nil
org-agenda-log-mode-items '(closed state clock)
org-agenda-start-with-log-mode t
org-agenda-start-with-entry-text-mode nil
org-agenda-add-entry-text-maxlines 5
org-agenda-entry-text-maxlines 5
org-agenda-start-with-clockreport-mode nil
org-priority-default ?C
org-agenda-custom-commands
'(
;; Default Agenda
("d" "Default (Master) Agenda"
((agenda "" ((org-agenda-span 'day)
(org-deadline-warning-days 1)
;; Read this block as a timeline, so sort by clock time
;; first. Org's default for `agenda' leads with
;; `habit-down', which pulls every habit to the bottom of
;; the day: the 06:00 and 06:30 habits landed *below* the
;; 21:00 time-grid line, and the day no longer read top to
;; bottom. `time-up' sorts entries with no time-of-day
;; last (`org-cmp-time' scores them 9901), so the untimed
;; deadline and scheduled rows still collect at the end.
(org-agenda-sorting-strategy
'(time-up priority-down category-keep))
(org-agenda-overriding-header "Today's Agenda\n")))
(tags "TODO=\"PROG\""
((org-agenda-sorting-strategy '(priority-down deadline-up effort-down))
(org-agenda-prefix-format
'((tags . " %-3:c %(bhw/org-agenda-deadline-for-prefix)%(bhw/org-agenda-effort-for-prefix)")))
(org-agenda-todo-keyword-format "%-3s")
(org-agenda-overriding-header "\nTasks in Progress\n")))
(tags "TODO=\"TODO\""
((org-agenda-sorting-strategy '(priority-down deadline-up effort-down))
(org-agenda-todo-ignore-deadlines nil)
(org-agenda-prefix-format
'((tags . " %-3:c %(bhw/org-agenda-deadline-for-prefix)%(bhw/org-agenda-effort-for-prefix)")))
(org-agenda-todo-keyword-format "%-3s")
(org-agenda-skip-function '(org-agenda-skip-entry-if 'scheduled))
(org-agenda-overriding-header "\nTodo List\n")))
(agenda "" ((org-agenda-span 30)
(org-agenda-start-day "+1d")
(org-agenda-start-on-weekday nil)
;; `:deadline' is included so a deadline-only task (no
;; SCHEDULED, no plain timestamp) still lands on its due day
;; here -- otherwise it exists only in the Todo List block.
;; It does not multiply across the span: prewarning and
;; overdue lines are emitted only onto *today*, and this
;; block starts at +1d, so each deadline shows exactly once.
(org-agenda-entry-types '(:timestamp :sexp :scheduled :deadline))
;; Habits belong in Today's Agenda, not here. This block
;; starts at +1d so `org-habit-show-habits-only-for-today'
;; already excludes them; saying so outright keeps the block
;; correct if that global or this start day ever moves.
(org-habit-show-habits nil)
(org-agenda-overriding-header "Calendar\n"))))
((org-agenda-tag-filter-preset '("-SDAY"))
;; Hide rather than dim Edna-blocked tasks here: an entry gated behind a
;; BLOCKER is not actionable yet, so it is noise in the master view.
;; The global default stays `t' (dim), which the Review agenda keeps.
;; Command-global properties are bound by `org-agenda-run-series' around
;; both block generation (`org-agenda-finalize-entries', which sets the
;; `org-todo-blocked' text property) and `org-agenda-finalize' (which
;; acts on it), so `invisible' takes effect in both phases.
(org-agenda-dim-blocked-tasks 'invisible)))
;; Review Agenda
("r" "Review Agenda"
((tags "TODO=\"DONE\""
((org-agenda-sorting-strategy '(priority-down deadline-up))
(org-agenda-todo-keyword-format "%-3s")
(org-agenda-overriding-header "\nCompleted Tasks\n")))
(tags "TODO=\"CXLD\""
((org-agenda-sorting-strategy '(tsia-up))
(org-agenda-todo-keyword-format "%-3s")
(org-agenda-overriding-header "\nTerminated Tasks\n")))
(tags "+TODO=\"WAIT\""
((org-agenda-sorting-strategy '(timestamp-down))
(org-agenda-todo-keyword-format "%-3s")
(org-agenda-overriding-header "\nDelegated/Waiting For\n")))
(stuck "" ((org-agenda-overriding-header "\nStuck Projects\n")))
(agenda "" ((org-agenda-span 120)
(org-agenda-start-on-weekday nil)
;; See the note on the default agenda's Calendar block.
(org-agenda-entry-types '(:timestamp :sexp :scheduled :deadline))
;; Unlike the default agenda's Calendar this block does start
;; today, so the today-only rule would still let habits
;; through on the first day. Suppress them outright.
(org-habit-show-habits nil)
(org-agenda-overriding-header "Calendar\n"))))
((org-agenda-tag-filter-preset '("-SDAY" "-lat211" "-phl225"))
(org-agenda-skip-function #'bhw/org-agenda-skip-proj-descendants))))
org-agenda-window-setup 'current-window)
;; NOTE: must be `:nm', not `:m'. `evil-org-agenda-set-keys' binds
;; everything in *motion* state and calls
;; (evil-set-initial-state 'org-agenda-mode 'motion), but
;; `evil-collection-org-agenda' loads later and resets that to 'normal, so
;; agenda buffers open in normal state. Evil then orders the *entire* normal
;; group in `evil-mode-map-alist' ahead of the entire motion group — the
;; global `evil-normal-state-map' included. A motion-only binding here
;; therefore loses not just to evil-collection's normal-state bindings in
;; `org-agenda-mode-map' but to Doom's plain global normal-state bindings.
;; Order for "gd" in a live agenda buffer:
;;
;; evil-org-agenda-mode (normal aux) nil
;; org-agenda-mode-map (normal aux) nil
;; evil-normal-state-map +lookup/definition <- wins
;; evil-org-agenda-mode (motion aux) org-agenda-goto-date <- unreachable
;;
;; ~30 of `evil-org-agenda's motion bindings are dead this way. Revived
;; below are the ones whose live normal-state binding is `ignore', unbound,
;; or meaningless in a read-only agenda buffer. Deliberately NOT revived,
;; because evil-collection's binding is the better one: H/L (agenda span
;; earlier/later — nothing else is bound to those), J (goto-date), K, A
;; (archive), m/u (bulk mark/unmark), x (exit), gt (workspace switch), 0,
;; S-RET, and the "d…" prefix (bare "d" is already `org-agenda-kill'; making
;; it a prefix here would swallow the bare key).
(map! :after evil-org-agenda
:map evil-org-agenda-mode-map
:nm "s" #'avy-goto-word-or-subword-1
;; Shadows `org-agenda-filter-remove-all'; the whole "s…" filter
;; family is already shadowed by the avy binding above.
:nm "S" #'bhw/org-schedule-with-effort
;; Shadows `org-timer-set-timer' on ";" in `org-agenda-mode-map'.
:nm ";" #'consult-line
;; --- revived from `evil-org-agenda's motion map ---
;; evil-collection also has "J" for goto-date; "gd" is the
;; evil-org-agenda spelling and the one muscle memory expects.
:nm "gd" #'org-agenda-goto-date
:nm "gc" #'org-agenda-goto-calendar ; was `evilnc-comment-operator'
:nm "gD" #'org-agenda-view-mode-dispatch ; was `+lookup/references'
;; These four sit on keys evil-collection remaps to `ignore'.
:nm "C" #'org-agenda-capture
:nm "P" #'org-agenda-show-the-flagging-note
:nm "a" #'org-agenda-add-note
:nm "i" #'org-agenda-diary-entry
:nm "~" #'org-agenda-bulk-toggle-all
;; Takes "p" from `org-agenda-priority', which stays on the
;; localleader (", p").
:nm "p" #'org-agenda-date-prompt
;; Bare "c" is `evil-change' -> `ignore' here, so the clock prefix is
;; free. "c e" in particular is `org-agenda-set-effort'.
:nm "cc" #'org-agenda-clock-cancel
:nm "ce" #'org-agenda-set-effort
:nm "cg" #'org-agenda-clock-goto
:nm "cr" #'org-agenda-clockreport-mode
:nm "ct" #'org-agenda-set-tags
:nm "cT" #'org-timer-set-timer)
(map! :after org-agenda
:map org-agenda-mode-map
:localleader
(:prefix "d"
:desc "Schedule with effort" "S" #'bhw/org-schedule-with-effort))
(map! :map (org-mode-map)
:localleader
(:prefix "d"
:desc "Schedule with effort" "S" #'bhw/org-schedule-with-effort)
(:prefix ("x" . "text")
:desc "bold emphasis " "b" (cmd! (org-emphasize ?*))
:desc "italic emphasis " "i" (cmd! (org-emphasize ?/))
:desc "underline emphasis " "u" (cmd! (org-emphasize ?_))
:desc "verbatim emphasis " "v" (cmd! (org-emphasize ?=))
:desc "code emphasis " "c" (cmd! (org-emphasize ?~))
:desc "strikethrough emphasis" "s" (cmd! (org-emphasize ?+)))
(:prefix ("v" . "links")
:desc "org-insert-structure-template" "v" #'org-insert-structure-template))
(map! :leader
:desc "bhw/clock-in-dwim" "nc" #'bhw/clock-in-dwim))
;; Build the default agenda once on idle so the *first* `SPC a' of a session is
;; a buffer switch too, not a ~1.2 s cold build (visiting hq.org, warming its
;; org-element cache, then generating four blocks). This only pays off with
;; `org-agenda-sticky' on; without it the result would be discarded.
;;
;; Deliberately top-level, not inside the `after! org' above: the point is to
;; run before org is needed interactively, and a body inside `after! org' would
;; not arm until something else had already loaded org. `org-agenda' is
;; autoloaded, so calling it here pulls in org, org-agenda and the `after! org'
;; configuration in the right order.
;;
;; One-shot, not repeating: a standing timer would rebuild the agenda forever
;; and fight `bhw/agenda-max-age', which already handles staleness on entry.
;; The delay sits well after `bhw/org-node-activate-h' (5 s), which pulls in org
;; anyway. Cancel-before-arm keeps `doom/reload' from stacking timers.
(defvar bhw/org-agenda-prewarm-idle-seconds 30
"Idle seconds before the default agenda is built in the background.")
(defun bhw/org-agenda-prewarm-h ()
"Pre-build the default agenda without disturbing the window layout."
(with-demoted-errors "bhw/org-agenda-prewarm-h: %S"
(save-window-excursion
(org-agenda nil "d"))))
(cancel-function-timers #'bhw/org-agenda-prewarm-h)
(run-with-idle-timer bhw/org-agenda-prewarm-idle-seconds nil
#'bhw/org-agenda-prewarm-h)
Tasks are broken down into kinds. Because humans cannot bi-locate, all tasks needs to have a valid contextual location in which to accomplish them. This is done with tags which are prefixed with “@”, errand - Tasks that must be completed out and about home - 0office 0docket - Instead of having a @John Doe, @Alice, @Bob tagging a task, you can signify such a group with a custom context. Tasks that must be completed in such a meeting setting. @John Doe - Say you have an agenda list of items for the next time you run into John Doe. Tasks that must be completed in John Doe’s company
- A Projects list & Project Support Materials - hq.org
A comprehensive list. Can include delegated projects.
- Critical for control and focus
- Alleviates subtle tensions
- Projects often evolve.
- Core of the Weekly Review
- Facilitates relationship management
Calendar actions and information - hq.org
Does this action require you to be at a specific place in a specific time frame? If so, prefix with APPT keyword.
Does a project belong under 0projects or 0calendar? Answer this: Is the time specific appointment a subset of the project or vice versa? Should an item that has a time context be put in 0calendar.org?
- YES - time specific - Appointments to be tracked Uses a timestamp, synced with google calendar.
- NO - day-specific action - time slot enlarged to entire days Use a scheduled timestamp, org refile to 0projects or 0solo
- NO - day-specific information - items that do not require action from YOU. Useful nonetheless:
Use the WAIT keyword and a scheduled timestamp, refile to 0projects or 0solo.
- Special events with a certain lead time for handling (product launches, fund- raisers, etc.)
- Regular events that you need to prepare for, such as budget reviews, annual conferences, planning events, or meetings (e.g., when should you add next year’s “Annual sales conference” or “Get kids set up for next school year”to your Projects list?)
- Key dates for significant people that you might want to do something about (birthdays, anniversaries, holiday gift giving, etc.)
- Seminars, conferences, speeches, and social and cultural events. It’s OK to decide not to decideas long as you have a decide-not-to-decide system.
- Big life or strategy decisions that require: Additional information needed from internal sources. Ex. you need to sleep on it. Justifiable reason to delay until all factors are visible and understood Create future trigger so you can feel comfortable just “hanging it out” for now.
- Next Actions lists - 0solo.org One off actions that you cannot neatly organize under a project. Ideally, of course, with enough self agency, most tasks should be under your projects list.
- Reference Material - project-jerome-index.org & contacts.org
- A Someday/Maybe list - someday.org
Refile a task here without changing any TODO keywords or tags. The file tag “-SDAY” excludes such entries from the Agenda.
If on reflection you realize that an optional project doesn’t have a chance of getting your attention for the next few months or more, move it to this list. Whatever ideas/ex-projects/processes on this list can have its own reference materials.
Keep in mind: ex-projects are projects that have lost part of their definition: action-ability. Please refer to the 5 step workflow diagram.
Difference between 0someday items and WAIT keyword items is the intersection between this organizational group and the calendar. Non actionable items that may need an action in the future. Refer to the incubate heading under chapter 6. Falls under calendar rule 3. day specific information. In order to keep with the theme of the calendar as sacred “hardlined” ground, ticklers (HOLD keyword tasks) must have a scheduled date, and exist in 0project or 0solo files.
- Things to get or build for your home
- Hobbies to take up
- Skills to learn
- Creative expressions to explore
- Clothes and accessories to buy
- Toys (hi-tech and otherwise!) to acquire
- Trips to take
- Organizations to join
- Service projects to contribute to
- Things to see and do
- Childrenthings to do with them
- Books to read
- Music to download
- Movies to see
- Gift ideas
- Web sites to explore
- Weekend trips to take
- IdeasMisc. (meaning you don’t know where else to put them!)
Org Attach Config
Attached images will show up in inline by using the , i l RET attachment: with
no description. Toggle on with , T i. If you look at the org mode manual, it
will provide detail into how attachments work. What is more pressing is how to
delete attachments after you have deleted their associated headline.
;;---------------------------------------------------------------------------
;; Adapted from Fuco1 (originally `fuco/org-attach-visit-headline-from-dired').
(defun bhw/org-attach-visit-headline-from-dired ()
"Go to the headline corresponding to this org-attach directory."
(interactive)
(let* ((id-parts (last (split-string default-directory "/" t) 2))
(id (apply #'concat id-parts)))
(let ((m (org-id-find id 'marker)))
(unless m (user-error "Cannot find entry with ID \"%s\"" id))
(pop-to-buffer (marker-buffer m))
(goto-char m)
(move-marker m nil)
(org-fold-show-context))))
(setf
org-attach-id-dir "~/project-jerome/org-attach-data/"
;; https://helpdeskheadesk.net/2022-03-13/
;; For org attach, change org timestamps to more human readable format.
org-id-method 'ts
org-attach-id-to-path-function-list
'(org-attach-id-ts-folder-format org-attach-id-uuid-folder-format)
org-attach-method 'mv)
Org Edna Config
;;---------------------------------------------------------------------------
;; `org-depend' (org-contrib) is Edna's deprecated predecessor. It claims the
;; same BLOCKER and TRIGGER properties with an incompatible syntax, and adds
;; itself to `org-blocker-hook' / `org-trigger-hook' unconditionally at *load*
;; time -- so merely requiring the library anywhere silently doubles up on
;; Edna:
;;
;; - `org-depend-block-todo' splits BLOCKER on whitespace and treats every
;; token as an org-id, so an Edna form like `ids(...)' matches nothing and
;; falls through. It then still runs `org-toggle-tag' with "blocked",
;; writing a tag into the file on each state change. (Org core binds
;; `org-depend-tag-blocked' to nil inside `org-agenda--mark-blocked-entry',
;; so the agenda path is safe, but the interactive `org-todo' path is not.)
;; - `org-depend-trigger-todo' re-parses TRIGGER after Edna already ran it,
;; so a trigger's side effects are attempted twice by two engines.
;;
;; Nothing here needs it: its only users are two
;; `chain-find-next(NEXT,...)' TRIGGERs in someday.org, which name a NEXT
;; keyword `org-todo-keywords' no longer defines, in a file that is not in
;; `org-agenda-files'. The `require' is gone from the `after! org' block
;; above; the `remove-hook's below are belt-and-braces in case org-contrib
;; autoloads it back in.
(use-package! org-edna
:after org
:config
(remove-hook 'org-blocker-hook #'org-depend-block-todo)
(remove-hook 'org-trigger-hook #'org-depend-trigger-todo)
;; Explicit 1: `org-edna-mode' is a global minor mode, and a bare call reads
;; as a toggle. On `doom/reload' that would turn Edna *off* again.
(org-edna-mode 1))
;; `org-id-method' is `ts' here, so org-ids look like "20260830T202229.097877",
;; not a UUID -- and that breaks Edna's `ids(...)' finder outright:
;;
;; `org-edna-parse-string-form' reads an unquoted argument with `read', so
;; `ids(20260830T202229.097877)' yields a *symbol*. `org-edna--transform-arg'
;; converts such a symbol back to a string only when `org-uuidgen-p' matches
;; it, which a ts id never does. `org-edna-finder/ids' then runs
;; `string-prefix-p' against a symbol and signals `wrong-type-argument'.
;;
;; The failure is silent *and* inverted: `org-edna-run' wraps every form in
;; `condition-case-unless-debug' and treats any error as a block, so a ts-id
;; BLOCKER blocks its entry forever regardless of the target's todo state --
;; the one outcome that looks like "edna is installed and working".
;;
;; Quoting the id in the property also works; coerce at the finder so both
;; spellings do.
(define-advice org-edna-finder/ids
(:filter-args (ids) bhw/accept-non-uuid-ids)
"Let `ids(...)' accept non-UUID org-ids written without quotes."
(mapcar (lambda (id) (if (symbolp id) (symbol-name id) id)) ids))
Org Mem Config
;;---------------------------------------------------------------------------
(use-package! org-mem
:after org
:config
(setf org-mem-watch-dirs (list +project-maria-dir+))
(org-mem-updater-mode))
;; org-mem re-scans the whole corpus on an idle timer whose delay is
;; `(* 10 (1+ org-mem--time-elapsed))' -- ~115s here, because a full scan of the
;; 157-file / 12.4 MB corpus takes ~10.5s. Profiled, one scan allocates ~80 MB
;; of conses and ~24 MB of string data and costs 1250 ms in GC; at ~20 scans per
;; session that was ~95% of all GC time in a 5h session (83 GCs, 21.4s).
;; Incremental updates already run from `after-save-hook'
;; (`org-mem-updater--update-soon'), so the periodic full scan is only a safety
;; net for out-of-band edits -- git pull, external tools. Ten idle minutes is
;; plenty for that. Upstream invites this override by name in the docstring of
;; `org-mem-updater-adjust-reset-timer'.
;;
;; Deliberately NOT adding bible/ or archived-tasks/ to `org-mem-exclude':
;; org-mem does not persist its index ("no need to persist data",
;; org-mem.el:30), and `org-mem-exclude' feeds `:full-dir-deny' at file-discovery
;; time, so excluding a directory drops it from the *startup* scan too. That
;; would take 36878 (bible/) + 65 (archived-tasks/) ID nodes and 99 inbound ID
;; links out of org-node entirely, breaking both link completion and backlinks.
(defvar bhw/org-mem-full-rescan-idle-seconds 600
"Idle seconds between org-mem full rescans.
Upstream recomputes this as ten times the last scan duration; we pin it,
because incremental `after-save-hook' updates carry the real load.")
(after! org-mem-updater
(defun org-mem-updater-adjust-reset-timer (&rest _)
"Re-arm the org-mem full-rescan timer at a fixed, relaxed interval.
Overrides upstream's `(* 10 (1+ org-mem--time-elapsed))' delay; see
`bhw/org-mem-full-rescan-idle-seconds'. The `member' guard keeps us from
re-arming a repeating idle timer while it is already armed, which is the
infinite-loop footgun upstream warns about."
(let ((want bhw/org-mem-full-rescan-idle-seconds))
(when (and (or (not (member org-mem-updater--reset-timer timer-idle-list))
;; Also re-arm when an *existing* timer carries the wrong
;; delay, so a `doom/reload' migrates the live session
;; instead of leaving upstream's cadence armed until restart.
(/= want (float-time
(timer--time org-mem-updater--reset-timer))))
;; Never re-arm while already idle past WANT: the fresh timer
;; would fire immediately and could loop. Upstream's footgun.
(not (> (float-time (or (current-idle-time) 0)) want)))
(cancel-timer org-mem-updater--reset-timer)
(setq org-mem-updater--reset-timer
(run-with-idle-timer want t #'org-mem--scan-full))))))
Org Node Config
GitHub - d12frosted/vulpea: Database layer for org-mode notes with async inde…
;;---------------------------------------------------------------------------
(use-package! org-node
:defer t
:init
(map! :leader
:desc "org-node-find" "sf" #'org-node-find)
;; Activate the cache a few idle seconds after launch instead of during
;; startup. Loading org-node pulls in org itself, so this also keeps org
;; off the critical path. Both mode commands are autoloaded; calling the
;; first loads the package, which runs :config before the mode body, so
;; `org-node-backlink-do-drawers' is set before the backlink mode starts.
;; Cancel-before-arm keeps `doom/reload' from stacking timers.
(defun bhw/org-node-activate-h ()
"Enable the org-node cache and backlink modes."
(org-node-cache-mode +1)
(org-node-backlink-mode +1))
(cancel-function-timers #'bhw/org-node-activate-h)
(run-with-idle-timer 5 nil #'bhw/org-node-activate-h)
:config
(setf org-node-backlink-do-drawers t)
(defun bhw/org-node-ensure-source-id ()
"Give the heading at point its own ID when inserting an org-node link.
Registered on `org-node-insert-link-hook' (point sits in the just-inserted
link). `org-node-backlink--add-in-target' resolves the link's origin with
`org-entry-get-with-inheritance', so without an own ID the backlink lands on
the nearest ancestor node -- or, if no ancestor has an ID, no backlink is
written at all. Creating an ID here makes this heading its own node so the
target's backlink points back to it precisely."
(when (and (derived-mode-p 'org-mode)
(buffer-file-name)
(not (org-before-first-heading-p)))
(save-excursion
(org-id-get-create))))
;; Negative depth => runs before `org-node-backlink--add-in-target' (depth 0),
;; so the source ID exists by the time the backlink is written.
(add-hook 'org-node-insert-link-hook #'bhw/org-node-ensure-source-id -50)
;; NOTE: `:prefix' must be given as a bare key here, *not* as ("l" . "links").
;; When `map!' sees a string description on a `:prefix', `doom--map-def' binds
;; the prefix key itself to (cons DESC (make-sparse-keymap)) -- a brand new,
;; empty keymap. Since this runs after `+org-init-keybinds-h' (org-load-hook),
;; that would replace Doom's whole "links" group under `, l', silently dropping
;; org-cliplink, org-insert-link, org-store-link, +org/yank-link, etc. A bare
;; `:prefix' just adds to the existing keymap.
(map! :map (org-mode-map)
:localleader
(:prefix "l"
:desc "org-node-insert-link" "n" #'org-node-insert-link))
)
Org Noter Config
;;---------------------------------------------------------------------------
(after! org-noter
(setf org-noter-always-create-frame nil
org-noter-hide-other nil
org-noter-auto-save-last-location t
org-noter-arrow-delay -1)
;; Work around a stale `org-element' cache in the notes buffer.
;; `org-noter--insert-heading' reads the just-inserted heading's level via
;; (org-element-property :level (org-element-at-point)). In some sessions the
;; element cache is not invalidated after `org-insert-heading', so that returns a
;; stale paragraph with nil :level and `org-noter-insert-note' dies with
;; "Wrong type argument: number-or-marker-p, nil". Force a fresh, cache-free
;; parse when point is really on a heading but the cache disagrees.
(define-advice org-noter--insert-heading
(:around (orig-fn &rest args) bhw/org-noter-cache-safe-level)
(cl-letf* ((real-at-point (symbol-function 'org-element-at-point))
((symbol-function 'org-element-at-point)
(lambda (&rest a)
(let ((el (apply real-at-point a)))
(if (and (org-at-heading-p)
(not (eq (org-element-type el) 'headline)))
(let ((org-element-use-cache nil))
(apply real-at-point a))
el)))))
(apply orig-fn args)))
;; Every notetaking session stays in the current frame: since
;; https://github.com/org-noter/org-noter/pull/129 (merged upstream
;; 2026-07-30), `org-noter-always-create-frame' nil means "never create a
;; frame", reusing the selected one even when it already hosts a session.
;; The old `make-frame'-shadowing advice for this is no longer needed.
(defun bhw/org-noter-quit ()
"Kill org-noter session without closing the emacs client frame.
Un-dedicates windows first to avoid Doom's `switch-to-prev-buffer' error,
then shadows `delete-frame' so the session teardown cannot close the frame."
(interactive)
(org-noter--with-valid-session
(let ((frame (org-noter--session-frame session)))
(dolist (win (window-list frame))
(set-window-dedicated-p win nil))
(cl-letf (((symbol-function 'delete-frame) #'ignore))
(org-noter-kill-session session))
(when (frame-live-p frame)
(unless (doom-real-buffer-p (current-buffer))
(switch-to-buffer (doom-fallback-buffer)))))))
(map! :map org-noter-notes-mode-map
:n "q" #'bhw/org-noter-quit)
(defun bhw/org-noter-insert-precise-quote (&optional toggle-highlight)
"Insert a quotation block from selected PDF text with org-cite reference.
With prefix argument, fall back to the original `org-noter-insert-precise-note'."
(interactive "P")
(if toggle-highlight
(org-noter-insert-precise-note toggle-highlight)
(org-noter--with-valid-session
(let ((selected-text (run-hook-with-args-until-success
'org-noter-get-selected-text-hook
(org-noter--session-doc-mode session))))
(if (or (null selected-text) (string-empty-p selected-text))
(org-noter-insert-precise-note)
(let* ((location (org-noter--doc-approx-location
(or (org-noter--get-precise-info) 'interactive)))
(page (car location))
(cite-key (file-name-sans-extension
(file-name-nondirectory
(org-noter--session-property-text session))))
(ast (org-noter--parse-root))
(window (org-noter--get-notes-window 'force))
(view-info (org-noter--get-view-info
(org-noter--get-current-view) location))
(ref (org-noter--view-info-reference-for-insertion view-info)))
(let ((inhibit-quit t))
(with-local-quit
(select-frame-set-input-focus (window-frame window))
(select-window window)
(if ref
(goto-char (org-element-property
(if (eq (car ref) 'before) :begin :end)
(cdr ref)))
(goto-char (or (org-element-map (org-element-contents ast)
'section
(lambda (s)
(org-element-property :end s))
nil t org-element-all-elements)
(point-max))))
(unless (bolp) (insert "\n"))
(insert "\n#+BEGIN_QUOTE\n" selected-text
"\n" (format "[cite:@%s %s]" cite-key page)
"\n#+END_QUOTE\n"))
(when quit-flag
(select-frame-set-input-focus (org-noter--session-frame session))
(select-window (get-buffer-window
(org-noter--session-doc-buffer session))))))))))))
Org Transclusion Config
;;---------------------------------------------------------------------------
(use-package! org-transclusion
:after org
:init
(map! :leader :prefix "n"
:desc "Toggle Org Transclusion Mode" "t" #'org-transclusion-mode))
Org Mode Export
Ob Tangle Sync Config
{ANN} lisp/ob-tangle-sync.el - Mehmet Tekman
Two directions, two mechanisms. Tangling on save is mine and deliberately
narrow: bhw/org-babel-tangle-on-save runs a full org-babel-tangle after
saving, but only for the files in bhw/org-tangle-on-save-files — this file
and dotfiles.org. Since after-save-hook is global, that list is the
allowlist; every other Org file in project-maria is left alone no matter what
its :tangle headers say. /De/tangling stays with ob-tangle-sync: saving a
generated file (config.el, packages.el) pushes the edit back into its source
block here. Advice suppresses ob-tangle-sync’s own org-to-file leg for the two
allowlisted files, so a save parses this 460 KB file once rather than twice.
;;---------------------------------------------------------------------------
;; Inside `after! org': ob-tangle-sync.el requires org/org-element/ob-core, and
;; as a top-level load! it was the sole form dragging all of org (and every
;; `after! org' block in this file) onto the startup path. Its after-save-hook
;; only matters in org buffers, so waiting for org to load first is free.
(after! org
(load! "private-packages/ob-tangle-sync.el")
(setf org-babel-tangle-sync-files
(list (concat +project-maria-dir+ "blog/dotemacs.org")))
(org-babel-tangle-sync-mode)
;; ob-tangle-sync's org->file leg re-tangles the block-at-point's target on
;; the same save: a second parse of the file for a subset of what
;; `bhw/org-babel-tangle-on-save' has already written. Suppress it for the
;; allowlisted files. The file->org (detangle) leg is untouched -- that one
;; runs in the *tangled* buffer (config.el, packages.el, ...), which is never
;; a member of the allowlist.
(define-advice org-babel-tangle-sync-synchronize
(:around (fn &rest args) bhw/tangle-on-save-supersedes)
(unless (bhw/org-tangle-on-save-file-p)
(apply fn args))))
(defvar bhw/org-tangle-on-save-files
(mapcar #'file-truename
(list (concat +project-maria-dir+ "blog/dotemacs.org")
(concat +project-maria-dir+ "dotfiles.org")))
"Org files tangled in full by `bhw/org-babel-tangle-on-save'.
`after-save-hook' is global, so this list is the entire allowlist: no other
Org file auto-tangles on save, whatever its :tangle headers say.")
(defun bhw/org-tangle-on-save-file-p ()
"Non-nil when the current buffer visits a `bhw/org-tangle-on-save-files' member."
(and buffer-file-name
(member (file-truename buffer-file-name) bhw/org-tangle-on-save-files)))
(defun bhw/org-babel-tangle-on-save ()
"Tangle the whole buffer after saving, for the allowlisted files only.
Errors are demoted to a warning rather than signalled: this runs from
`after-save-hook', where a signal aborts every hook queued behind it (org-mem's
incremental index, for one). dotfiles.org makes that concrete --- one of its
targets, /mnt/c/ProgramData/Microsoft/WSL/.wslgconfig, is read-only from WSL,
so the day that block diverges from the file the whole save would come down
with it."
(when (bhw/org-tangle-on-save-file-p)
(condition-case err
(let ((org-confirm-babel-evaluate nil))
(dolist (file (org-babel-tangle))
;; ob-tangle writes targets with `write-region', so a buffer
;; already visiting one keeps its pre-tangle text and would clobber
;; the fresh output on its next save. Revert the clean ones; a
;; modified buffer is left alone rather than silently losing
;; unsaved edits.
(when-let* ((buf (get-file-buffer file)))
(with-current-buffer buf
(unless (buffer-modified-p)
(revert-buffer :ignore-auto :noconfirm))))))
(error
;; Both channels on purpose: the echo area is where a save is watched,
;; *Warnings* is what survives the next message.
(message "Tangle-on-save FAILED for %s: %s"
(abbreviate-file-name buffer-file-name)
(error-message-string err))
(display-warning 'bhw/org-babel-tangle-on-save
(format "tangling %s failed: %s"
(abbreviate-file-name buffer-file-name)
(error-message-string err))
:error)))))
(add-hook 'after-save-hook #'bhw/org-babel-tangle-on-save)
(defun bhw/touch-tangled-files (files)
"Bump the mtime of FILES, the value `org-babel-tangle' returns.
ob-tangle skips the `write-region' for a target whose content is unchanged
(see the `compare-buffer-substrings' guard in `org-babel-tangle'), but still
collects it. So an edit confined to prose or a property drawer -- publishing
the blog stamps ID drawers through dotemacs.org, for one -- leaves every
target older than the org source permanently, which is exactly the state
`bhw/config-test--tangle-fresh' reads as a never-tangled edit.
Touching them makes \"newest target is at least as new as the source\" mean
what that test assumes it means: a tangle has run since the last edit."
(dolist (file files)
(when (file-writable-p file)
(set-file-times file)))
files)
(advice-add 'org-babel-tangle :filter-return #'bhw/touch-tangled-files)
Office to Org Config
To read docx comments: https://github.com/nick-maderight/docx-view
A small homegrown package that reads common office files — word-processor
documents (.docx=/.odt=), spreadsheets (.xlsx=/.csv=) and presentations
(.pptx=/.odp=) — and renders them as Org. With the global mode enabled,
pressing RET on such a file in Dired converts it and visits the
result: documents and spreadsheets write a sibling .org file, while
presentations write a slug-named folder holding the .org alongside their
extracted inline images. The ZIP-based formats are unzipped and their XML parts
parsed with libxml; the xlsx reader mirrors the cell semantics — shared
strings, number/boolean/inline-string cells, and date serials gated by cell
styles — of my Common Lisp cl-excel library. It unifies three former
siblings (document-to-org, spreadsheet-to-org, slides-to-org) that shared
most of their ZIP/XML and rendering plumbing. Behaviour is tunable via
office-to-org-{extensions,list-indent,first-row-is-header,emit-title,emit-startup-inline-images,slide-heading,include-notes,csv-separator,date-format,include-time,overwrite}.
;;---------------------------------------------------------------------------
;; Inside `after! dired': the 1,500-line package requires dired, and its only
;; startup-time effect is :around advice on `dired-find-file' — pointless any
;; earlier than the first dired buffer.
(after! dired
(load! "private-packages/office-to-org.el")
(office-to-org-mode +1))
Org Clock Analyzer Config
A homegrown clock analyzer, private-packages/org-clock-analyzer.el – hand-written,
not tangled (the Office to Org Config precedent). It draws what
org-clock-report cannot: a dashboard with summary statistics, a GitHub-style
calendar heatmap, stacked totals by period, ranked activities / tags / areas /
weekdays / hours, estimate-vs-actual and an outline roll-up; a per-day view and a
per-day timeline; a sortable activity list; a sanity check (open, overlapping,
mismatched and unparsable clocks); CSV / Org-table / dynamic-block export; and,
on top of org-edna’s BLOCKER finders, project forecasts that schedule the remaining
effort of a project’s open tasks (TOTAL_EFFORT or Effort minus clocked time,
predecessors first, at the measured daily capacity) against its DEADLINE.
Rendering is text with faces, so it works in a terminal too.
Everything is autoloaded from SPC n r (and a / p in the clock transient), so
nothing loads at startup. Its ERT suite, tests/org-clock-analyzer-tests.el, is
loaded by the Config Test Suite and so runs on every doom/reload; it also runs
standalone:
Usage: SPC n r dashboard (? menu; s search word “phrase” +tag -tag todo:KW NAME; r/</> range; d/w/m drill from a heatmap cell; t timeline, l list, c check, p projects — C-u p for the global earliest-deadline schedule; RET jumps to the heading; gr refresh, C-u gr re-parse). From an org/agenda heading: C-u SPC n c → p forecasts that subtree. Dynamic block: #+BEGIN: clock-analyzer :view tags :range this-year :limit 5.
B=~/.config/emacs/.local/straight/build-30.1 emacs -Q --batch -L $B/org -L $B/compat -L $B/cond-let -L $B/llama \ -L $B/magit-section -L $B/transient -L $B/org-edna \ -l org -l ~/.config/doom/tests/org-clock-analyzer-tests.el -f ert-run-tests-batch-and-exit
;;---------------------------------------------------------------------------
;; Homegrown clock analyzer (private-packages/org-clock-analyzer.el; hand-written,
;; not tangled). Autoloaded rather than `load!'ed: it requires org, magit-section
;; and transient, and nothing needs any of that before the first `SPC n r'.
(let ((file (expand-file-name "private-packages/org-clock-analyzer" doom-user-dir)))
(dolist (cmd '(org-clock-analyzer org-clock-analyzer-day org-clock-analyzer-week
org-clock-analyzer-month org-clock-analyzer-timeline org-clock-analyzer-list
org-clock-analyzer-check org-clock-analyzer-projects
org-clock-analyzer-project-at-point org-clock-analyzer-export-csv
org-clock-analyzer-insert-org-table org-clock-analyzer-menu))
(autoload cmd file nil t))
;; `C-c C-c' on a #+BEGIN: clock-analyzer block funcalls this by name.
(autoload 'org-dblock-write:clock-analyzer file))
;; Set before the defcustom exists: `defcustom' keeps an already-bound value.
(setq org-clock-analyzer-files
(list (concat +project-maria-dir+ "hq.org")
(concat +project-maria-dir+ "archived-tasks/*.org")))
(map! :leader :desc "Clock analyzer" "nr" #'org-clock-analyzer)
(after! org-clock-analyzer
;; Evil overlay. evil-collection's magit-section bindings (TAB, gj/gk, [ ],
;; z-folds, 1-4) are inherited through the derived keymap, so only the
;; analyzer's own keys need normal-state entries. `g' is evil's prefix, so
;; refresh is `gr'; `[' / `]' are sibling navigation, so the range shifts on
;; `<' / `>'.
(map! :map org-clock-analyzer-mode-map
:n "RET" #'org-clock-analyzer-visit
:n "o" #'org-clock-analyzer-visit-other-window
:n "gr" #'org-clock-analyzer-refresh
:n "?" #'org-clock-analyzer-menu
:n "s" #'org-clock-analyzer-set-search
:n "S" #'org-clock-analyzer-clear-search
:n "r" #'org-clock-analyzer-set-range
:n "R" #'org-clock-analyzer-set-custom-range
:n "<" #'org-clock-analyzer-shift-range-backward
:n ">" #'org-clock-analyzer-shift-range-forward
:n "d" #'org-clock-analyzer-day-at-point
:n "w" #'org-clock-analyzer-week-at-point
:n "m" #'org-clock-analyzer-month-at-point
:n "t" #'org-clock-analyzer-timeline
:n "l" #'org-clock-analyzer-list
:n "c" #'org-clock-analyzer-check
:n "p" #'org-clock-analyzer-projects
:n "x" #'org-clock-analyzer-export-csv
:n "b" #'org-clock-analyzer-set-group-by
:n "T" #'org-clock-analyzer-toggle-tag-attribution
:n "h" #'org-clock-analyzer-highlight-at-point
:n "H" #'org-clock-analyzer-clear-highlight
:n "q" #'quit-window)
(map! :map org-clock-analyzer-list-mode-map
:n "RET" #'org-clock-analyzer-list-visit
:n "o" #'org-clock-analyzer-list-visit-other-window
:n "gr" #'org-clock-analyzer-list-refresh
:n "r" #'org-clock-analyzer-list-toggle-raw
:n "q" #'quit-window))
Org Contacts Config
A flat file of org headings is the simplest and in the unlikely case that I exceed dunbar’s number, there is GitHub - girzel/ebdb: An EIEIO port of BBDB, Emacs’ contact-management package For further customization options, check out: GitHub - tmalsburg/helm-org-contacts: A helm source address books in org-cont…
An evaluation of other contact management software in Emacs.
Install the below if we’re going to import contacts into Emacs. GitHub - jwiegley/ecard: Library for representing vCard data using EIEIO classes
;; Require org-contacts to work with mu4e
(after! org
(require 'org-contacts)
(setf org-contacts-files (list (concat +project-maria-dir+ "contacts.org"))))
Org Download Config
I don’t like the fixed image width setting for images. So, I wrote a custom function for a bit more dynamic rescaling of images:
https://github.com/sainathadapa/emacs-spacemacs-config/blob/mac/org-display-inline-images-custom.el
Always conserve the aspect ratio
Image shouldn’t exceed the current window’s width (minus 100 pixels)
Image shouldn’t exceed half of the current window’s height
Resize only if the actual dimensions do not conform to the above two points
Press 0 w (zero, then w) or C-0 w. This executes the command dired-copy-filename-as-kill with a zero prefix argument, which tells it to copy the absolute (full) file name
Two upstream behaviours have to be worked around on WSL:
org-download-clipboarddoes not consultorg-download-screenshot-method. It let-binds over it with asystem-typedispatch that demands xclip/wl-paste ongnu/linux, which is what WSL reports. So the PowerShell incantation has to be installed by overriding the command outright; an:aroundadvice would just be shadowed by that innerlet.- Doom’s
+dragndrop:configblock (modules/lang/org/contrib/dragndrop.el)setqsorg-download-screenshot-methodfrom a maim/scrot/gnome-screenshotcondthat yieldsnilon this machine. That runs at package load, so setting the variable at config top level is clobbered; it has to go insideafter!. - The replacement is a plain
defuninconfig.el, so invoking it by name (M-x bhw/org-download-clipboard) never goes through the autoload stub that Doom’s+dragndropuse-package!installs fororg-download-screenshot. It has torequirethe package itself, or it dies withvoid-functionwheneverorg-downloadhappens not to be loaded.
There is no usable screenshot binary under WSL anyway, so both entry points
are pointed at the same clipboard reader: grab a region with Win+Shift+S,
then M-x org-download-clipboard (or org-download-screenshot).
(setf org-download-method 'attach)
;; org-download's defcustom only becomes special once the package is loaded,
;; which the `require' below does at call time -- but declare it here too, so
;; the `let' binds dynamically even if config.el is ever byte-compiled.
(defvar org-download-screenshot-method)
(defun bhw/org-download-clipboard (&optional basename)
"Insert the clipboard image at point, via the Windows clipboard.
WSL replacement for `org-download-clipboard', which hardcodes
xclip/wl-paste on gnu/linux."
(interactive)
(require 'org-download)
(let ((org-download-screenshot-method
"powershell.exe -Command \"(Get-Clipboard -Format Image).Save('$(wslpath -w %s)')\""))
(org-id-get-create)
(org-download-screenshot basename)))
(after! org-download
;; https://www.reddit.com/r/emacs/comments/1ow0gza/some_tips_for_using_emacs_on_wsl/
(setf org-download-screenshot-method
"powershell.exe -Command \"(Get-Clipboard -Format Image).Save('$(wslpath -w %s)')\"")
(advice-add 'org-download-clipboard :override #'bhw/org-download-clipboard))
Ox Publish Config
https://nicolasknoebber.com/posts/blogging-with-emacs-and-org.html
Note that when editing this config you MUST properly escape " characters and avoid the use of the literal % character unless you are using it as intended i.e. %C
;;---------------------------------------------------------------------------
;; project-isidore serves ^/assets/ out of its own source root, so the
;; published blog lives at /assets/blog/, not at the site root. These three
;; locations used to be repeated as literals and the feed's copy had drifted
;; to the site root, which 404'd every entry link and the feed's own self URL.
(defconst bhw/blog-source-directory "~/project-maria/blog/"
"Directory holding the blog's Org sources.")
(defconst bhw/blog-publish-directory "~/common-lisp/project-isidore/assets/blog/"
"Directory `org-publish' writes the blog's HTML into.")
(defconst bhw/blog-base-url "https://bhw.name/assets/blog/"
"Public URL that serves `bhw/blog-publish-directory'.")
;; Declared special here so the `let' in `bhw/publish-blog' binds it
;; dynamically even when this file is evaluated before webfeeder is loaded.
(defvar webfeeder-date-function)
;; https://www.taingram.org/blog/org-mode-blog.html
;; These configure the exporter, not the publisher, so they hang off ox-html:
;; a manual C-c C-e h h in a session that never loaded ox-publish needs them
;; too. `org-html-prefer-user-labels' is what turns the ID properties stamped
;; by `bhw/ensure-headline-ids' into the pages' anchors.
(after! ox-html
(setf org-html-head-include-default-style nil
org-html-htmlize-output-type 'css
org-html-prefer-user-labels t))
(after! ox-publish
;; ox-extra's `ignore-headlines' lets us exclude a heading itself from
;; the ToC while still exporting its body.
;; https://emacs.stackexchange.com/questions/30183/orgmode-export-skip-ignore-first-headline-level
(require 'ox-extra)
(ox-extras-activate '(ignore-headlines))
(require 'ox-bibtex)
(require 'webfeeder)
(setf org-export-global-macros
'(("timestamp" . "@@html:<span class=\"timestamp\">[$1]</span>@@"))
org-export-with-broken-links t)
(defun bhw/org-sitemap-date-entry-format (entry style project)
"Format ENTRY in org-publish PROJECT Sitemap with a date prefix.
STYLE is the project's `:sitemap-style'. Under the `tree' style ENTRY may
be a directory, which has no title and no meaningful date, so mirror
`org-publish-sitemap-default-entry' for those."
(if (directory-name-p entry)
(if (eq style 'tree)
(file-name-nondirectory (directory-file-name entry))
entry)
(let ((title (org-publish-find-title entry project)))
(if (= (length title) 0)
(format "*%s*" entry)
(format "{{{timestamp(%s)}}} [[file:%s][%s]]"
(format-time-string "%Y-%m-%d"
(org-publish-find-date entry project))
entry
title)))))
(setf org-publish-project-alist
`(("blog"
:base-directory ,bhw/blog-source-directory
:html-extension "html"
:base-extension "org"
:recursive t
:publishing-function org-html-publish-to-html
:publishing-directory ,bhw/blog-publish-directory
:preparation-function bhw/blog-stamp-headline-ids
:section-numbers t
:table-of-contents t
:exclude "rss.org"
:with-title nil
:auto-sitemap t
:sitemap-filename "archive.org"
:sitemap-title "Blog Archive"
:sitemap-sort-files anti-chronologically
:sitemap-style tree
:sitemap-format-entry bhw/org-sitemap-date-entry-format
;; https://orgmode.org/manual/HTML-doctypes.html#HTML-doctypes
:html-doctype "html5"
:html-html5-fancy t
:html-head "
<link rel=\"stylesheet\" type=\"text/css\" href=\"../global.css\"/>
<link rel=\"stylesheet\"
href=\"//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.2.0/styles/base16/solarized-light.min.css\">
<script src=\"//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.2.0/highlight.min.js\" defer></script>
<script>var hlf=function(){Array.prototype.forEach.call(document.querySelectorAll(\"pre.src\"),function(t){var e;e=t.getAttribute(\"class\"),e=e.replace(/src-(\\w+)/,\"src-$1 $1\"),t.setAttribute(\"class\",e),hljs.highlightElement(t)})};addEventListener(\"DOMContentLoaded\",hlf);</script>"
:html-preamble "
<div class=\"header header-fixed\">
<div class=\"navbar container\">
<div class=\"logo\"><a href=\"/\">BHW</a></div>
<input type=\"checkbox\" id=\"navbar-toggle\" >
<label for=\"navbar-toggle\"><i></i></label>
<nav class=\"menu\">
<ul>
<li><a href=\"/about\">About</a></li>
<li><a href=\"/work\">Work</a></li>
<li><a href=\"/assets/blog/archive.html\">Blog</a></li>
<li><a href=\"/contact\">Contact</a></li>
</ul>
</nav>
</div>
</div>
<h1 class=\"title\">%t</h1>
<p class=\"subtitle\">%s</p> <br/>
<p class=\"updated\"><a href=\"/contact#article-history\">Updated:</a> %C</p>"
:html-postamble "<script>
const headers = Array.from( document.querySelectorAll('h2, h3, h4, h5, h6') );
headers.forEach( header => {
header.insertAdjacentHTML('afterbegin',
'<a href=\"#table-of-contents\">⇱</a>'
);
});
</script>
<hr/>
<footer>
<div class=\"copyright-container\">
Comments? Corrections? <a href=\"https://bhw.name/contact\"> Please do reach out.</a><a href=\"https://bhw.name/assets/blog/atom.xml\"> RSS Feed. </a><a href=\"https://bhw.name/subscribe\"> Mailing List. </a><br/>
Copyright 2021 Ben H. W. <br/>
Blog content is available under <a rel=\"license\" href=\"http://creativecommons.org/licenses/by-sa/4.0/\"> CC-BY-SA 4.0 </a> unless otherwise noted.<br/>
Created with %c on <a href=\"https://www.gnu.org\">GNU</a>/<a href=\"https://www.kernel.org/\">Linux</a><br/>
</div>
</footer>"))))
(defun bhw/ensure-headline-ids (&rest _)
"Give every heading in the current buffer an Org `ID' property.
`org-id-method' is `ts', so the ids are timestamps like
\"20240219T122532.621724\", which ox-html renders as the anchor
\"ID-20240219T122532.621724\" (see `org-html-prefer-user-labels' above).
Org's own anchors cannot be linked to: `org-export-new-reference' mints them
with `(random #x10000000)', and the `:crossrefs' cache meant to stabilise
them across publishes is a disposable file keyed on heading titles. Between
the 2026-05-08 and 2026-08-18 publishes of dotemacs.html, all 90 generated
anchors changed. A minted id is written back to the source, so it survives
both a cache wipe and a heading rename.
Headings that already carry a CUSTOM_ID keep it as their anchor -- ox-html
honours CUSTOM_ID unconditionally -- so the older slug URLs stay live. This
adds an ID beside them; it never overwrites one."
(interactive)
(require 'org-id)
;; `org-id-add-location' raises on a buffer with no file, which would abort
;; the very export this advises. Mirror `org-id-get''s own file resolution.
(when (or org-id-overriding-file-name (buffer-file-name (buffer-base-buffer)))
(org-map-entries #'org-id-get-create)))
;; Manual exports get the same treatment, so a post exported by hand anchors
;; the same way it does when published.
(advice-add 'org-html-export-to-html :before 'bhw/ensure-headline-ids)
(advice-add 'org-md-export-to-markdown :before 'bhw/ensure-headline-ids)
(defun bhw/blog-stamp-headline-ids (plist)
"Stamp IDs through every Org source of the blog project described by PLIST.
Wired as the project's `:preparation-function'.
Advising the publishing function would not work here: `org-html-publish-to-html'
runs before `org-publish-org-to' visits the file, so `org-map-entries' would
walk whatever buffer happened to be current instead.
Sources are saved when stamping changed them -- otherwise the new ids would
live only in a buffer that `org-with-file-buffer' then kills."
(require 'org-id)
(let ((sitemap (expand-file-name
(or (plist-get plist :sitemap-filename) "sitemap.org")
(plist-get plist :base-directory))))
(dolist (file (org-publish-get-base-files (cons "blog" plist)))
;; The sitemap is regenerated from scratch on every run, so ids written
;; into it would be discarded immediately.
(unless (file-equal-p file sitemap)
(org-with-file-buffer file
(bhw/ensure-headline-ids)
(when (buffer-modified-p) (save-buffer)))))))
(defun bhw/blog-post-html-files ()
"Return the blog's post pages, relative to `bhw/blog-publish-directory'.
Only pages that still have an Org source count. That drops the
`archive.html' sitemap, hand-placed files like LICENSE, editor lockfiles,
and -- the reason for the check -- HTML orphaned when a post is renamed or
deleted, which webfeeder would otherwise keep listing as a feed entry
pointing at a dead URL."
(seq-filter
(lambda (f)
(and (not (equal f "archive.html"))
(file-exists-p (expand-file-name (concat (file-name-base f) ".org")
bhw/blog-source-directory))))
(directory-files bhw/blog-publish-directory nil "\\.html\\'")))
(defun bhw/blog-post-date (html-file)
"Return the date of HTML-FILE's Org source, for `webfeeder-date-function'.
HTML-FILE is an absolute path under `bhw/blog-publish-directory'.
Webfeeder's default extractor wants an element of class `date' and falls
back to the export timestamp Org stamps into every page's <head>. This
project's `:html-postamble' emits no such element, so every entry was
dated to the last publish run: the feed re-dated and re-sorted every post
on each rebuild. Use the same date the sitemap does instead."
(let ((org-file (expand-file-name (concat (file-name-base html-file) ".org")
bhw/blog-source-directory)))
(if (file-exists-p org-file)
(org-publish-find-date org-file (assoc "blog" org-publish-project-alist))
(file-attribute-modification-time (file-attributes html-file)))))
;; Deliberately outside the `after!': it is the entry point, so defining it
;; only once ox-publish happens to be loaded made `M-x bhw/publish-blog'
;; unavailable in a fresh session.
(defun bhw/publish-blog ()
"Publish the blog project and rebuild atom.xml via webfeeder."
(interactive)
(require 'ox-publish)
(require 'webfeeder)
(org-publish "blog")
;; `bhw/blog-post-date' reads titles and dates through the publish cache.
(org-publish-initialize-cache "blog")
(let ((webfeeder-date-function #'bhw/blog-post-date))
(webfeeder-build
"atom.xml"
bhw/blog-publish-directory
bhw/blog-base-url
(bhw/blog-post-html-files)
:title "BHW Blog"
;; `webfeeder-make-atom' has no :description -- Atom's feed-level
;; element is <subtitle> -- and &allow-other-keys swallowed it silently.
:subtitle "Ben's personal blog"
;; Atom requires a feed-level author unless every entry carries one.
:author "Ben H. W.")))
Org Re Reveal Config
ltx-talk — Beamer Atelier Add audio to presentation? oer / emacs-reveal · GitLab
(use-package! org-re-reveal
:after org
:config
(setf org-re-reveal-revealjs-version "4"
org-re-reveal-root "https://cdn.jsdelivr.net/npm/reveal.js"))
Plantuml Config
<- Plant UML
For collaborative diagrams and brainstorming, consider https://plus.excalidraw.com/
sudo apt install plantuml
Download Plantuml.jar and place it in the root of your home directory, at ~/plantuml.jar. Control output type with plantuml-output-type. Org babel file header is required.
Old config:
(setf plantuml-default-exec-mode 'jar
plantuml-jar-path "/usr/share/plantuml/plantuml.jar"
org-plantuml-jar-path "/usr/share/plantuml/plantuml.jar"
plantuml-output-type "txt")
(with-eval-after-load "org-mode"
(add-to-list 'org-src-lang-modes '("plantuml" . plantuml)))
Python Config
- package manager Installation | uv
- LSP server Installation | ty
- linter Installing Ruff | Ruff
Enabled in Emacs Initialization as (lsp +eglot) plus (python +lsp
+tree-sitter +uv), with :tools tree-sitter (required by the +tree-sitter
flag). Eglot forwards diagnostics to flycheck through flycheck-eglot, which
is also where claude-code-ide’s getDiagnostics MCP tool reads them from.
;;---------------------------------------------------------------------------
;; Eglot's stock python entry is an `eglot-alternatives' list in which pylsp,
;; basedpyright, pyright, and pyrefly all precede ty -- installing any of them
;; (say, into some project venv) would silently displace ty. Prepending a
;; dedicated entry makes ty the unconditional choice.
(after! eglot
(add-to-list 'eglot-server-programs
'((python-mode python-ts-mode) . ("ty" "server"))))
1.3.13. Email Config
- <–Linux Program Directory
- <–Communication in Emacs
- Mu4e with Microsoft Outlook 365
- All Computers Are Brilliant, Inc. > Alternative to self-hosting
- Sign Always, Encrypt When Possible: Automatic GPG in mu4e - Bounga’s Home
- Re-auth the M365 token (the cached refresh token has expired — device-code sign-in as the Microsoft 365 identity being refreshed):
~/.local/bin/uv run ~/.config/oauth2/m365-token.py --account companions --login
Only one maildir is synced (/gmail). Every non-Gmail identity below is
send-only: its incoming mail is redirected or forwarded into the Gmail
mailbox and arrives through the single gmail mbsync channel, so none has an
mbsync channel, maildir, or mu4e context of its own.
Note on placeholders: the addresses and domains in this section are
placeholders (me@gmail.com, work.example, school.example). The real
values live in personal-info.el (dotfiles.org §personal-info), which is not
published.
me@work.example is send-only (2026-07-23):
- Receive: nothing in Emacs. An Outlook redirect rule on the work tenant
forwards everything to me@gmail.com, so it lands in
/gmailthrough the existing mbsync channel. Redirect (unlike forward) preserves the originalTo:, which is what letsbhw/mu4e-set-from-address-dwimput the right From on a reply. No mbsync channel, maildir, or mu4e context of its own. - Send: smtp.office365.com:587 XOAUTH2 —
bhw/m365-accountsmaps the From address tom365-token.py --account companions, whose cache is separate so the two refresh tokens are independent. Seed it once withuv run ~/.config/oauth2/m365-token.py --account companions --login. - The address was already in
bhw/mu4e-address-routingterritory and in themu init --my-addresslist below, so no store re-init was needed. - work.example is Microsoft 365 (MX → work-example.mail.
protection.outlook.com), SPF
-all, DMARCp=none. Sending through M365 rather than Gmail keeps DKIM aligned (d=work.example). - Verified end-to-end . Outbound: Gmail stamps
dkim=pass header.i=@work.example header.s=selector1,spf=pass,dmarc=pass. Inbound: a message addressed to me@work.example lands in the Gmail inbox via the redirect. Caveat found while testing — a redirect re-sends under a work.example envelope, so if the original sender domain publishes DMARCp=reject(e.g. bhw.name), the forwarded copy getsdmarc=fail ... dis=QUARANTINEat the far end; Microsoft’s ARC seal is what keeps Gmail delivering it anyway. Ordinary third-party senders are unaffected.
me@school.example is send-only (2026-09-01):
- Symptom: mail composed in mu4e as this address landed in the Spam folder of school.example recipients (a staff thread).
- Cause: the address is only a Gmail “Send mail as” alias, so
bhw/set-smtp-from-identityfell through to thetbranch and submitted it on the personal Gmail login. Gmail then rewrites the envelope of an alias send to the authenticated account and signs DKIMd=gmail.com. Confirmed by probe : a submission withMAIL FROM:<me@school.example>came back stored withReturn-Path: <me@gmail.com>. With neither SPF nor DKIM aligned toFrom: ...@school.example, DMARC fails, and school.example publishesp=quarantine; sp=quarantine; pct=100(DMARCLY-managed) – and the recipients are on the same Google Workspace tenant, which treats unauthenticated mail claiming its own domain harshly. - Fix:
bhw/google-workspace-sendersroutes the address to smtp.gmail.com under its own login (Google app password in ~/.authinfo). Google then uses an school.example envelope – SPF passes and aligns, since school.example’s SPF isinclude:_spf.google.complus a DMARCLY include covering Google’s netblocks – and signs with the tenant’s DKIM key (google._domainkey.school.exampleis published; older mail shows the defaultschool-example.20230601.gappssmtp.com). - No inbox of its own: school.example already forwards to me@gmail.com
(
X-Forwarded-For: me@school.example me@gmail.com), so incoming mail arrives through the existinggmailmbsync channel. Sent copies go to the school.example account’s Sent folder upstream; the local archive copy is mu4e’s own (mu4e-sent-messages-behaviordefaults tosent). - App password prerequisites on the school.example Google account: 2-Step Verification on, and the Workspace admin must leave IMAP/SMTP client access enabled.
- Verified end-to-end through
smtpmail-send-itwith the advice live. Delivered copy:Return-Path: <me@school.example>,spf=pass ... smtp.mailfrom=me@school.example,dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=school.example. Controlled before/after on the same recipient (me@work.example, whose Outlook redirect bounces mail back to Gmail): the old alias path never reached that inbox at all in 15 minutes – M365 junked it, so the redirect rule never fired – while the fixed path was delivered and redirected back inside 30 seconds. - DMARC here rests on SPF alignment, not DKIM: school.example’s outbound DKIM is
Google’s default
d=school-example.20230601.gappssmtp.com, whose organizational domain does not align with school.example under relaxed alignment. That is fine for direct delivery but means a forwarded copy (SPF broken by the forwarder, DKIM unaligned) fails DMARC unless the forwarder ARC-seals it. A custom DKIM key is published atgoogle._domainkey.school.example; if the admin has switched the tenant to it, outbound is signedd=school.exampleand alignment no longer depends on the envelope.
GitHub - laurynas-biveinis/mu4e-autotask: Email automation for mu4e · GitHub
Amazon.com: Run Your Own Mail Server (IT Mastery): 9781642350784: Lucas, Mich… Book for if I want to self-host.
Use gmail’s send as feature with porkbun. Check email compliance status
Run the below when Office 365 sending breaks (“silent token failed” means the refresh token died),
uv run ~/.config/oauth2/m365-token.py --account companions --login
Gmail Configuration Tips
Use POP3 forwarding on proper emails. Then check box enabling us to send as those addresses. Inbox options should be default with all categories checked off. Finally must use custom filters to disable gmail auto spam filter. I don’t want important messages auto tagged as spam. Let me decide for myself please gmail.
- Click Create a new filter.
- Enter {(to:me) (deliveredto:USERNAME@gmail.com)} in the Has the words field (replacing USERNAME with your actual Gmail username).
- Click Create filter.
- In addition, create another one with the Has the words field with “is:spam” and of course, set all filters to never send items to the spam folder.
Note you can convert Gmail takeout’s mbox format to the format used by Mu4e,
maildir, by using mb2md in the debian apt repositories.
Mu4e Config
Updating Inbox & Sorting Mail view mu4e-update process by list-process command in SPC a p.
Key Effect d mark for deletion r mark as read x execute marked actions * mark for unknown actions # resolve unknown actions Reading Mail For html mail, use the view in browser capability
Key Effect aV view email in browser (can be eww,firefox etc) K yank links, followed by a numerical argument use M-x customize-variable to find possible values for browse-url-browser-function
Composing Mail Emails are currently composed within org-mode and then exported to html. Search through contacts.org and then email the appropriate person. Make sure to edit the html before sending it out.
Locate recipient of email in contacts.org Compose emails first in org mode through email-template.org in project-maria export through “org-mime-org-subtree-htmlize Hit” “, e s” to export the subtree to mu4e compose Verify html renders correctly send with “, ,” TIP: press tab to autocomplete addresses
Switching Accounts Accounts live in ~/.mbsyncrc (dotfiles.org §mbsyncrc) + the mu4e-contexts below.
;switches context in the main/headers view;C-c C-;(mu4e-compose-context-switch) flips the From in a compose buffer.To add/remove an address from the mu store, re-init it (mu4e must NOT be running — quit it first; the maildir itself is untouched):
mu init --maildir=/home/ben/project-jerome/email-archive \ --my-address=me@gmail.com --my-address=me@school.example \ --my-address=alias1@bhw.name --my-address=alias2@bhw.name \ --my-address=alias3@bhw.name --my-address=old1@school2.example \ --my-address=old2@school3.example --my-address=me@work.example(Placeholders — the real address list is
bhw/mu4e-address-routinginpersonal-info.el, dotfiles.org §personal-info.) thenmbsync -aandmu index. Now in emacs `M-x mu4e’ should work.
| Key | Effect |
|---|---|
| P | Toggle threading |
| W | Show related messages |
| S | Search while in mu4e header buffer |
| SPC s e | Search all emails through helm completion |
| \ | Mu4e headers search narrow. C-u to remove results limit for the next search |
| M-left/right | Previous, next query |
| o | Save email attachment - gnus-mime-save-part |
| SPC a o l | org-store-link (C-c M-l) org-insert-last-stored-link |
| Key | Effect |
|---|---|
| OG | Search group |
;;---------------------------------------------------------------------------
(map! :leader
:desc "Email" "oe" #'mu4e)
;; Defer mu4e off the startup path but still begin background mail sync
;; shortly after launch: `mu4e' with a non-nil BACKGROUND arg loads the
;; package (running the `after!' block below) and arms the periodic
;; mbsync/index timer without opening any UI. Cancel-before-arm keeps
;; `doom/reload' from stacking timers.
(defun bhw/mu4e-start-in-background ()
"Start mu4e headlessly so background mail sync begins."
(unless (featurep 'mu4e) (mu4e t)))
(cancel-function-timers #'bhw/mu4e-start-in-background)
(run-with-idle-timer 60 nil #'bhw/mu4e-start-in-background)
(after! mu4e
(require 'mu4e-contrib)
;; https://mu-discuss.narkive.com/hXk7RbcH/set-from-address-depending-on-to-address-header
;; On `mu4e-compose-mode-hook', NOT `mu4e-compose-pre-hook': in mu4e 1.12
;; the pre-hook fires before the context autoswitch (`mu4e--draft' ->
;; `mu4e--prepare-draft' -> `mu4e--context-autoswitch'), whose :vars are
;; applied globally, so a pre-hook From pick is clobbered whenever a reply
;; crosses contexts. In the mode hook the draft buffer exists,
;; `mu4e-compose-parent-message' is permanent-local in it, and we rewrite
;; the generated From: header directly (same technique as
;; `bhw/mu4e-empty-from-for-new' and mu4e's own
;; `mu4e-compose-context-switch').
(defun bhw/mu4e-set-from-address-dwim ()
"Align From: with the routing alias the parent message was addressed to.
Match the parent's To: against `bhw/mu4e-address-routing'; on a match,
point `user-mail-address' (buffer-locally) and the From: header at it.
New mail (no parent) or no match: keep the context's default From."
(when-let* ((msg mu4e-compose-parent-message)
(addr (seq-find
(lambda (a)
(mu4e-message-contact-field-matches msg :to a))
bhw/mu4e-address-routing)))
(setq-local user-mail-address addr)
(save-excursion
(message-replace-header "From" (message-make-from)))))
(evil-set-initial-state 'mu4e-headers-mode 'normal)
(evil-set-initial-state 'mu4e-view-mode 'normal)
(evil-set-initial-state 'mu4e-compose-mode 'insert)
(setf mu4e-change-filenames-when-moving t ; mbsync specific.
;; Contexts: start in the first one (personal); when composing, derive
;; the context from the parent message's maildir, otherwise stay in
;; the current one (ask only if somehow none is active yet). These
;; restate Doom's defaults on purpose.
mu4e-context-policy 'pick-first
mu4e-compose-context-policy 'ask-if-none
;; see an ASCII table for the character decimal codes
mu4e-bookmarks '(("maildir:/gmail/inbox" "Inbox" 105 )
("\"maildir:/gmail/[Gmail]/All Mail\" and flag:unread" "Unread" 85)
("\"maildir:/gmail/[Gmail]/All Mail\"" "All Mail" 97)
("\"maildir:/gmail/[Gmail]/Sent Mail\"" "Sent Mail" 115))
user-mail-address bhw/default-mail-address
user-full-name "Ben H. W."
;; mu4e-compose-signature
mail-user-agent 'mu4e-user-agent
;; `bhw/windows-download-dir' is defined in personal-info.el
;; (dotfiles.org §personal-info): it embeds the Windows user name.
mu4e-attachment-dir bhw/windows-download-dir
;; Every identity files drafts/sent/trash/refile into the Gmail
;; folders: one archive. (These moves are local-only mirrors -- the
;; mbsync channel does not push new messages upstream.)
mu4e-drafts-folder "/gmail/[Gmail]/Drafts"
mu4e-sent-folder "/gmail/[Gmail]/Sent Mail"
mu4e-trash-folder "/gmail/[Gmail]/Trash"
mu4e-refile-folder "/gmail/[Gmail]/All Mail"
;; Use a real domain in Message-IDs; otherwise message.el derives
;; them from the WSL hostname (@<host>.localdomain), and Google's
;; sender guidelines want an RFC 5322 Message-ID on a valid domain.
message-user-fqdn "bhw.name"
send-mail-function 'smtpmail-send-it
smtpmail-stream-type 'starttls
smtpmail-default-smtp-server "smtp.gmail.com"
smtpmail-smtp-server "smtp.gmail.com"
smtpmail-smtp-service 587
message-sendmail-f-is-evil t
mu4e-index-update-in-background t
mu4e-update-interval 3600
mu4e-get-mail-command "mbsync -a"
mu4e-hide-index-messages t
mu4e-search-skip-duplicates t
;; Prefer text/plain over text/html in multipart/alternative messages.
mm-discouraged-alternatives '("text/html" "text/richtext")
gnus-blocked-images "."
mu4e-org-link-query-in-headers-mode nil
;; mu4e-org-contacts-file (concat +project-maria-dir+ "contacts.org")
message-kill-buffer-on-exit t
mu4e-confirm-quit nil
;; mu4e-headers-time-format "%y/%m/%d %H:%M"
;; mu4e-headers-fields
;; '((:human-date . 14)
;; (:from-or-to . 20)
;; (:subject))
mml-secure-openpgp-sign-with-sender t
mml-secure-openpgp-signers '("06DDA93690F775E3715B628CCA949A6D46BC2BBE")
mu4e-compose-complete-addresses t
mu4e-compose-complete-only-after "2018-01-01"
browse-url-filename-alist
'(("^/\\(ftp@\\|anonymous@\\)?\\([^:/]+\\):/*" . "ftp://\\2/")
("^/\\([^:@/]+@\\)?\\([^:/]+\\):/*" . "ftp://\\1\\2/")
;; For gnus-article-browse-html-article on Windows Subsystem for Linux.
("^/+" . "file://///wsl$/Debian/"))
mu4e-modeline-support nil
mu4e-search-include-related nil
;; One window: reading a message replaces the headers buffer in the
;; current window instead of splitting it (mu4e's default is
;; `horizontal'). `nil' rather than `single-window' so the main
;; view stays a real buffer instead of a minibuffer menu
;; (`mu4e--main-view'). `mu4e-view-quit' (q) then kills the view
;; buffer and puts point back on the message just read.
mu4e-split-view nil
mu4e-notification-function nil)
;; --- Context: personal ---------------------------------------------------
;; Only one maildir is synced (/gmail), so there is a single context and it
;; carries just the default From; every other identity is send-only.
;; Aliases are refined on top by `bhw/mu4e-set-from-address-dwim'; transport
;; is chosen per-From by `bhw/set-smtp-from-identity'. Kept as a context
;; rather than folded into the global `user-mail-address' so that adding a
;; second receiving account later is a one-entry change.
(setf mu4e-contexts
(list
(make-mu4e-context
:name "personal"
:match-func (lambda (_msg) t)
:vars `((user-mail-address . ,bhw/default-mail-address)))))
;; Appended (t) so it runs after `org-msg-post-setup' on this hook.
(add-hook 'mu4e-compose-mode-hook #'bhw/mu4e-set-from-address-dwim t)
;; Doom's `+mu4e-set-from-address-h' (on `mu4e-compose-pre-hook') pops a
;; `completing-read'/consult prompt for the From: alias on new messages. We
;; handle From: ourselves: `bhw/mu4e-set-from-address-dwim' for
;; replies/forwards and `bhw/mu4e-empty-from-for-new' for new mail. Drop the
;; Doom hook so composing a new email doesn't prompt for the sender.
(remove-hook 'mu4e-compose-pre-hook #'+mu4e-set-from-address-h)
;; --- Multi-account outgoing SMTP -----------------------------------------
;; The base config (above) points smtpmail at Gmail. Before each send, pick
;; the transport + auth mechanism from the From: address. Gmail uses the
;; app-password in ~/.authinfo (LOGIN); the Microsoft 365 addresses in
;; `bhw/m365-accounts' use XOAUTH2 via the external token helper;
;; OCI-approved bhw.name senders relay through OCI Email Delivery, which
;; advertises only AUTH PLAIN. Because `smtpmail-auth-supported' is global,
;; we set it per-send so Gmail never tries XOAUTH2 (it has no token), M365
;; never tries LOGIN (basic auth may be disabled tenant-side), and OCI only
;; tries PLAIN (it rejects LOGIN, 504).
(require 'smtpmail)
;; Each entry needs a matching ~/.authinfo line
;; machine smtp.office365.com login <ADDR> port 587 password <placeholder>
;; -- `smtpmail-try-auth-methods' filters those lines by `:user
;; smtpmail-smtp-user', so several may share the host, but it skips
;; authentication outright if auth-source yields no secret; hence the
;; placeholder password. The real bearer token comes from the helper below.
;; `bhw/m365-accounts' -- an alist of From address -> `m365-token.py
;; --account' name, mapping each Microsoft 365 address to the token
;; cache/identity the helper refreshes for it -- is defined in
;; personal-info.el (dotfiles.org §personal-info) to keep the real
;; addresses out of this published file. Every entry is send-only: its
;; incoming mail reaches Gmail through an Outlook redirect rule, so it has
;; no mbsync channel, maildir, or mu4e context of its own.
;; Quoted specializer: Emacs 30's smtpmail ships its own `(eql 'xoauth2)'
;; method (it would send the authinfo placeholder password as the bearer
;; token). The same quoted form makes this definition replace the built-in
;; rather than coexist with it under ambiguous dispatch.
(cl-defmethod smtpmail-try-auth-method
(process (_mech (eql 'xoauth2)) user _password)
"Authenticate to SMTP with XOAUTH2, fetching a fresh M365 access token.
USER is the login smtpmail matched in ~/.authinfo, which selects the
token helper account via `bhw/m365-accounts'."
(let* ((account (or (cdr (assoc (downcase user) bhw/m365-accounts))
(user-error "No M365 token account configured for %s" user)))
(token
(with-temp-buffer
(let ((status (call-process
"/home/ben/.local/bin/uv" nil t nil
"run" "/home/ben/.config/oauth2/m365-token.py"
"--account" account "--token")))
(unless (eql status 0)
(user-error "M365 token helper failed for %s (exit %s): %s"
account status (string-trim (buffer-string))))
(string-trim (buffer-string)))))
(resp (base64-encode-string
(concat "user=" user (string 1)
"auth=Bearer " token (string 1) (string 1))
t)))
(smtpmail-command-or-throw process (concat "AUTH XOAUTH2 " resp) 235)))
;; Google Workspace mailboxes that authenticate to Gmail's SMTP *as
;; themselves* rather than riding the personal Gmail login below. Sending
;; them through the `t' branch is what put mail to school.example staff in Spam:
;; for a "Send mail as" alias Gmail rewrites the envelope to the
;; authenticated account -- verified [2026-09-01 Tue], a submission with
;; MAIL FROM:<me@school.example> came back stamped `Return-Path:
;; <me@gmail.com>' -- and signs DKIM d=gmail.com. Neither
;; identifier aligns with `From: ...@school.example', so DMARC fails, and school.example
;; publishes p=quarantine (sp=quarantine, pct=100). Authenticating as the
;; Workspace mailbox restores an school.example envelope (SPF pass and aligned --
;; school.example's SPF includes _spf.google.com) plus an school.example DKIM signature.
;; Needs a Google app password on that account:
;; machine smtp.gmail.com login me@school.example port 587 password <app-pw>
;; Send-only, like me@work.example: incoming school.example mail already
;; reaches /gmail through the Workspace forward-to-Gmail rule, so this adds
;; no mbsync channel, maildir, or mu4e context.
;; `bhw/google-workspace-senders' -- From addresses submitted to
;; smtp.gmail.com under their own login -- is defined in personal-info.el
;; (dotfiles.org §personal-info). Each needs a `machine smtp.gmail.com
;; login ADDR port 587' line in ~/.authinfo holding a Google app password.
;; From addresses registered as OCI Email Delivery "Approved Senders". Only
;; these may be MAIL FROM through OCI; add one *after* approving it in the OCI
;; console, or OCI refuses the envelope sender. Relaying bhw.name mail through
;; OCI (vs. Gmail's app-password path) gets it DKIM-signed d=bhw.name -- see
;; the DKIM note above.
;; `bhw/oci-approved-senders' -- From addresses relayed through OCI Email
;; Delivery instead of Gmail -- is defined in personal-info.el
;; (dotfiles.org §personal-info), alongside the other real addresses.
(defun bhw/set-smtp-from-identity (&rest _)
"Choose SMTP server + auth mechanism from the From: header before sending.
Installed as `:before' advice on `smtpmail-send-it' (see below), not on
`message-send-mail-hook': the hook was observed to leave a previous OCI
send's `smtpmail-smtp-server' in place for a subsequent Gmail send, which
OCI then rejected with \"535 ... Envelope From address <...> not
authorized\" (a gmail.com envelope is not an OCI Approved Sender).
`smtpmail-send-it' provably runs for every smtpmail send and sees the
finalized From:, so recomputing the transport here is leak-proof.
Takes &rest args so it is usable as advice on a no-arg function."
(let* ((from (message-field-value "from"))
(addr (downcase (or (cadr (mail-extract-address-components (or from ""))) ""))))
(cond
;; Microsoft 365: the accounts differ only in the SMTP login, which is
;; the From address itself and also picks the token helper account.
((assoc addr bhw/m365-accounts)
(setq smtpmail-smtp-user addr
smtpmail-smtp-server "smtp.office365.com"
smtpmail-smtp-service 587
smtpmail-stream-type 'starttls
smtpmail-auth-supported '(xoauth2)))
((member addr bhw/oci-approved-senders)
;; OCI Email Delivery (ca-toronto-1). `smtpmail-smtp-user' is left nil so
;; the SMTP username -- a long OCID, kept out of this public file -- is
;; read from ~/.authinfo along with the password, keyed by host + port:
;; machine smtp.email.ca-toronto-1.oci.oraclecloud.com login <OCID> port 587 password <pw>
;; Envelope MAIL FROM is taken from the From: header (mail-specify-envelope-from
;; is nil), so it equals ADDR above, which must be an Approved Sender.
(setq smtpmail-smtp-user nil
smtpmail-smtp-server "smtp.email.ca-toronto-1.oci.oraclecloud.com"
smtpmail-smtp-service 587
smtpmail-stream-type 'starttls
smtpmail-auth-supported '(plain)))
((member addr bhw/google-workspace-senders)
;; Same host as the `t' branch -- only the login differs, and that is
;; the whole point: the authenticated account is what decides which
;; domain Google puts in the envelope and the DKIM signature.
;; `smtpmail-try-auth-methods' selects the ~/.authinfo line by host +
;; `smtpmail-smtp-user', so the two smtp.gmail.com logins coexist.
(setq smtpmail-smtp-user addr
smtpmail-smtp-server "smtp.gmail.com"
smtpmail-smtp-service 587
smtpmail-stream-type 'starttls
smtpmail-auth-supported '(login plain)))
(t
(setq smtpmail-smtp-user bhw/default-mail-address
smtpmail-smtp-server "smtp.gmail.com"
smtpmail-smtp-service 587
smtpmail-stream-type 'starttls
smtpmail-auth-supported '(login plain))))))
;; Recompute the transport as `:before' advice on `smtpmail-send-it' rather
;; than via `message-send-mail-hook'. The hook was observed to leave a prior
;; OCI send's server in place for a later Gmail send (-> OCI 535 "Envelope
;; From ... not authorized"). `smtpmail-send-it' runs for every send and sees
;; the final From:, so selecting here can't inherit stale state.
(advice-add 'smtpmail-send-it :before #'bhw/set-smtp-from-identity)
;; (Removed with the last IMAP-synced Microsoft 365 account: an advice on
;; `mu4e--update-sentinel-func' used to scan the `mbsync -a' output for a
;; dead M365 refresh token. No M365 identity is pulled by mbsync any more
;; -- they are all send-only -- so a dying token now surfaces at send time,
;; where `smtpmail-try-auth-method' raises it directly.)
;; Leave the From: header empty for brand-new messages so the sending
;; identity must be chosen explicitly. Replies/forwards/edits keep the
;; From set by the context + `bhw/mu4e-set-from-address-dwim' above (the
;; two never overlap: the dwim needs a parent message, this needs type
;; `new'). Appended (t) so it runs after `org-msg-post-setup', which is
;; also on this hook.
(defun bhw/mu4e-empty-from-for-new ()
"Empty the From: header in new (`mu4e-compose-type' = `new') buffers."
(when (eq mu4e-compose-type 'new)
(save-excursion
(save-restriction
(message-narrow-to-headers)
(goto-char (point-min))
(when (re-search-forward "^From:.*$" nil t)
(replace-match "From:"))))))
(add-hook 'mu4e-compose-mode-hook #'bhw/mu4e-empty-from-for-new t)
;; (add-hook
;; 'mu4e-headers-mode-hook
;; (lambda () (define-key evil-motion-state-map (kbd "RET") nil)))
;; (add-hook
;; 'mu4e-view-mode-hook
;; (lambda () (define-key evil-normal-state-map (kbd "a") nil)))
;; (evil-define-key 'normal mu4e-headers-mode-map
;; "RET" #'mu4e-headers-view-message
;; "s" #'avy-goto-word-or-subword-1
;; "e" #'mu4e-headers-flag-all-read
;; "E" #'mu4e-headers-mark-all)
;; --- Quitting the view returns to the headers list -----------------------
;; With `mu4e-split-view' nil the view buffer replaces the headers buffer in
;; the same window, and mu4e's `mu4e-view-quit' handles that case by merely
;; killing the view buffer: nothing ever displays the headers buffer again.
;; It is Emacs' per-window buffer history (`switch-to-prev-buffer') that
;; happens to bring it back, so anything that has touched that window since
;; -- a popup, a workspace switch, reaching the message from somewhere other
;; than the headers list -- leaves another buffer on top of that history and
;; `q' drops us there instead of into the headers list. mu4e's
;; point-restoring half never works at all: after its `(kill-buffer)' the
;; current buffer is whatever Emacs falls back to (`*scratch*' here), not
;; the headers buffer, so its `mu4e~headers-goto-docid' call runs in the
;; wrong buffer and is swallowed by the surrounding `ignore-errors'.
;;
;; So do it explicitly: put the linked headers buffer back into the window
;; the view occupied, move point onto the message just read, then kill the
;; view. Split-view layouts already do the right thing
;; (`kill-buffer-and-window'), and a detached view has no headers buffer to
;; go back to, so defer to mu4e in both of those cases.
(defun bhw/mu4e-view-quit-to-headers ()
"Kill the mu4e view buffer and return to its headers buffer.
Like `mu4e-view-quit', but displays the linked headers buffer in the
window the view occupied and puts point back on the message that was
being read, instead of leaving both to window history."
(interactive)
(let ((view (current-buffer))
(headers (and (bound-and-true-p mu4e-linked-headers-buffer)
(buffer-live-p mu4e-linked-headers-buffer)
mu4e-linked-headers-buffer))
(docid (ignore-errors (mu4e-field-at-point :docid)))
(win (get-buffer-window (current-buffer))))
(if (or (memq mu4e-split-view '(horizontal vertical)) (not headers))
(mu4e-view-quit)
;; the bookkeeping `mu4e-view-quit' does before killing the view
(with-current-buffer headers
(when (eq (selected-window) mu4e~headers-view-win)
(setq mu4e~headers-view-win nil)))
(setq mu4e-linked-headers-buffer nil)
(if (window-live-p win)
(with-selected-window win
(switch-to-buffer headers)
(when docid (ignore-errors (mu4e~headers-goto-docid docid))))
(mu4e-display-buffer headers t)
(with-current-buffer headers
(when docid (ignore-errors (mu4e~headers-goto-docid docid)))))
(when (buffer-live-p view) (kill-buffer view)))))
;; A remap rather than a `q' binding: it catches every key bound to
;; `mu4e-view-quit', including the one evil-collection installs.
(define-key mu4e-view-mode-map
[remap mu4e-view-quit] #'bhw/mu4e-view-quit-to-headers)
;; `mu4e-view-headers-next' (C-j, via evil-collection's `next-section-2')
;; returns the new docid, or nil when there is no next header -- at the
;; last message `mu4e~headers-move' just lands on the "End of search
;; results" footer line, which has no docid, and silently does nothing.
;; Since `mu4e-split-view' is nil the view buffer owns the whole window,
;; so "nothing happened" is invisible; say so and drop back to the headers
;; list, which also undoes the footer-line drift. The return is on a timer
;; (not `sit-for') so the second does not block input; it is guarded because
;; the user may have moved on.
(defun bhw/mu4e-view-headers-next-or-back ()
"View the next message; if there is none, return to the headers view.
Report in the echo area (and *Messages*) that there are no more
messages, then after one second go back to the mu4e headers buffer."
(interactive)
(unless (mu4e-view-headers-next)
(let ((buf (current-buffer))
(msg "mu4e: no more messages -- returning to headers"))
(message "%s" msg)
(run-at-time
1 nil
(lambda ()
(when (and (buffer-live-p buf)
(eq buf (window-buffer (selected-window))))
(with-current-buffer buf
(bhw/mu4e-view-quit-to-headers))
(message "%s" msg)))))))
;; --- Elide long recipient lists in the view ------------------------------
;; Gnus renders To:/Cc:/Bcc: verbatim from the message, so a mail addressed
;; to a large list pushes the body off-screen and has to be scrolled past
;; before every read. Show the first `bhw/mu4e-view-recipients-shown'
;; addresses and collapse the rest behind a "+N more" marker.
;;
;; The buffer text is left intact -- an overlay `display' property does the
;; hiding -- so yanking the header still yields every address, hovering the
;; marker shows the full list, and `bhw/mu4e-view-toggle-recipients'
;; (RET or mouse-1 on the marker) flips it back. No mode-map key is
;; taken for it: mu4e binds `,' in the view to `mu4e-sexp-at-point', and
;; the overlay's own keymap is enough to reach the toggle. The one
;; edit made to the buffer is unfolding the header's RFC 5322 continuation
;; lines first: without that a folded header still costs a screen line per
;; fold no matter how few addresses survive.
(defvar bhw/mu4e-view-recipients-shown 2
"How many addresses of a recipient header to show before eliding.")
(defvar bhw/mu4e-view-elide-headers '("To" "Cc" "Bcc")
"Header fields whose address lists get elided in the mu4e view.")
(defvar bhw/mu4e-view-elided-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'bhw/mu4e-view-toggle-recipients)
(define-key map [mouse-1] #'bhw/mu4e-view-toggle-recipients)
map)
"Keymap active on the \"+N more\" recipient marker.")
(defun bhw/mu4e-view--address-commas (beg end)
"Return the positions of address-separating commas between BEG and END.
Commas inside quoted display names, angle-addresses or RFC 5322
comments do not separate addresses, so they are skipped."
(let ((pos beg) (depth 0) (in-quote nil) (escaped nil) (commas nil))
(while (< pos end)
(let ((c (char-after pos)))
(cond
(escaped (setq escaped nil))
((eq c ?\\) (setq escaped t))
(in-quote (when (eq c ?\") (setq in-quote nil)))
((eq c ?\") (setq in-quote t))
((memq c '(?< ?\()) (setq depth (1+ depth)))
((memq c '(?> ?\))) (setq depth (max 0 (1- depth))))
((and (eq c ?,) (zerop depth)) (push pos commas))))
(setq pos (1+ pos)))
(nreverse commas)))
(defun bhw/mu4e-view--header-region (field limit)
"Return (BEG . END) spanning the value of header FIELD, or nil.
The search stops at LIMIT; continuation lines are included."
(save-excursion
(goto-char (point-min))
(when (re-search-forward
(concat "^" (regexp-quote field) ":[ \t]*") limit t)
(let ((beg (point)))
(forward-line 1)
(while (and (< (point) limit) (looking-at-p "[ \t]"))
(forward-line 1))
(cons beg (max beg (1- (point))))))))
(defun bhw/mu4e-view--elide-header (field max)
"Hide all but the first MAX addresses of header FIELD behind a marker."
(let* ((limit (save-excursion (article-goto-body) (point)))
(region (and (>= max 1) (bhw/mu4e-view--header-region field limit)))
(beg (car-safe region))
(end (cdr-safe region))
(commas nil))
(when region
(save-excursion ; unfold, then find the separators
(save-restriction
(narrow-to-region beg end)
(goto-char (point-min))
(while (re-search-forward "[ \t]*\n[ \t]+" nil t)
(replace-match " "))
(setq end (point-max))))
(setq commas (bhw/mu4e-view--address-commas beg end)))
(when (>= (length commas) max)
(let* ((tail (buffer-substring-no-properties
(min end (1+ (car (last commas)))) end))
(total (+ (length commas) (if (string-blank-p tail) 0 1)))
(extra (- total max))
(cut (nth (1- max) commas))
(label (propertize (format " … +%d more" extra)
'face 'shadow 'mouse-face 'highlight)))
;; Skip when the marker would not actually be shorter (e.g. one
;; more address, spelled `a@b').
(when (and (> extra 0) (> (- end cut) (length label)))
(let ((ov (make-overlay cut end)))
(overlay-put ov 'bhw/mu4e-elided label)
(overlay-put ov 'display label)
(overlay-put ov 'help-echo
(buffer-substring-no-properties beg end))
(overlay-put ov 'keymap bhw/mu4e-view-elided-map)))))))
(defun bhw/mu4e-view-elide-recipients ()
"Elide long recipient lists in the freshly rendered message view."
(dolist (field bhw/mu4e-view-elide-headers)
(ignore-errors
(bhw/mu4e-view--elide-header field bhw/mu4e-view-recipients-shown))))
(defun bhw/mu4e-view-toggle-recipients ()
"Show, or re-hide, the elided recipients in this message view."
(interactive)
(let* ((ovs (seq-filter (lambda (o) (overlay-get o 'bhw/mu4e-elided))
(overlays-in (point-min) (point-max))))
(expand (seq-some (lambda (o) (overlay-get o 'display)) ovs)))
(if (null ovs)
(message "mu4e: no elided recipients in this message")
(dolist (o ovs)
(overlay-put o 'display
(unless expand (overlay-get o 'bhw/mu4e-elided)))))))
;; `mu4e-view-rendered-hook' runs in the view buffer with `inhibit-read-only'.
(add-hook 'mu4e-view-rendered-hook #'bhw/mu4e-view-elide-recipients)
(map! :map mu4e-headers-mode-map
:n "RET" #'mu4e-headers-view-message
:n "s" #'avy-goto-word-or-subword-1
:n "e" #'mu4e-headers-flag-all-read
:n "E" #'mu4e-headers-mark-all
:map mu4e-view-mode-map
:n "RET" #'browse-url-at-point
:n "s" #'avy-goto-word-or-subword-1
:n "L" #'mu4e-view-save-url
:n "A" #'mu4e-view-save-url
:n "C-j" #'bhw/mu4e-view-headers-next-or-back))
;; Forwarded HTML bodies can contain unbalanced `<`/`>` (e.g. inside
;; `<style>` blocks), which makes `forward-sexp' under `mml-syntax-table'
;; signal `scan-error' and abort sending. The CID-image rewriting this
;; function performs is only useful when local inline images are present,
;; so fall back to the original `cont' on failure.
(defun +mml-expand-html-into-multipart-related-safe-a (orig cont)
(condition-case nil
(funcall orig cont)
(scan-error cont)))
(advice-add 'mml-expand-html-into-multipart-related :around
#'+mml-expand-html-into-multipart-related-safe-a)
;; (require 'mu4e-send-delay)
;; (use-package! mu4e-send-delay
;; :config
;; (advice-remove 'org-msg-ctrl-c-ctrl-c #'mu4e-send-delay-org-msg-ctrl-c-ctrl-c)
;; (add-hook! 'mu4e-main-mode-hook 'mu4e-send-delay-setup))
(after! recentf
(add-to-list 'recentf-exclude "~/project-jerome/email-archive/")
(add-to-list 'recentf-exclude "/tmp/"))
Consult-Mu Config
;;---------------------------------------------------------------------------
(use-package! consult-mu
:after (mu4e consult)
:init
(map! :leader
:desc "Search Email" "se" #'consult-mu
:desc "Search .emacs.d" "sE" #'+default/search-emacsd)
:config
(require 'consult-mu-embark)
(require 'consult-mu-compose)
(require 'consult-mu-compose-embark)
(require 'consult-mu-contacts)
(require 'consult-mu-contacts-embark)
(consult-mu-compose-embark-bind-attach-file-key)
(setf consult-mu-maxnum 200
consult-mu-preview-key 'any
consult-mu-mark-previewed-as-read nil
consult-mu-mark-viewed-as-read t
consult-mu-use-wide-reply 'ask
consult-mu-headers-template
(lambda () (concat "%f" (number-to-string (floor (* (frame-width) 0.15))) "%s" (number-to-string (floor (* (frame-width) 0.5))) "%d13" "%g" "%x"))
consult-mu-saved-searches-async '("#flag:unread")
consult-mu-saved-searches-dynamic '("flag:unread")
consult-mu-compose-preview-key "M-o"
consult-mu-embark-attach-file-key "C-a"
consult-mu-contacts-ignore-list '("^.*no.*reply.*")
consult-mu-contacts-ignore-case-fold-search t
consult-mu-compose-use-dired-attachment 'in-dired)
;; Selecting any consult-mu result signalled (wrong-type-argument stringp nil)
;; from `mu4e-view'.
;;
;; `consult-mu--view' renders the message into *consult-mu-view* itself, then
;; marks it read with an async (mu4e--server-move DOCID nil "+S-u-N"). mu
;; answers (:update ... :maybe-view t) -- mu 1.12.9 ignores `mu4e--server-move''s
;; NO-VIEW argument, verified against the running server -- so
;; `mu4e~headers-update-handler' finds a view buffer holding that docid and
;; calls the *real* `mu4e-view' from the process filter, long after
;; consult-mu's `cl-letf' shadowing of `mu4e-view' has unwound.
;;
;; `mu4e-view' kills *consult-mu-view* to rebuild it; killing the current
;; buffer drops us outside mu4e, so the next `mu4e-get-view-buffer' resolves
;; its headers buffer through the *global* `mu4e-headers-buffer-name'
;; ("*mu4e-headers*"), which never exists under consult-mu. That returns nil
;; and `with-current-buffer' chokes on it.
;;
;; Rebuilding is unwanted regardless: it would discard the match overlays
;; `consult-mu--view' adds afterwards and reset point. Only the message plist
;; needs refreshing -- the flag change renames the file, so a stale :path
;; breaks later actions on the message.
(defun bhw/consult-mu-view-buffer-for (msg)
"Return consult-mu's view buffer if it is currently displaying MSG."
(when-let* ((buf (get-buffer consult-mu-view-buffer-name))
(docid (plist-get msg :docid)))
(with-current-buffer buf
(and (derived-mode-p 'mu4e-view-mode)
(eq docid (plist-get mu4e--view-message :docid))
buf))))
;; consult-mu re-renders every message into the *same* `*consult-mu-view*'
;; buffer. `mu4e-view' deliberately refuses to do that -- it kills and
;; recreates its view buffer because buffer-local gnus state "can interfere
;; with message rendering in gnus".
;;
;; The state that bites is `gnus-article-mime-handles'. Every rendered MIME
;; part carries an *undisplayer* closure remembering the buffer positions of
;; the text it inserted. `gnus-display-mime' destroys the previous handles
;; -- which runs those closures -- but only *after* `consult-mu--view-msg'
;; has erased the buffer and inserted the next message. The stale
;; `delete-region' calls then eat a slice of the new message: the gnus header
;; block (From/Subject/To/Cc/Date) vanishes, and mu4e's Flags/Maildir/
;; Attachment lines -- inserted at `article-goto-body', i.e. the first blank
;; line -- land after the first line of the body instead of above it.
;;
;; Destroying the old handles *before* the buffer is refilled makes their
;; positions moot. This is what `gnus-article-setup-buffer' does when it
;; recycles the article buffer, and what killing the buffer buys `mu4e-view'.
(define-advice consult-mu--view-msg
(:before (_msg &optional buffername) bhw/consult-mu-destroy-stale-mime-handles)
"Destroy the previous message's MIME handles before the view buffer is reused."
(when-let* ((buf (get-buffer (or buffername consult-mu-view-buffer-name))))
(with-current-buffer buf
(when (bound-and-true-p gnus-article-mime-handles)
(mm-destroy-parts gnus-article-mime-handles)
(setq gnus-article-mime-handles nil
gnus-article-mime-handle-alist nil)))))
(define-advice mu4e-view (:around (fn msg) bhw/consult-mu-keep-view-buffer)
"Refresh consult-mu's view in place rather than let `mu4e-view' rebuild it."
(if-let* ((buf (bhw/consult-mu-view-buffer-for msg)))
(with-current-buffer buf
(setq-local mu4e--view-message msg))
(funcall fn msg))))
bhw.name email config
Any email addressed to the bhw.name domain is forwarded to my personal gmail address through DNS records set on Porkbun. Any email sent from the bhw.name domain is sent by OCI Email Delivery and through mu4e. A warning to increase OCI Email Delivery service limits to 60MB as 2MB is the default.
1.3.14. Application Config
Elfeed Config
- <–Communication in Emacs
- GitHub - larrasket/rssc: rssc provides a real-time, self-hostable regex-orien…
- elfeed-paywall: Avoid paywalls and retrieve content from a feed entry’s link
- GitHub - leafac/kill-the-newsletter: Convert email newsletters into Atom feeds
Extending elfeed with PDF viewer and subtitles fetcher : emacs
- Youtube Feeds
For youtube channel subscriptions, use:https://www.youtube.com/feeds/videos.xml?channel_id=THE_CHANNEL_ID_HERE
To get the channel ID’s:
- View the page’s source code
- Look for the following text (ctrl-f): externalID
- Get the value for that element
- Replace that value into above URL:
- For some videos, instead of watching it, use elfeed-tube to read the subtitles more effectively.
;;---------------------------------------------------------------------------
(map! :leader
:desc "Web Feed - Elfeed" "ow" #'elfeed)
(after! elfeed
;; `defhydra' below must be expandable when this block runs; hydra is only a
;; transitive dependency, so load it explicitly.
(require 'hydra)
(defun elfeed-mark-all-as-read ()
"Marks entire buffer before tagging marked region as read"
(interactive)
(mark-whole-buffer)
(elfeed-search-untag-all-unread))
(defun bhw/elfeed-search-browse-url (&optional use-generic-p)
"Visit the current entry in your browser using `browse-url'.
If there is a prefix argument, visit the current entry in the
browser defined by `browse-url-generic-program'."
(interactive "P")
(let ((buffer (current-buffer))
(entries (elfeed-search-selected)))
(cl-loop for entry in entries
for link = (elfeed-entry-link entry)
do (elfeed-untag entry 'unread)
when link
do (if use-generic-p
(browse-url-generic link)
(eww link)))
;; `browse-url' could have switched to another buffer if eww or another
;; internal browser is used, but the remainder of the functions needs to
;; run in the elfeed buffer.
(with-current-buffer buffer
(mapc #'elfeed-search-update-entry entries)
(unless (or elfeed-search-remain-on-entry (use-region-p))
(forward-line)))))
(defhydra bhw/hydra-elfeed (:exit t)
("g" (elfeed-search-set-filter "@6-months-ago +unread +gbl") "Global News")
("l" (elfeed-search-set-filter "@6-months-ago +unread +lcl") "Local News")
("s" (elfeed-search-set-filter "@6-months-ago +unread +sci") "Science & Tech")
("c" (elfeed-search-set-filter "@6-months-ago +unread +rel") "Catholic")
("f" (elfeed-search-set-filter "@6-months-ago +unread +frm") "Forums")
("o" (elfeed-search-set-filter "@6-months-ago +unread +pod") "Podcasts")
("b" (elfeed-search-set-filter "@6-months-ago +unread +blog") "Misc Blogs")
("y" (elfeed-search-set-filter "@6-months-ago +unread +vid") "Youtube")
("a" (elfeed-search-set-filter "@6-months-ago +unread") "All")
("q" nil "quit" :color blue))
(add-hook 'elfeed-search-mode-hook #'elfeed-update)
(map! :map elfeed-search-mode-map
:n "s" #'avy-goto-word-or-subword-1
:n "r" #'elfeed-mark-all-as-read
:n "S" #'elfeed-search-live-filter
:n "f" #'bhw/hydra-elfeed/body
:n "b" #'bhw/elfeed-search-browse-url
:n "B" #'elfeed-search-browse-url
:n "R" #'elfeed-search-update--force
:n ";" #'consult-line
:map elfeed-show-mode-map
:n "s" #'avy-goto-word-or-subword-1
:n "b" #'bhw/elfeed-search-browse-url
:n "B" #'elfeed-search-browse-url
:n ";" #'consult-line))
Ement Config
;;---------------------------------------------------------------------------
(map! :leader
:desc "Ement" "oc" #'ement-notifications
:desc "Ement (Login)" "oC" #'ement-connect
:desc "ement-room-view" "sc" #'bhw/ement-room-view
:desc "Search Matrix" "sC" #'bhw/ement-search-all-rooms)
(defvar bhw/ement-room-picker-show-all nil
"When non-nil, the room picker offers empty per-call portals too.
Declared here rather than in ement-config.el so that `bhw/ement-room-view'
can bind it before Ement -- and hence that file -- has been loaded.")
(defun bhw/ement-room-view (&optional show-all)
"Load Ement, then pick a room to view.
`ement-room-view' is not autoloaded, so binding it directly would leave
the key void until something else happens to load Ement. With a prefix
argument SHOW-ALL, also offer the portals `bhw/ement-room-live-p' hides."
(interactive "P")
(require 'ement)
(let ((bhw/ement-room-picker-show-all show-all))
(call-interactively #'ement-room-view)))
(defun bhw/ement-search-all-rooms ()
"Load Ement, then search every joined room for a phrase.
`bhw/ement-search' lives in ement-config.el, which `after! ement' loads
only once Ement itself is loaded, so it is void until then."
(interactive)
(require 'ement)
(call-interactively #'bhw/ement-search))
(after! ement
;; The body of this config lives in its own file: several of its forms
;; cannot be macroexpanded until Ement is loaded
;; (`ement-room-list-define-column' is a macro; pcase patterns use
;; `cl-struct'/`map'), and Emacs eagerly macroexpands inline `after!'
;; bodies while loading config.el -- a fresh daemon would abort the load
;; with "ement-room is not a struct name".
(load! "private-packages/ement-config.el"))
(defun bhw/define-key-skip-non-prefix-a (fn keymap key def &optional remove)
"Advice for `define-key': skip KEY instead of signalling a prefix conflict.
`define-key' errors with \"Key sequence K starts with non-prefix key P\"
when a prefix of KEY is already bound to a command; `lookup-key' reports
exactly that case by returning a number. Skipping leaves the existing
binding -- ours -- in place."
(unless (and (keymapp keymap) (arrayp key) (numberp (lookup-key keymap key)))
(funcall fn keymap key def remove)))
(after! evil-collection
;; `evil-collection-init' runs on `doom-after-modules-config-hook', which
;; fires before $DOOMDIR/config.el (a post-config module, see
;; `doom-profiles.el'), so its `with-eval-after-load' hook for Ement is
;; already registered by the time this form runs -- dropping `ement' from
;; `evil-collection-mode-list' here was a no-op. Ement *is* evilified; our
;; bindings in ement-config.el simply land on top, because that file is
;; loaded by `after! ement', i.e. after evil-collection's setup.
;;
;; Unless the order gets inverted, which it does whenever the Ement block is
;; hand-evaluated from dotemacs.org: our `:n "r"'/`:n "s"' are then already
;; bound when `evil-collection-ement-setup' tries to make `r'/`s' prefixes
;; ("r/", "rd", "se", ...), and `define-key' signals. evil-collection
;; swallows that with `condition-case-unless-debug' (evil-collection.el:1019),
;; so with `debug-on-error' on it escapes, aborts ement.el's after-load
;; chain, and `after! ement' never fires: no ement-config.el, and
;; `ement-connect' dies with it.
(defadvice! bhw/evil-collection-ement-skip-conflicts-a (fn &rest args)
"Run `evil-collection-ement-setup' without tripping over our bindings."
:around #'evil-collection-ement-setup
(advice-add 'define-key :around #'bhw/define-key-skip-non-prefix-a)
(unwind-protect (apply fn args)
(advice-remove 'define-key #'bhw/define-key-skip-non-prefix-a)))
(map! :map ement-notifications-mode-map
:n "<return>" #'ement-notifications-jump
:n "RET" #'ement-notifications-jump ;; Bind both to be safe
:n "r" #'ement-notify-reply))
The body above merely loads this file. It is tangled separately rather than
written inline because Emacs eagerly macroexpands inline after! bodies while
loading config.el, and these forms need Ement’s structs and macros already
defined; as its own file it is only macroexpanded when loaded, at which point
Ement is present.
The cookie sits in its own :comments no block; see Emacs Initialization.
;;; ement-config.el --- Ement configuration, loaded via (after! ement ...) -*- lexical-binding: t; -*-
(require 'map)
(require 'org)
(require 'url-util)
(defun bhw/ement-schedule-message (time-str message)
(interactive
(let ((msg (if (derived-mode-p 'ement-room-compose-mode)
(buffer-substring-no-properties (point-min) (point-max))
(read-string "Message to schedule: "))))
(list (org-read-date nil nil nil "Schedule for: ")
msg)))
(let ((room ement-room)
(session ement-session)
(time (org-time-string-to-time time-str)))
(unless (and room session)
(user-error "Not in an Ement room context"))
(when (time-less-p time (current-time))
(user-error "Scheduled time must be in the future"))
(run-at-time time nil
(lambda (r s m)
(ement-room-send-message r s :body m))
room session message)
(message "Message scheduled for %s" time-str)
(when (derived-mode-p 'ement-room-compose-mode)
(erase-buffer)
(kill-buffer))))
(defun bhw/ement-mark-all-read ()
(interactive)
(let ((count 0))
(dolist (session-pair ement-sessions)
(let ((session (cdr session-pair)))
(dolist (room (ement-session-rooms session))
(when (ement--room-unread-p room session)
(let* ((timeline (ement-room-timeline room))
(latest-event (car (last timeline))))
(when (and latest-event (ement-event-id latest-event))
(cl-incf count)
(ement-api session
(format "rooms/%s/receipt/m.read/%s"
(url-hexify-string (ement-room-id room))
(url-hexify-string (ement-event-id latest-event)))
:method 'post
:then (apply-partially
(lambda (name _)
(message "Read receipt confirmed for %s" name))
(ement-room-display-name room))
:else (lambda (plz-error)
(message "Error marking read: %s" plz-error)))))))))
(if (> count 0)
(message "Sending read receipts for %d rooms..." count)
(message "No unread rooms found."))
(when (derived-mode-p 'ement-room-list-mode)
(ement-room-list))))
(defvar bhw/ement-recycling nil
"Non-nil while `bhw/ement-recycle' is tearing the session down.
Suppresses `bhw/ement-notifications-run-after-kill', which would otherwise
see `ement-kill-buffers' kill \"*Ement Notifications*\" and disconnect us
again immediately after we reconnect.")
;; When closing the notifications buffer, mark everything read, then tear the
;; sessions down to keep Ement's heap from growing while idle. The dominant
;; consumer is `ement-session-events' -- a hash of every event ever seen that
;; is never pruned. `ement-disconnect' only stops the long-poll sync (capping
;; further growth) and leaves room buffers alive, which keep buffer-local
;; references to the session struct and thus pin that hash; so we also
;; `ement-kill-buffers' to drop the last references and let it be GC'd.
;; We run it on a 0-delay timer so it fires *after* the buffer is gone.
(defun bhw/ement-notifications-run-after-kill ()
;; `eq', not `derived-mode-p': `bhw/ement-search-mode' inherits this mode
;; hook, and killing a buffer of search results must not disconnect us.
(when (and (eq major-mode 'ement-notifications-mode)
(not bhw/ement-recycling))
(run-at-time 0 nil
(lambda ()
;; Dispatches read receipts synchronously across
;; `ement-sessions', so it must run before we clear it.
(bhw/ement-mark-all-read)
(when ement-sessions
;; The receipt POSTs are now in-flight plz processes,
;; independent of the sync process, so disconnecting
;; won't abort them.
(ement-disconnect (mapcar #'cdr ement-sessions))
(ement-kill-buffers))))))
(defun bhw/ement-notifications-add-kill-hook-h ()
"Run `bhw/ement-notifications-run-after-kill' when this buffer is killed."
(add-hook 'kill-buffer-hook #'bhw/ement-notifications-run-after-kill nil t))
(add-hook 'ement-notifications-mode-hook #'bhw/ement-notifications-add-kill-hook-h)
(map! :after ement-room
:map ement-room-mode-map
:n "RET" #'ement-room-send-message
:n "<return>" #'ement-room-send-message
:n "M-RET" #'ement-room-compose-message
:n "<M-return>" #'ement-room-compose-message
:n "s" #'avy-goto-word-or-subword-1
:n "r" #'ement-room-write-reply
:n "D" #'ement-room-download-file
:n ";" #'ement-room-occur
:n "S" #'bhw/ement-search
:n "gg" #'ement-room-scroll-down-command
:n "G" #'ement-room-scroll-up-mark-read
:n "m" #'ement-room-mark-read
:n "e" #'ement-room-edit-message-prepare
:n "a e" #'ement-room-send-emote
:n "a f" #'ement-room-send-file
:n "a i" #'ement-room-send-image
:n "a r" #'ement-room-send-reaction)
(add-hook 'ement-room-compose-hook 'ement-room-compose-org)
;; NOTE: `setopt', not `setf'. Several of these have `:set' functions that
;; `setf' silently bypasses (e.g. `ement-room-images' at ement-room.el:5297,
;; which checks for ImageMagick/Cairo scaling support); README.org:276 says
;; to use `setopt'/customize for exactly this reason.
(setopt
ement-save-sessions t
ement-room-mark-rooms-read 'send
ement-room-send-typing nil
ement-auto-sync t
;; Images: thumbnail by default, click to enlarge (`ement-room-image-scale',
;; bound in `ement-room-image-keymap'). The previous `-min' of 1500 won the
;; `max' at ement-room.el:5480, so *every* inline image rendered ~1500px
;; tall; worse, its raw bytes are stashed on the event struct
;; (ement-room.el:5534), which lives in `ement-session-events' forever, so
;; the bytes outlived the room buffer. Also note that
;; `ement-room-image-initial-height' is defined as
;; `ement-room-image-thumbnail-height' *at definition time*
;; (ement-room.el:5317), so it must be set explicitly -- it does not track
;; changes to the thumbnail height.
ement-room-images t
ement-room-image-thumbnail-height 0.15
ement-room-image-thumbnail-height-min 200
ement-room-image-initial-height 0.15
;; `ement-room--add-member-face' searches the *whole* room buffer from
;; `point-min', calling `ewoc-locate' at every hit (ement-room.el:1489),
;; for any message body starting with "Word:" or "Word," -- effectively
;; O(n^2) per such message in a long-lived buffer. Cost of disabling:
;; addressee names lose their prism colour.
ement-room-prism-addressee nil
;; The default predicates include `ement-notify--room-buffer-live-p', so
;; every message in a room we have *open* is formatted a second time (full
;; `shr' HTML render) into the never-truncated "*Ement Notifications*" EWOC
;; (ement-notify.el:69, ement-notifications.el:199). Our
;; `ement-notify--room-unread-p' override below still logs rooms we are not
;; reading, which is the behaviour we actually want from that buffer.
ement-notify-log-predicates
'(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p
ement-notify--room-unread-p)
ement-notify-notification-predicates
'(ement-notify--event-mentions-session-user-p
ement-notify--event-mentions-room-p
ement-notify--room-unread-p)
;; `ement-room-list--after-initial-sync' calls (ement-room-list) with the
;; default display action -- `display-buffer-same-window', and it *selects*
;; the window (ement-room-list.el:606, 752). Tolerable once at login;
;; unacceptable on every automatic reconnect from `bhw/ement-recycle'.
ement-after-initial-sync-hook
(remq 'ement-room-list--after-initial-sync ement-after-initial-sync-hook))
;; --- Ement memory instrumentation ----------------------------------------
;; Ement prunes nothing, and Emacs 30's mark-sweep GC pauses in proportion to
;; the live object count, so a long session's GC pauses grow without bound.
;; Use this to tell which of Ement's retained structures is actually growing
;; before/after the mitigations below.
(defun bhw/ement-memory-report ()
"Report Ement's retained-object counts.
`ephemeral' and `new-events' should stay near zero; if they run to the
thousands, `bhw/ement-drop-orphan-room-events' is not running."
(interactive)
(let ((events 0) (timeline 0) (max-timeline 0) (ephemeral 0)
(new-events 0) (image-events 0) (image-bytes 0) (rooms 0) (buffers 0))
(dolist (pair ement-sessions)
(let ((session (cdr pair)))
(cl-incf events (hash-table-count (ement-session-events session)))
(dolist (room (ement-session-rooms session))
(cl-incf rooms)
(let ((n (length (ement-room-timeline room))))
(cl-incf timeline n)
(setf max-timeline (max max-timeline n)))
(cl-incf ephemeral (length (ement-room-ephemeral room)))
(cl-incf new-events
(length (alist-get 'new-events (ement-room-local room))))
(dolist (ev (ement-room-timeline room))
(when-let ((data (alist-get 'image (ement-event-local ev))))
(cl-incf image-events)
(cl-incf image-bytes (length data)))))))
(dolist (buf (buffer-list))
(when (eq 'ement-room-mode (buffer-local-value 'major-mode buf))
(cl-incf buffers)))
(message (concat "Ement: %d rooms, %d room buffers | events-hash %d | "
"timeline %d (max %d) | ephemeral %d | new-events %d | "
"images %d (%.1f MB) | GCs %d, %.1fs total")
rooms buffers events timeline max-timeline ephemeral new-events
image-events (/ image-bytes 1048576.0) gcs-done gc-elapsed)))
;; --- Ement heap containment ----------------------------------------------
;; (1) A genuine leak. Every m.typing/m.receipt event is pushed onto
;; `ement-room-ephemeral' for *every* room (ement.el:778), but
;; `ement--update-room-buffers' clears the slot only inside its loop over
;; rooms that have a live buffer (ement.el:662-670). For every other joined
;; room the list grows for the lifetime of the session, in proportion to all
;; Matrix traffic. `new-events' has the same shape.
;;
;; Dropping them is safe: `ement--process-event' has already run on each
;; ephemeral event at push time (ement.el:779), so receipt/typing state is
;; recorded in the room's own tables; these lists exist only to feed buffer
;; rendering, and `ement-room--buffer' discards `new-events' outright when it
;; creates a buffer anyway (ement-room.el:2832).
(defun bhw/ement-drop-orphan-room-events (session)
"Clear ephemeral and pending events for SESSION rooms with no live buffer."
(dolist (room (ement-session-rooms session))
(unless (buffer-live-p (alist-get 'buffer (ement-room-local room)))
(setf (ement-room-ephemeral room) nil
(alist-get 'new-events (ement-room-local room)) nil))))
;; Depth 90 so this runs *after* `ement--update-room-buffers' (depth 0).
(add-hook 'ement-sync-callback-hook #'bhw/ement-drop-orphan-room-events 90)
;; (2) `ement-room-list-auto-update' runs a plain `revert-buffer' on every
;; sync callback and every room switch, which re-runs the whole of
;; `ement-room-list' (ement-room-list.el:604-753): fresh [room session]
;; vectors, a new taxy tree, ~15 sort passes whose predicates are each
;; O(timeline), a new format hash table, `erase-buffer', and a new
;; magit-section per room. It does this even when the buffer is not on
;; screen. Gate it on visibility and debounce it.
(defvar bhw/ement-room-list-min-interval 5
"Minimum seconds between automatic room-list rebuilds.")
(defvar bhw/ement-room-list--last-update 0)
(define-advice ement-room-list-auto-update
(:around (fn session) bhw/visible-and-debounced)
"Rebuild the room list only when displayed, and not more often than
`bhw/ement-room-list-min-interval' seconds."
(when (and (get-buffer-window "*Ement Room List*" t)
(> (float-time (time-since bhw/ement-room-list--last-update))
bhw/ement-room-list-min-interval))
(setq bhw/ement-room-list--last-update (current-time))
(funcall fn session)))
;; (3) `ement-room--process-events' ends with an unbounded
;; `ement-room--insert-ts-headers' (ement-room.el:3036) -- a full pairwise
;; walk of the buffer's EWOC -- and `ement--update-room-buffers' calls it up
;; to three times per open buffer per sync (ement.el:662-674), including for
;; batches that are nothing but m.typing/m.receipt. Worse, the `ement-debug'
;; form inside that walk (ement-room.el:3637) still *evaluates* its arguments:
;; `ement-debug' expands to (ignore ARGS...) when disabled
;; (ement-macros.el:102), and `ignore' is an ordinary function, so two
;; `format-time-string' + `format' + `truncate-string-to-width' calls run per
;; node pair and are thrown away. Skip the walk when the batch cannot need a
;; timestamp header at all.
(defvar bhw/ement--skip-ts-headers nil)
(defvar bhw/ement-ts-header-irrelevant-types '("m.typing" "m.receipt")
"Event types that can never add a timestamped node to a room's EWOC.
\"m.typing\" only calls `ewoc-set-hf' (ement-room.el:3221) and \"m.receipt\"
has no entry in `ement-room-event-fns' at all, so a batch made purely of
these cannot change where a timestamp header belongs. Deliberately
conservative: account-data batches (\"m.read\", \"m.fully_read\") move marker
nodes, which the walk does consult (ement-room.el:3646-3649), so they are
not listed here.")
(define-advice ement-room--insert-ts-headers
(:around (fn &optional start end) bhw/skippable)
"Skip the unbounded walk when the caller had nothing to place."
(unless (and bhw/ement--skip-ts-headers (null start) (null end))
(funcall fn start end)))
(define-advice ement-room--process-events (:around (fn events) bhw/mark-ephemeral)
"Bind `bhw/ement--skip-ts-headers' when EVENTS cannot need a ts header.
EVENTS may be a list or a vector."
(let ((bhw/ement--skip-ts-headers
(and (> (length events) 0)
(cl-loop for e being the elements of events
always (and (ement-event-p e)
(member (ement-event-type e)
bhw/ement-ts-header-irrelevant-types))))))
(funcall fn events)))
;; (4) The per-sync EWOC walks above are per *open buffer*, and each open
;; buffer also retains one EWOC node, marker set and text-property interval
;; run per event. Closing idle rooms is the cheapest fix for both.
(defvar bhw/ement-room-buffer-idle-kill-seconds (* 15 60)
"Kill a non-displayed `ement-room-mode' buffer after this many seconds.")
(defun bhw/ement-kill-idle-room-buffers ()
"Kill Ement room buffers that have not been displayed recently."
(unless (cl-find-if (lambda (b)
(eq 'ement-room-compose-mode
(buffer-local-value 'major-mode b)))
(buffer-list))
(dolist (buf (buffer-list))
(when (and (eq 'ement-room-mode (buffer-local-value 'major-mode buf))
(not (get-buffer-window buf t))
(let ((dt (buffer-local-value 'buffer-display-time buf)))
(or (null dt)
(> (float-time (time-since dt))
bhw/ement-room-buffer-idle-kill-seconds))))
(kill-buffer buf)))))
;; (5) `ement-room--m.image-callback' stores the full downloaded bytes on the
;; event struct (ement-room.el:5534). The struct is retained forever in
;; `ement-session-events', so the bytes survive killing the room buffer.
;; Reopening the room re-downloads them.
(defun bhw/ement-drop-orphan-image-data ()
"Drop raw image bytes from events in rooms without a live buffer."
(dolist (pair ement-sessions)
(dolist (room (ement-session-rooms (cdr pair)))
(unless (buffer-live-p (alist-get 'buffer (ement-room-local room)))
(dolist (ev (ement-room-timeline room))
(when (alist-get 'image (ement-event-local ev))
(setf (ement-event-local ev)
(cl-remove 'image (ement-event-local ev) :key #'car-safe))))))))
;; (6) The notifications EWOC is never truncated and has no size option
;; (ement-notifications.el:199).
(defvar bhw/ement-notifications-max-entries 500
"Maximum entries to retain in \"*Ement Notifications*\".")
(defun bhw/ement-notifications-trim ()
"Trim \"*Ement Notifications*\" to `bhw/ement-notifications-max-entries'."
(when-let ((buf (get-buffer "*Ement Notifications*")))
(with-current-buffer buf
(when (bound-and-true-p ement-ewoc)
(let ((inhibit-read-only t)
(nodes nil)
(node (ewoc-nth ement-ewoc 0)))
(while node
(push node nodes) ; NODES ends up newest-first
(setq node (ewoc-next ement-ewoc node)))
(let ((excess (- (length nodes) bhw/ement-notifications-max-entries)))
(when (> excess 0)
;; The tail of a newest-first list is the oldest entries.
(dolist (n (last nodes excess))
(ewoc-delete ement-ewoc n)))))))))
;; (7) Opt-in, and the riskiest of these. Bounds `ement-room-timeline', which
;; is otherwise push-only (ement.el:751), and keeps reopening an old room fast
;; -- `ement-room--buffer' renders the *entire* retained timeline
;; (ement-room.el:2844). Enable only if `bhw/ement-memory-report' still shows
;; a large timeline total after everything above. Note this does NOT shrink
;; `ement-session-events'; only `bhw/ement-recycle' does that.
(defvar bhw/ement-room-timeline-max-events nil
"If a number, trim buffer-less rooms' timelines to this many events.")
(defun bhw/ement-trim-room-timelines ()
"Trim retained timelines for rooms without a live buffer.
State events are always kept, because `ement--room-display-name' and friends
scan the timeline for them (ement-lib.el:1471). Scrollback is unaffected: it
uses the room's `prev-batch' token against the server, not this list.
Trade-off: edits, reactions and redactions naming a trimmed event resolve to
nothing and are silently skipped (ement-room.el:3088)."
(when bhw/ement-room-timeline-max-events
(dolist (pair ement-sessions)
(dolist (room (ement-session-rooms (cdr pair)))
(unless (buffer-live-p (alist-get 'buffer (ement-room-local room)))
(let ((tl (ement-room-timeline room))) ; newest-first
(when (> (length tl) bhw/ement-room-timeline-max-events)
(setf (ement-room-timeline room)
(append (take bhw/ement-room-timeline-max-events tl)
(cl-remove-if-not
#'ement-event-state-key
(nthcdr bhw/ement-room-timeline-max-events tl)))))))))))
;; --- Ement heap reset ------------------------------------------------------
;; Nothing in Ement prunes `ement-session-events'. `ement-disconnect' drops
;; the sessions from `ement-sessions' and clears `ement-users'
;; (ement.el:369-374); `ement-kill-buffers' drops the last buffer-local
;; references to the session struct; and a session re-read from disk is built
;; with a *fresh, empty* events hash (ement.el:845). Reconnecting with
;; :session on a token-bearing session just registers it and syncs -- no
;; prompt. That round trip is the only complete reclaim available.
(defvar bhw/ement-recycle-idle-seconds (* 30 60)
"Recycle the Ement session after this many seconds of Emacs idle time.")
(defun bhw/ement-recycle ()
"Reset Ement's heap by disconnecting and reconnecting from the saved session."
(interactive)
(when ement-sessions
(let ((bhw/ement-recycling t))
;; `ement-disconnect' writes the current token to disk first.
(ement-disconnect (mapcar #'cdr ement-sessions))
(ement-kill-buffers))
;; Re-read from disk: reusing the old structs would retain the very hash
;; tables we are trying to drop.
(dolist (pair (ement--read-sessions))
(ement-connect :session (cdr pair)))
(message "Ement: session recycled.")))
(defun bhw/ement-recycle-maybe ()
"Recycle Ement when nothing of ours is on screen or in flight.
NOTE: deliberately conservative -- *any* visible Ement buffer vetoes the
recycle, because `ement-kill-buffers' would take \"*Ement Notifications*\"
with it and that buffer's contents are only ever accumulated live, never
re-fetched. If you habitually leave the notifications buffer in a window,
this will effectively never fire; drop the `get-buffer-window' clause (or
call `bhw/ement-recycle' by hand) if you would rather trade the inbox
history for the reclaim."
;; Reclaim stale sync records first, outside the `ement-sessions' guard below.
;; `ement-disconnect' empties `ement-sessions' but leaves `ement-syncs' holding
;; its (SESSION . PROCESS) pairs, and each dead entry pins an entire session
;; struct -- rooms, timelines, events -- against GC. Measured at 58 MiB
;; retained across 3 entries with zero live sessions. Nothing reads
;; `ement-syncs' while disconnected (`bhw/ement-sync-watchdog-fire' gates on
;; `ement-sessions' membership), and `ement-connect' repopulates it.
(unless ement-sessions
(setq ement-syncs nil))
(when (and ement-sessions
(not bhw/ement-recycling)
(not (active-minibuffer-window))
(not (cl-find-if
(lambda (b)
(let ((mode (buffer-local-value 'major-mode b)))
;; Matches `bhw/ement-search-mode' as well as Ement's own.
(and (string-match-p "\\`\\(bhw/\\)?ement-" (symbol-name mode))
(or (get-buffer-window b t)
(eq mode 'ement-room-compose-mode)))))
(buffer-list))))
(bhw/ement-recycle)))
;; Register the sweepers idempotently. `doom/reload' re-evaluates config.el,
;; which re-runs this `:config' block; without the cancel step that would
;; stack a second copy of every timer. (`add-hook' and `define-advice' above
;; are already idempotent.)
(dolist (spec (list (list 300 #'bhw/ement-kill-idle-room-buffers)
(list 600 #'bhw/ement-drop-orphan-image-data)
(list 600 #'bhw/ement-notifications-trim)
(list 900 #'bhw/ement-trim-room-timelines)
(list bhw/ement-recycle-idle-seconds
#'bhw/ement-recycle-maybe)))
(cancel-function-timers (cadr spec))
(run-with-idle-timer (car spec) t (cadr spec)))
;; Watchdog for silently-stalled syncs. If `ement--sync-callback' errors during
;; event processing, `ement-syncs' has already been cleared but the next
;; long-poll is never started (the call to `ement--auto-sync' is gated behind
;; `ement-sync-callback-hook' running, which it never does on error). Symptom:
;; sent messages reach the server but don't appear in the buffer until you
;; reconnect. The Matrix long-poll has a 30s timeout, so under healthy
;; conditions a callback fires at least that often; if we go noticeably longer,
;; force a fresh sync.
(defvar bhw/ement-sync-watchdog-timer nil)
(defvar bhw/ement-sync-watchdog-seconds 90)
(defun bhw/ement-sync-watchdog-fire (session)
(setq bhw/ement-sync-watchdog-timer nil)
(when (and ement-auto-sync
(memq session (mapcar #'cdr ement-sessions)))
(message "Ement: sync watchdog firing; force-resyncing %s"
(ement-user-id (ement-session-user session)))
;; The forced delete-process below SIGKILLs the stalled sync; plz then
;; misreads signal 9 as curl exit 9 ("FTP access denied") and the dead
;; process's :else both signals that error and nulls `ement-syncs' for the
;; new sync. Neutralise its callbacks first so its deferred timer is inert.
(when-let ((proc (map-elt ement-syncs session)))
(when (process-live-p proc)
(process-put proc :plz-then #'ignore)
(process-put proc :plz-else #'ignore)))
(condition-case err
(ement--sync session :force t)
(error (message "Ement: watchdog force-sync failed: %S" err)))))
(defun bhw/ement-sync-watchdog-reset (session)
(when (timerp bhw/ement-sync-watchdog-timer)
(cancel-timer bhw/ement-sync-watchdog-timer))
(setq bhw/ement-sync-watchdog-timer
(run-at-time bhw/ement-sync-watchdog-seconds nil
#'bhw/ement-sync-watchdog-fire session)))
(add-hook 'ement-sync-callback-hook #'bhw/ement-sync-watchdog-reset)
;; Upstream ement-room-list crashes with (wrong-type-argument number-or-marker-p nil)
;; when unread_notifications is present but notification_count or highlight_count is nil.
;; Redefine the "Unread" column to coerce nil -> 0.
(ement-room-list-define-column
#("Unread" 0 6 (help-echo "Unread events (Notifications:Highlights)"))
(:align 'right)
(pcase-let* ((`[,(cl-struct ement-room unread-notifications) ,_session] item)
((map notification_count highlight_count) unread-notifications)
(n (or notification_count 0))
(h (or highlight_count 0)))
(if (or (not unread-notifications)
(and (zerop n) (zerop h)))
""
(concat (ement-propertize (number-to-string n)
'face (if (zerop h) 'default 'ement-room-mention))
":"
(ement-propertize (number-to-string h)
'face 'highlight)))))
;; Related nil-handling bug: `ement-notify--room-unread-p' fails open when a
;; room's `unread-notifications' slot is nil (absent from /sync). It tests
;; (equal 0 notification_count), which is nil for a nil count, so the room is
;; reported "unread" and every message is logged to *Ement Notifications*.
;; Muted rooms keep their count at 0, the server stops sending the field, and
;; the slot stays nil -- exactly when this misfires, so server-side mutes
;; (WhatsApp bridge / Element) never silence the buffer. Treat absent counts
;; as read; a genuine positive count still logs.
(define-advice ement-notify--room-unread-p
(:override (_event room _session) bhw/nil-counts-are-read)
(pcase-let* (((cl-struct ement-room unread-notifications) room)
((map notification_count highlight_count) unread-notifications))
(or (and (numberp notification_count) (> notification_count 0))
(and (numberp highlight_count) (> highlight_count 0)))))
;; --- Search all rooms ------------------------------------------------------
;; Ement has no full-text search over message bodies. The two commands that
;; sound like it are not it: `ement-room-occur' (M-s o upstream, `;' in the
;; bindings above) filters only the events already cached for the *current*
;; room, and `ement-directory-search' searches a server's public room
;; *directory*, not messages. Matrix itself has POST
;; /_matrix/client/r0/search (spec § 11.11), which searches server-side
;; across every joined room over its whole history, so drive that.
;;
;; Results render through `ement-notifications-log-to-buffer', which already
;; solves the many-rooms-in-one-EWOC problem: it prefixes each event with its
;; room, tints it with that room's prism colour, and attaches the
;; session/room/event text properties that `ement-notifications-jump' (RET)
;; needs in order to jump to the event in context.
;;
;; Server quirk (tuwunel 1.7, and conduwuit-derived servers generally):
;; `filter.limit' is ignored -- a query reporting count=1854 answered with
;; 201 events in one response -- so hold the surplus back and render it in
;; batches of our own, falling through to the server's `next_batch' token
;; (there, an opaque offset) only once the local surplus runs out.
;; `filter.rooms' *is* honoured, so the prefix-arg single-room search is a
;; real server-side query rather than a client-side filter.
;;
;; Results are listed newest-first -- what you want of a search, and the
;; opposite of a room buffer. `ement-room--insert-event' bakes its timestamp
;; comparison into `cl-labels' and so cannot be parameterised, but the whole
;; EWOC order follows from the `<-fn' it hands to
;; `ement-room--ewoc-node-before', which is advised below.
(require 'ement-notifications)
(defvar bhw/ement-search-batch-size 100
"Number of matching events rendered per batch in a search buffer.")
(defvar bhw/ement-search-history nil
"Minibuffer history for `bhw/ement-search'.")
(defvar-local bhw/ement-search--etc nil
"State of the current `bhw/ement-search-mode' buffer.
An alist with keys `session', `term', `rooms', `localp', `count' (the
server's total match count), `next-batch', `loading', `pending' (matches
received but not yet rendered, as (ROOM-ID . EVENT) conses), `shown', and
`seen' (a hash table of rendered event IDs, since server pages overlap).")
(define-derived-mode bhw/ement-search-mode ement-notifications-mode "Ement-Search"
"Major mode for `bhw/ement-search' result buffers."
;; The inherited record function would bookmark this buffer's *name* but
;; reopen the notifications inbox behind it.
(setq-local bookmark-make-record-function #'bookmark-make-record-default))
(map! :map bhw/ement-search-mode-map
;; Evil resolves a state's bindings through the auxiliary keymap of each
;; active map, and a child map's own auxiliary keymap shadows its
;; parent's wholesale -- so `ement-notifications-mode-map's normal-state
;; bindings must be restated here, not merely extended.
:n "<return>" #'ement-notifications-jump
:n "RET" #'ement-notifications-jump
:n "r" #'ement-notify-reply
:n "+" #'bhw/ement-search-more
:n "gr" #'revert-buffer
:n "q" #'quit-window)
(defun bhw/ement-search--request-data (term rooms next-batch)
"Return the JSON body for a Matrix /search of TERM.
ROOMS, when non-nil, is a list of room IDs to restrict the search to.
NEXT-BATCH, when non-nil, is a paging token from a previous response."
(json-encode
(ement-alist
"search_categories"
(ement-alist
"room_events"
(append (ement-alist "search_term" term
"order_by" "recent"
"event_context" (ement-alist "before_limit" 0
"after_limit" 0
"include_profile" t))
(when rooms
(ement-alist "filter" (ement-alist "rooms" rooms)))
(when next-batch
(ement-alist "next_batch" next-batch)))))))
(defun bhw/ement-search--update-header ()
"Set the header line of the current search buffer from its state."
(pcase-let (((map term count shown pending next-batch localp loading)
bhw/ement-search--etc))
(setf header-line-format
(concat (format "Ement search%s: %s" (if localp " (cached)" "") term)
(format " -- %d shown" shown)
(if count (format " of %d" count) "")
(cond (loading " (fetching...)")
((or pending next-batch)
(substitute-command-keys
" (\\[bhw/ement-search-more] for more)"))
(t ""))))))
(defun bhw/ement-search--buffer (name session term rooms localp)
"Return a search buffer named NAME, freshly initialised for this search."
(with-current-buffer (get-buffer-create name)
;; `ement-room-mode', which we inherit from, erases the buffer and builds
;; a fresh EWOC, so re-invoking the mode is how a re-run resets.
(bhw/ement-search-mode)
(setq-local ement-session session)
(setq-local revert-buffer-function
(lambda (&rest _)
(if localp
(bhw/ement-search-local term session)
(bhw/ement-search term :session session :rooms rooms))))
(setf bhw/ement-search--etc
(ement-alist 'session session 'term term 'rooms rooms 'localp localp
'count nil 'next-batch nil 'loading nil 'pending nil
'shown 0 'seen (make-hash-table :test 'equal)))
(bhw/ement-search--update-header)
(current-buffer)))
(defvar bhw/ement-search--newest-first nil
"When non-nil, insert events newest-first.
Bound around event insertion by `bhw/ement-search--render'.")
(define-advice ement-room--ewoc-node-before
(:around (fn ewoc data <-fn &rest args) bhw/newest-first)
"Reverse the EWOC order while rendering a `bhw/ement-search' buffer.
This function decides where every event lands -- it is the only caller of
`ewoc-enter-after'\='s placement in `ement-room--insert-event' -- and it takes
the comparison as an argument, so inverting <-FN reverses the buffer without
touching anything else. Inert unless `bhw/ement-search--newest-first'."
(apply fn ewoc data
(if bhw/ement-search--newest-first
(lambda (a b) (funcall <-fn b a))
<-fn)
args))
(defun bhw/ement-search--insert-date-headers ()
"Head each run of same-day events in the current buffer with its date.
`ement-room--insert-ts-headers' is no use once the EWOC runs newest-first:
it only inserts where the *next* node is later than the current one, so it
walks the whole buffer and emits nothing. Existing headers are dropped
first, because a later batch can land events in between them."
(let ((ewoc ement-ewoc)
(inhibit-read-only t)
(ement-room-timestamp-header-format
ement-room-timestamp-header-with-date-format)
(previous-day nil)
(node nil))
(ewoc-filter ewoc (lambda (data) (not (eq 'ts (car-safe data)))))
(setf node (ewoc-nth ewoc 0))
(while node
(let ((data (ewoc-data node)))
(when (ement-event-p data)
(let* ((seconds (/ (ement-event-origin-server-ts data) 1000))
(day (time-to-days seconds)))
(unless (eql day previous-day)
(setf previous-day day)
(with-silent-modifications
(ewoc-enter-before ewoc node (list 'ts seconds)))))))
(setf node (ewoc-next ewoc node)))))
(defun bhw/ement-search--render ()
"Render the next batch of pending matches into the current search buffer."
(pcase-let* (((map session seen) bhw/ement-search--etc)
(pending (alist-get 'pending bhw/ement-search--etc))
(batch (seq-take pending bhw/ement-search-batch-size))
(rooms (ement-session-rooms session))
(firstp (zerop (alist-get 'shown bhw/ement-search--etc)))
(orphans 0))
(setf (alist-get 'pending bhw/ement-search--etc)
(nthcdr (length batch) pending))
(let ((bhw/ement-search--newest-first t))
(pcase-dolist (`(,room-id . ,event) batch)
(cond
((gethash (ement-event-id event) seen)) ; server pages can overlap
((not (cl-find room-id rooms :key #'ement-room-id :test #'equal))
;; A room this session has left or never joined: the notifications
;; renderer needs the room struct, so there is nothing to show.
(cl-incf orphans))
(t
(puthash (ement-event-id event) t seen)
(cl-incf (alist-get 'shown bhw/ement-search--etc))
(ement-notifications-log-to-buffer
session (make-ement-notification :room-id room-id :event event)
:buffer-name (buffer-name))))))
(bhw/ement-search--insert-date-headers)
(bhw/ement-search--update-header)
(when firstp
;; Every insertion lands at or above the window point, which drags it
;; down the buffer; the newest match is the one to be looking at.
(goto-char (point-min))
(dolist (window (get-buffer-window-list (current-buffer) nil t))
(set-window-start window (point-min))
(set-window-point window (point-min))))
(when (> orphans 0)
(message "Ement search: skipped %d match(es) in rooms not in this session"
orphans))))
(defun bhw/ement-search--callback (buffer data)
"Handle a Matrix /search response DATA for BUFFER."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(pcase-let* (((map ('search_categories
(map ('room_events (map count results next_batch)))))
data)
(new (cl-loop for result being the elements of results
for event = (map-elt result 'result)
when event
collect (cons (map-elt event 'room_id)
(ement--make-event event)))))
(setf (alist-get 'loading bhw/ement-search--etc) nil
(alist-get 'count bhw/ement-search--etc) count
;; tuwunel keeps handing back a `next_batch' offset once the
;; results are exhausted -- and hands back the *same* one -- so
;; taking it at face value makes `+' promise more forever. A
;; page that adds nothing, or that fails to advance the cursor,
;; is the end. (This `setf' is sequential, so the comparison
;; below still sees the previous token.)
(alist-get 'next-batch bhw/ement-search--etc)
(and new
(not (equal next_batch
(alist-get 'next-batch bhw/ement-search--etc)))
next_batch)
(alist-get 'pending bhw/ement-search--etc)
(append (alist-get 'pending bhw/ement-search--etc) new))
(bhw/ement-search--render)))))
(defun bhw/ement-search--fetch (buffer)
"Fetch the next page of server-side results for BUFFER."
(pcase-let (((map session term rooms next-batch)
(buffer-local-value 'bhw/ement-search--etc buffer)))
(with-current-buffer buffer
(setf (alist-get 'loading bhw/ement-search--etc) t)
(bhw/ement-search--update-header))
(ement-api session "search" :method 'post
:data (bhw/ement-search--request-data term rooms next-batch)
:then (lambda (data) (bhw/ement-search--callback buffer data))
:else (lambda (plz-error)
(when (buffer-live-p buffer)
(with-current-buffer buffer
(setf (alist-get 'loading bhw/ement-search--etc) nil)
(bhw/ement-search--update-header)))
(message "Ement search failed on %s (M_UNRECOGNIZED means no server-side index; use `bhw/ement-search-local'): %s"
(ement-server-name (ement-session-server session))
(truncate-string-to-width
(format "%S" plz-error) 200 nil nil t))))))
(defun bhw/ement-search-more ()
"Render, or fetch, more matches in the current search buffer."
(interactive)
(unless (derived-mode-p 'bhw/ement-search-mode)
(user-error "Not in an Ement search buffer"))
(pcase-let (((map term pending next-batch loading localp) bhw/ement-search--etc))
(cond (pending (bhw/ement-search--render))
(loading (message "Ement search: already fetching"))
((and next-batch (not localp))
(bhw/ement-search--fetch (current-buffer))
(message "Ement: fetching more matches for %S..." term))
(t (message "Ement search: no more matches for %S" term)))))
(cl-defun bhw/ement-search (term &key (session (ement-complete-session)) rooms)
"Search every room on SESSION for TERM, server-side.
Unlike `ement-room-occur', which filters the events Ement happens to have
cached for the room you are in, this asks the homeserver's own index and so
covers the full history of every room you have joined.
ROOMS, when non-nil, is a list of room IDs to restrict the search to;
interactively, a prefix argument restricts it to the current buffer's room.
Matches appear newest-first in a `bhw/ement-search-mode' buffer, grouped
under date headers and each prefixed by its room; RET jumps to the event in
its room, and `+' shows more."
(interactive
(let ((session (ement-complete-session))
(term (read-string "Search all Matrix rooms for: " nil
'bhw/ement-search-history)))
(list term :session session
:rooms (when (and current-prefix-arg ement-room)
(list (ement-room-id ement-room))))))
(when (string-empty-p (string-trim term))
(user-error "Nothing to search for"))
(let ((buffer (bhw/ement-search--buffer (format "*Ement Search: %s*" term)
session term rooms nil)))
(bhw/ement-search--fetch buffer)
(message "Ement: searching %s for %S..."
(if rooms "one room" "all rooms") term)
(pop-to-buffer buffer)))
(cl-defun bhw/ement-search-local (regexp &optional (session (ement-complete-session)))
"Search the events already cached for every room on SESSION for REGEXP.
This never touches the network: it scans each room's `ement-room-timeline',
i.e. only what this session has fetched so far, so it is the fallback when a
homeserver has no search index -- and the way to search with a regexp, since
the endpoint `bhw/ement-search' uses takes plain terms only."
(interactive (list (read-regexp "Search cached Ement events (regexp): ")))
(let* ((matches
(cl-loop for room in (ement-session-rooms session)
for room-id = (ement-room-id room)
append (cl-loop for event in (ement-room-timeline room)
for body = (alist-get 'body (ement-event-content event))
when (and (stringp body) (string-match-p regexp body))
collect (cons room-id event))))
(buffer (bhw/ement-search--buffer
(format "*Ement Search (cached): %s*" regexp)
session regexp nil t)))
(with-current-buffer buffer
(setf (alist-get 'count bhw/ement-search--etc) (length matches)
(alist-get 'pending bhw/ement-search--etc)
(sort matches (lambda (a b)
(> (or (ement-event-origin-server-ts (cdr a)) 0)
(or (ement-event-origin-server-ts (cdr b)) 0)))))
(bhw/ement-search--render))
(pop-to-buffer buffer)
(message "Ement: %d cached match(es) for %S" (length matches) regexp)))
(defun bhw/ement-search-kill-buffers ()
"Kill `bhw/ement-search-mode' buffers.
`ement-kill-buffers' only matches major modes whose name starts with
\"ement-\", so it would leave these behind holding the session -- and the
event structs -- that `ement-disconnect' has just torn down."
(dolist (buffer (buffer-list))
(when (eq 'bhw/ement-search-mode (buffer-local-value 'major-mode buffer))
(kill-buffer buffer))))
(add-hook 'ement-disconnect-hook #'bhw/ement-search-kill-buffers)
(map! :map ement-notifications-mode-map
:n "S" #'bhw/ement-search)
;;; --- Room picker: network labels, liveness filter, recency sort ------------
;; `ement-complete-room' offers `ement--format-room' output, which for a
;; bridged DM is just the remote contact's display name plus an opaque room
;; ID: four rooms read "John Doe (<!...>): \"\"" with nothing to
;; choose between them. The three additions below are independent:
;; `bhw/ement-room-label' names the network, `bhw/ement-room-live-p' drops
;; per-call portals, and the completion table sorts by recency and annotates
;; with last activity.
(defconst bhw/ement-bridge-tags
'(("gvoice" . "GV")
("whatsapp" . "WA")
("slackgo" . "Slack")
("facebookgo" . "FB")
("discordgo" . "DC")
("gmessages-sms" . "SMS")
("signal" . "SG")
("telegram" . "TG")
("instagram" . "IG"))
"Short tags for `m.bridge' protocol ids.
Used only when the bridge reports no network name; Slack and the other
multi-tenant bridges name their workspace, which is more useful than the
protocol (\"Acme Workspace\", not \"Slack\").")
(defun bhw/ement--room-state-event (room type)
"Return the newest state event of TYPE in ROOM, or nil.
Ement files a state event wherever it arrived -- the sync timeline, the
room's state list, or invite-state -- so `ement--room-display-name'
searches all three (ement-lib.el:1471) and so must we. Looking only at
`ement-room-state' finds `m.bridge' on a small minority of rooms and
silently misreports every other portal as unbridged."
(or (cl-find type (ement-room-timeline room) :key #'ement-event-type :test #'equal)
(cl-find type (ement-room-state room) :key #'ement-event-type :test #'equal)
(cl-find type (ement-room-invite-state room) :key #'ement-event-type :test #'equal)))
(defun bhw/ement-room-bridge-info (room)
"Return an alist describing ROOM's bridge, or nil for a native Matrix room.
Keys are `protocol', `network', `channel' and `tag', read from the
`m.bridge' state event (MSC2346). `tag' is the network name when the
bridge reports one, else the short tag from `bhw/ement-bridge-tags'."
(when-let* ((event (bhw/ement--room-state-event room "m.bridge"))
(content (ement-event-content event))
(protocol (alist-get 'id (alist-get 'protocol content))))
(let ((network (alist-get 'displayname (alist-get 'network content))))
(list (cons 'protocol protocol)
(cons 'network network)
(cons 'channel (alist-get 'id (alist-get 'channel content)))
(cons 'receiver (alist-get 'fi.mau.receiver (alist-get 'channel content)))
(cons 'tag (or network
(cdr (assoc protocol bhw/ement-bridge-tags))
protocol))))))
(defun bhw/ement-room-label (room)
"Return ROOM's display name suffixed with the network it lives on.
\"Jane Doe\" becomes \"Jane Doe (GV)\" on Google Voice and
\"Jane Doe (Acme Workspace)\" on Slack. WhatsApp and Slack
already append the tag themselves, so an existing suffix is absorbed rather
than doubled; Slack group DMs, which repeat the workspace after every single
member, collapse to one trailing suffix."
(let ((name (or (ement-room-display-name room)
(setf (ement-room-display-name room)
(ement--room-display-name room))
"[unnamed]"))
(tag (alist-get 'tag (bhw/ement-room-bridge-info room))))
(if (or (null tag) (equal name tag))
name
(let ((base (string-trim
(replace-regexp-in-string
(concat "[ \t]*(" (regexp-quote tag) ")") "" name))))
(if (string-empty-p base) name (format "%s (%s)" base tag))))))
(defun bhw/ement-room-live-p (room)
"Return non-nil unless ROOM is an empty Google Voice per-call portal.
mautrix-gvoice opens a portal per *call*, keyed `c.<id>' in the `m.bridge'
channel, beside the single `t.<number>' portal carrying the SMS thread.
Those call portals never receive a message and are named after the contact,
so a contact you have phoned three times occupies four identically named
rooms in the picker. A call portal that somehow does hold messages is kept."
(let* ((bridge (bhw/ement-room-bridge-info room))
(channel (alist-get 'channel bridge)))
(not (and (equal (alist-get 'protocol bridge) "gvoice")
(stringp channel)
(string-prefix-p "c." channel)
(not (cl-find "m.room.message" (ement-room-timeline room)
:key #'ement-event-type :test #'equal))))))
(defvar bhw/ement--ambiguous-labels nil
"Hash table of labels naming more than one room in the current completion.
`ement-complete-room' maps the chosen string back to a room with
`alist-get' and `string=' (ement-lib.el:1112), so candidates have to stay
unique; the room ID is appended to exactly those that would otherwise
collide, instead of to all of them.")
(defun bhw/ement--format-room-a (fn room &optional topic)
"Return a network-labelled candidate when ROOM is being completed.
Advice for `ement--format-room'. Both completion call sites pass the
symbol `topic' (ement-lib.el:1102,1111); every other caller passes nothing
and is left alone, so prompts and `ement-message' output keep upstream's
name-alias-ID form."
(if (not (eq topic 'topic))
(funcall fn room topic)
(let ((label (bhw/ement-room-label room)))
(if (and bhw/ement--ambiguous-labels
(gethash label bhw/ement--ambiguous-labels))
(format "%s <%s>" label (ement-room-id room))
label))))
(advice-add 'ement--format-room :around #'bhw/ement--format-room-a)
(defun bhw/ement--room-age (room)
"Return a compact human age for ROOM's last activity, e.g. \"3d\"."
(if-let ((ts (ement-room-latest-ts room)))
(let ((days (/ (- (float-time) (/ ts 1000)) 86400.0)))
(cond ((< days 1) (format "%dh" (max 1 (truncate (* days 24)))))
((< days 7) (format "%dd" (truncate days)))
((< days 365) (format "%dw" (truncate (/ days 7))))
(t (format "%dy" (truncate (/ days 365))))))
"-"))
(defun bhw/ement--completion-table (candidates rooms)
"Return a completion table over CANDIDATES annotated and sorted by recency.
ROOMS is a hash mapping each candidate string to its `ement-room'."
(lambda (string predicate action)
(cond
((eq action 'metadata)
`(metadata
(annotation-function
. ,(lambda (cand)
(if-let ((room (gethash cand rooms)))
(let ((unread (alist-get 'notification_count
(ement-room-unread-notifications room))))
(concat (propertize " " 'display '(space :align-to 60))
(propertize (format "%-5s" (bhw/ement--room-age room))
'face 'font-lock-comment-face)
(propertize (if (and unread (> unread 0))
(format "%d unread" unread) "")
'face 'font-lock-keyword-face)))
"")))
(display-sort-function
. ,(lambda (cands)
(sort cands
(lambda (a b)
(> (or (and (gethash a rooms)
(ement-room-latest-ts (gethash a rooms)))
0)
(or (and (gethash b rooms)
(ement-room-latest-ts (gethash b rooms)))
0))))))
(cycle-sort-function . identity)))
(t (complete-with-action action candidates string predicate)))))
(defun bhw/ement-complete-room-a (fn &rest args)
"Filter, sort and annotate the room picker.
Advice for `ement-complete-room'. The `:predicate' in ARGS gains a
liveness test, and `completing-read' is rebound so the plain candidate list
upstream builds is wrapped in a table carrying `annotation-function' and
`display-sort-function' -- a list alone cannot answer a metadata request,
so Vertico would otherwise fall back to `vertico-sort-function'
\(vertico.el:250) and sort these alphabetically."
(unless bhw/ement-room-picker-show-all
(let ((caller-predicate (or (plist-get args :predicate) #'identity)))
(setq args (plist-put (copy-sequence args) :predicate
(lambda (room)
(and (funcall caller-predicate room)
(bhw/ement-room-live-p room)))))))
(let* ((sessions (if-let ((session (plist-get args :session)))
(list session)
(mapcar #'cdr ement-sessions)))
;; The *effective* predicate, liveness included: a label shared only
;; with rooms that will not be offered is not ambiguous, and tagging
;; it with a room ID would put back the noise this is removing.
(offered-predicate (or (plist-get args :predicate) #'identity))
(offered (cl-loop for session in sessions
append (cl-remove-if-not offered-predicate
(ement-session-rooms session))))
(counts (make-hash-table :test 'equal))
(rooms (make-hash-table :test 'equal))
(bhw/ement--ambiguous-labels (make-hash-table :test 'equal))
(inner (symbol-function #'completing-read)))
;; Two passes: the first finds labels shared by several offered rooms, so
;; that the second -- via `bhw/ement--format-room-a' -- disambiguates only
;; those.
(dolist (room offered)
(let ((label (bhw/ement-room-label room)))
(puthash label (1+ (gethash label counts 0)) counts)))
(maphash (lambda (label n)
(when (> n 1) (puthash label t bhw/ement--ambiguous-labels)))
counts)
(dolist (room offered)
(puthash (ement--format-room room 'topic) room rooms))
(cl-letf (((symbol-function #'completing-read)
(lambda (prompt collection &rest rest)
(apply inner prompt
(if (listp collection)
(bhw/ement--completion-table collection rooms)
collection)
rest))))
(apply fn args))))
(advice-add 'ement-complete-room :around #'bhw/ement-complete-room-a)
;;; --- Duplicate portal cleanup ---------------------------------------------
;; Rooms sharing a display name are usually NOT duplicates: Google Voice opens
;; one portal per call, and two WhatsApp contacts can both be named "Sam".
;; The only sound identity for "the same remote conversation" is the m.bridge
;; triple of protocol, receiver and channel id.
(defun bhw/ement-duplicate-portal-groups ()
"Return groups of rooms bridging the same remote conversation.
Each group is a list of `ement-room' structs sharing an `m.bridge'
protocol, receiver and channel id, sorted most-recently-active first.
Native Matrix rooms and unbridged rooms are never grouped."
(let ((groups (make-hash-table :test 'equal)) out)
(dolist (pair ement-sessions)
(dolist (room (ement-session-rooms (cdr pair)))
(when-let* ((bridge (bhw/ement-room-bridge-info room))
(channel (alist-get 'channel bridge)))
(push (cons room (cdr pair))
(gethash (list (alist-get 'protocol bridge)
(alist-get 'receiver bridge)
channel)
groups)))))
(maphash (lambda (_key rooms)
(when (cdr rooms)
(push (sort rooms (lambda (a b)
(> (or (ement-room-latest-ts (car a)) 0)
(or (ement-room-latest-ts (car b)) 0))))
out)))
groups)
out))
(defun bhw/ement-report-duplicate-portals ()
"Show every remote conversation this session has portalled more than once.
Read-only counterpart to `bhw/ement-forget-duplicate-portals'."
(interactive)
(let ((groups (bhw/ement-duplicate-portal-groups)))
(if (not groups)
(message "Ement: no duplicate portals.")
(with-current-buffer (get-buffer-create "*Ement Duplicate Portals*")
(let ((inhibit-read-only t))
(erase-buffer)
(special-mode)
(insert (format "%d remote conversation(s) portalled more than once.\n"
(length groups))
"The first room in each group is the most recently active.\n\n")
(dolist (group groups)
(pcase-let ((`((,room . ,_session) . ,_) group))
(insert (propertize (bhw/ement-room-label room) 'face 'bold) "\n"))
(pcase-dolist (`(,room . ,_session) group)
(insert (format " %-38s %-10s %2d cached message(s)\n"
(ement-room-id room)
(format-time-string "%F" (/ (ement-room-latest-ts room) 1000))
(cl-count "m.room.message" (ement-room-timeline room)
:key #'ement-event-type :test #'equal))))
(insert "\n"))
(goto-char (point-min)))
(pop-to-buffer (current-buffer))))))
(defun bhw/ement-forget-duplicate-portals ()
"Leave and forget duplicate portals, keeping the most recent of each group.
Confirms once with a summary, then works without further prompting.
Forgetting is irreversible -- the room cannot be rejoined to read its
history -- so review `bhw/ement-report-duplicate-portals' first."
(interactive)
(let* ((groups (bhw/ement-duplicate-portal-groups))
(doomed (cl-loop for group in groups append (cdr group))))
(cond
((null doomed) (message "Ement: no duplicate portals to forget."))
((not (yes-or-no-p
(format "Leave and forget %d duplicate portal(s) in %d group(s)? \
(WARNING: irreversible; their history becomes unreachable) "
(length doomed) (length groups))))
(message "Ement: cancelled."))
(t
;; `ement-forget-room' still calls `yes-or-no-p' once per joined room
;; even with FORCE-P (ement-lib.el:265); the confirmation above stands
;; in for all of them.
(cl-letf (((symbol-function #'yes-or-no-p) (lambda (&rest _) t)))
(pcase-dolist (`(,room . ,session) doomed)
(ement-forget-room room session 'force)))
(message "Ement: forgetting %d duplicate portal(s)." (length doomed))))))
Transmission Config
There are two separate Transmission instances on this machine and they must never run at once — both want RPC port 9091 and peer port 51413, and the pair crashes:
- the user instance, config in
~/.config/transmission-daemon, holding the actual torrent list. This is the one to use. - the system instance,
transmission-daemon.service, running asdebian-transmissionout of/var/lib/transmission-daemonwith its own (empty) torrent list. Keep itdisabled;systemctl maskit if it ever gets started by accident.
sudo apt install transmission-daemon -y
# Keep the system unit out of the way, permanently:
sudo systemctl disable --now transmission-daemon
# Start the user instance instead (or M-x bhw/transmission-start-daemon).
# Bare invocation already defaults to ~/.config/transmission-daemon:
transmission-daemon -g ~/.config/transmission-daemon
# Add the torrent (e.g., using a magnet link):
transmission-remote -a "magnet:?xt=urn:btih:..."
# List all added torrents to find <torrent-ID>
transmission-remote -l
# List the files in the torrent to identify their indices:
transmission-remote -t <torrent-ID> -f
# Deselects all files, then re-selects only the desired ones.
transmission-remote -t <torrent-ID> -Gall
transmission-remote -t <torrent-ID> -g1,3,5
# Stop torrent
# transmission-remote -t <torrent-ID> -S
# Start torrent
# transmission-remote -t <torrent-ID> -s
#Remove a torrent (keeps downloaded data):
transmission-remote -t [ID] -r
#Remove a torrent AND delete data:
transmission-remote -t [ID] -rad
Transmission settings.json file and verify that rpc-authentication-required is set to true.
;;---------------------------------------------------------------------------
(map! :leader
:desc "transmission" "oT" #'transmission)
(after! transmission
(evil-collection-transmission-setup)
(map! :map transmission-mode-map
:n "s" #'avy-goto-word-or-subword-1)
(setf transmission-refresh-modes
'(transmission-mode
transmission-files-mode
transmission-info-mode
transmission-peers-mode)))
(defun bhw/transmission-reachable-p (&optional timeout)
"Return non-nil if a Transmission daemon accepts an RPC connection.
Waits at most TIMEOUT seconds, default 1. Unix-socket hosts are reported
reachable without probing: those fail immediately anyway, so they cannot
produce the stall this guards against."
(if (file-name-absolute-p transmission-host)
t
(let ((deadline (+ (float-time) (or timeout 1.0)))
proc)
(unwind-protect
(condition-case nil
(progn
(setq proc (make-network-process
:name "transmission-probe"
:host transmission-host
:service transmission-service
:nowait t :noquery t))
(while (and (eq (process-status proc) 'connect)
(< (float-time) deadline))
(accept-process-output proc 0.01))
(eq (process-status proc) 'open))
(error nil))
(when (process-live-p proc) (delete-process proc))))))
(defvar bhw/transmission-config-dir
(expand-file-name "~/.config/transmission-daemon")
"Config directory of the *user* Transmission instance.
Distinct from the system package's /var/lib/transmission-daemon.")
(defun bhw/transmission-start-daemon ()
"Start the user Transmission daemon, then wait for its RPC port.
Deliberately does not touch the system-wide `transmission-daemon.service'.
That unit runs as debian-transmission out of /var/lib/transmission-daemon
with its own torrent list, and would fight this instance for RPC port 9091
and peer port 51413; running both crashes them."
(interactive)
(require 'transmission)
(if (bhw/transmission-reachable-p 0.5)
(message "Transmission daemon already listening on %s:%s"
transmission-host transmission-service)
(unless (executable-find "transmission-daemon")
(user-error "No transmission-daemon executable found"))
(call-process "transmission-daemon" nil 0 nil
"-g" bhw/transmission-config-dir)
(let ((deadline (+ (float-time) 10)))
(while (and (< (float-time) deadline)
(not (bhw/transmission-reachable-p 0.3))))
(if (bhw/transmission-reachable-p 0.5)
(message "Transmission daemon started (%s)" bhw/transmission-config-dir)
(user-error "Transmission daemon did not come up within 10s")))))
(after! transmission
(defadvice! bhw/transmission-require-daemon-a (&rest _)
"Refuse to open a blocking connection to a daemon that is not there."
:before #'transmission-make-network-process
(unless (bhw/transmission-reachable-p)
(user-error "No Transmission daemon at %s:%s; start the user instance with %s"
transmission-host transmission-service
"M-x bhw/transmission-start-daemon"))))
transmission-make-network-process opens a blocking connection — it calls
make-network-process without :nowait — and transmission-wait then loops
on accept-process-output with no timeout at all. When nothing is listening,
Emacs sits inside connect(2) on the main thread and cannot even be C-g’d.
That is merely ugly on an ordinary host, where a closed port answers with an
immediate RST. Under WSL2 networkingMode=mirrored it is a hard freeze: a SYN
to a closed IPv4 loopback port is silently dropped rather than refused, so the
connect runs the full kernel SYN backoff before failing. Measured on this
machine with tcp_syn_retries=6:
curl 127.0.0.1:9091 # -> fails after 134207 ms
curl '[::1]:9091' # -> fails after 0 ms (refused)
So SPC o T with the daemon down froze Emacs for 2m14s. The advice above
probes with a non-blocking connect first and raises a user-error instead.
Calendar Config
;;---------------------------------------------------------------------------
(map! :leader
:desc "gregorian calendar" "og" #'calendar)
Anki Editor Config
;;---------------------------------------------------------------------------
(use-package! anki-editor :defer t)
Biome Config
;;---------------------------------------------------------------------------
(defun bhw/biome-detroit ()
"Load biome (defining its presets), then show the Detroit forecast.
The preset commands only exist after the `after!' block below runs."
(interactive)
(require 'biome)
(call-interactively #'meteorology-detroit-weather))
(map! :leader
:desc "biome" "om" #'bhw/biome-detroit)
(after! biome
(eval `(biome-def-preset meteorology-detroit-weather
((:name . "NOAA GFS & HRRR (U.S.)")
(:group . "hourly")
(:params
("hourly" "wind_speed_10m" "cloud_cover" "precipitation" "apparent_temperature")
("longitude" . ,bhw/biome-detroit-longitude)
("latitude" . ,bhw/biome-detroit-latitude)))))
(eval `(biome-def-preset meteorology-toronto-weather
((:name . "GEM (Canada)")
(:group . "hourly")
(:params
("hourly" "wind_speed_10m" "cloud_cover" "precipitation" "apparent_temperature")
("longitude" . ,bhw/biome-toronto-longitude)
("latitude" . ,bhw/biome-toronto-latitude))))))
Casual Emacs Calc Config
- Using Units in Emacs Calc
- WorgSheet Calc Intro
- Calc Manual SS 6.8
- Irreal Blog’s on Calc
- Make typing mathematical equations in LaTeX easier with Emacs’ Calc
- Why use Emacs Calc mode?
| Key | Command | Effect |
|---|---|---|
| C-x * : | calc-grab-sum-down | |
| C-x * 0 | calc-reset | reset calc stack |
M-x calc, or SPC a * will enter calc-dispatch. y will yank top to stack to the last edited buffer, x is like M-x but for calc functions only.
Make your way through
For a quick start. The complete calc manual is available here https://www.gnu.org/software/emacs/manual/html_mono/calc.html]], and is written by the author of the calc package: Dave Gillespie (who also wrote cl-lib, so massive respect). What is RPN (Reverse Polish Notation)? A comprehensive tutorial outlined it, RPN Tutorial. nfdn: Mathing in Emacs with Casual
;;---------------------------------------------------------------------------
;; Inside `after! calc': touching `calc-mode-map' loads calc, and
;; `casual-calc-tmenu' is autoloaded, so nothing here needs casual-calc
;; itself until the menu is first opened.
(after! calc
(dolist (m (list calc-mode-map calc-alg-map))
(map! :map m "SPC" #'doom/leader)
(keymap-set m "C-o" #'casual-calc-tmenu)))
Emacs Reader Config
Reinstall guide: #122 - Trouble Installing via VC - divyaranjan/emacs-reader - Codeberg.org
;;---------------------------------------------------------------------------
;; (add-to-list 'load-path "/usr/local/src/emacs-reader/")
;; (require 'reader-saveplace)
;; (require 'reader)
;; (add-to-list 'auto-mode-alist '("\\.docx\\'" . reader-mode))
;; (define-key reader-mode-map (kbd "d") #'reader-scroll-down-or-next-page)
;; (define-key reader-mode-map (kbd "u") #'reader-scroll-up-or-prev-page)
;; (define-key reader-mode-map (kbd "gt") #'reader-goto-page)
;; (define-key reader-mode-map (kbd "q") #'reader-close-doc)
;; (spacemacs/set-leader-keys-for-major-mode 'reader-mode "fh" 'reader-fit-to-height)
;; (spacemacs/set-leader-keys-for-major-mode 'reader-mode "fw" 'reader-fit-to-width)
;; (spacemacs/set-leader-keys-for-major-mode 'reader-mode "o" 'reader-outline-show)
;; (spacemacs/set-leader-keys-for-major-mode 'reader-mode "ss" 'reader-search-mode)
;; (load! "private-packages/emacs-reader-noter.el")
1.3.15. Config Load Sentinel
This must remain the final block tangled into config.el: reaching it proves
the whole file evaluated. bhw/config-test--load-stamp (see Config Test Suite) compares this stamp against the last reload’s start time to
detect loads that doom/reload silently aborted partway through.
;;---------------------------------------------------------------------------
;; Must remain the FINAL form tangled into config.el (see Config Test Harness).
(setq bhw/config-load-stamp (float-time))
1.4. Config Test Suite
An ERT suite for the tangled configuration, run automatically after every
doom/reload by the Config Test Harness. Quiet on success (one line in
*Messages*), pops the interactive ERT buffer on failure. Run manually with
M-x ert RET t RET or the tag selector (tag bhw-config).
The suite deliberately does not duplicate doom doctor (package
installability, stale .elc, fonts, environment). It checks what doctor
cannot: that this config actually finished loading, that its tangled
artifacts are internally consistent with their org source, that reloads are
idempotent (no stacked hooks, timers, or advice), and that entry points remain
bound and callable.
The cookie sits in its own :comments no block; see Emacs Initialization.
;;; config-tests.el --- ERT suite for the tangled config -*- lexical-binding: t; -*-
(require 'ert)
(require 'cl-lib)
;; `org-state' is org's own dynamic variable, bound by `org-todo' around
;; `org-after-todo-state-change-hook'. org declares it with a value-less
;; `defvar', which marks it special only inside org's own files, so under
;; lexical binding a `let' on it here would bind lexically and never reach
;; `bhw/org-schedule-on-wait'. Declare it special for this file too.
(defvar org-state)
(defconst bhw/config-tests--org-source
(expand-file-name "~/project-maria/blog/dotemacs.org")
"The literate source that tangles config.el, packages.el and this file.")
(defun bhw/config-tests--run-and-report ()
"Run every `bhw-config'-tagged test; quiet on pass, pop the ERT UI on failure."
(let* ((stats (ert-run-tests '(tag bhw-config) #'ignore))
(bad (ert-stats-completed-unexpected stats))
(total (ert-stats-total stats)))
(when (boundp 'bhw/config-tests-last-report)
(setq bhw/config-tests-last-report
(cons (float-time)
(if (zerop bad)
(format "%d/%d passed" total total)
(format "%d of %d FAILED (ERT buffer popped in Emacs)"
bad total)))))
(if (zerop bad)
(message "bhw/config-tests: %d/%d passed" total total)
(message "bhw/config-tests: %d of %d FAILED" bad total)
(ert '(tag bhw-config)))))
(defun bhw/config-tests--read-top-level-forms (file)
"Return every top-level sexp in FILE. Signals on malformed lisp."
(with-temp-buffer
(insert-file-contents file)
(let (forms)
(condition-case nil
(while t (push (read (current-buffer)) forms))
(end-of-file nil))
(nreverse forms))))
(ert-deftest bhw/config-test--load-stamp ()
"config.el evaluated all the way to its final form on the last (re)load.
`doom/reload' demotes config errors and reports success regardless; a stale
stamp is the only reliable tell that the load died partway."
:tags '(bhw-config)
(should bhw/config-load-stamp)
(skip-unless bhw/config-reload-start-time)
(should (> bhw/config-load-stamp bhw/config-reload-start-time)))
(ert-deftest bhw/config-test--tangle-markers-balanced ()
"Every tangled file has matching, nonzero org-babel link comment markers.
Catches truncated tangles and hand-edits that corrupt the detangle markers."
:tags '(bhw-config)
(dolist (file '("config.el" "packages.el" "init.el"))
(with-temp-buffer
(insert-file-contents (expand-file-name file doom-user-dir))
(let ((begins (count-matches "^;; \\[\\[file:" (point-min) (point-max)))
(ends (count-matches "^;; .* ends here$" (point-min) (point-max))))
(should (> begins 0))
(should (= begins ends))))))
(ert-deftest bhw/config-test--tangle-fresh ()
"At least one tangled file is as new as the org source.
ob-tangle-sync is bidirectional and per-block, so individual targets may
legitimately lag; but when dotemacs.org is newer than ALL of them, an
external edit was never tangled. The list below must therefore name
*every* target dotemacs.org tangles -- an edit confined to a target that is
missing from it reads as a missed tangle and fails this test spuriously."
:tags '(bhw-config)
(skip-unless (file-exists-p bhw/config-tests--org-source))
(let ((src (float-time (file-attribute-modification-time
(file-attributes bhw/config-tests--org-source)))))
(should (cl-some
(lambda (file)
(let ((out (expand-file-name file doom-user-dir)))
(and (file-exists-p out)
(>= (float-time (file-attribute-modification-time
(file-attributes out)))
(- src 1.0)))))
'("config.el" "packages.el" "init.el"
"private-packages/ement-config.el"
"tests/config-tests.el"
"tests/startup-deferral-check.sh")))))
(ert-deftest bhw/config-test--tangle-on-save-allowlist ()
"Tangle-on-save is armed, and fires for dotemacs.org and dotfiles.org only.
`after-save-hook' is global, so `bhw/org-tangle-on-save-file-p' is the only
thing standing between the hook and a full `org-babel-tangle' of every other
Org file in project-maria."
:tags '(bhw-config)
(should (memq 'bhw/org-babel-tangle-on-save (default-value 'after-save-hook)))
(should (equal bhw/org-tangle-on-save-files
(mapcar #'file-truename
(list bhw/config-tests--org-source
(expand-file-name "~/project-maria/dotfiles.org")))))
(pcase-dolist (`(,file . ,tangles-p)
`((,bhw/config-tests--org-source . t)
(,(expand-file-name "~/project-maria/dotfiles.org") . t)
(,(expand-file-name "~/project-maria/hq.org") . nil)
(nil . nil)))
(with-temp-buffer
(setq buffer-file-name file)
(should (eq (and (bhw/org-tangle-on-save-file-p) t) tangles-p))
;; `with-temp-buffer' kills the buffer on exit; a non-nil
;; `buffer-file-name' would make that prompt about a visited file.
(setq buffer-file-name nil))))
(defconst bhw/config-tests--must-defer
'(anki-editor ement elfeed citar lexic biome casual-calc
claude-code-ide transmission mu4e org-node)
"Packages whose `use-package!' blocks must carry a deferral keyword.
Grown as the config is refactored; a name listed here with an eager
use-package! block in config.el fails `bhw/config-test--use-package-deferral'.")
(defconst bhw/config-tests--forbidden-top-level-loads
'("ob-tangle-sync" "office-to-org")
"Regexps of `load!' targets that must not appear at top level in config.el.
Heavy private packages belong inside `after!' blocks.")
(ert-deftest bhw/config-test--use-package-deferral ()
"Heavy packages stay deferred; heavy private packages stay inside `after!'.
Static check over config.el's source, so it works no matter what is
currently loaded."
:tags '(bhw-config)
(let ((forms (bhw/config-tests--read-top-level-forms
(expand-file-name "config.el" doom-user-dir))))
(dolist (form forms)
(when (and (consp form)
(eq (car form) 'use-package!)
(memq (cadr form) bhw/config-tests--must-defer))
(should (cl-intersection '(:defer :commands :after :hook :bind :mode)
(cddr form))))
(when (and (consp form)
(eq (car form) 'load!)
(stringp (cadr form)))
(dolist (re bhw/config-tests--forbidden-top-level-loads)
(should-not (string-match-p re (cadr form))))))))
(ert-deftest bhw/config-test--bhw-hook-hygiene ()
"Every bhw/ function found in any hook appears exactly once and is defined.
Guards `doom/reload' idempotency: re-evaluating config.el must not stack
duplicate hook entries, and renamed functions must not leave stale entries."
:tags '(bhw-config)
(let (hook-problems)
(mapatoms
(lambda (sym)
(when (and (boundp sym)
(string-suffix-p "-hook" (symbol-name sym))
(listp (symbol-value sym)))
(let ((hook-members
(seq-filter (lambda (f)
(and (symbolp f)
(string-prefix-p "bhw/" (symbol-name f))))
(symbol-value sym))))
(dolist (f (delete-dups (copy-sequence hook-members)))
(unless (fboundp f)
(push (format "%s in %s is void" f sym) hook-problems))
(when (> (cl-count f hook-members) 1)
(push (format "%s appears %d times in %s"
f (cl-count f hook-members) sym)
hook-problems)))))))
(when (and (boundp 'elfeed-search-mode-hook)
(> (cl-count 'elfeed-update elfeed-search-mode-hook) 1))
(push "elfeed-update duplicated in elfeed-search-mode-hook" hook-problems))
(should-not hook-problems)))
(ert-deftest bhw/config-test--bhw-timer-hygiene ()
"Every bhw/ timer function is defined and scheduled at most once.
Guards the cancel-before-arm patterns that keep `doom/reload' from
stacking duplicate timers."
:tags '(bhw-config)
(let ((fns (mapcar #'timer--function (append timer-list timer-idle-list)))
timer-problems)
(dolist (f (delete-dups
(seq-filter (lambda (f)
(and (symbolp f)
(string-prefix-p "bhw/" (symbol-name f))))
(copy-sequence fns))))
(unless (fboundp f)
(push (format "%s is void" f) timer-problems))
(when (> (cl-count f fns) 1)
(push (format "%s scheduled %d times" f (cl-count f fns))
timer-problems)))
(should-not timer-problems)))
(ert-deftest bhw/config-test--ement-sweepers-armed ()
"All five Ement sweeper idle timers are armed exactly once."
:tags '(bhw-config)
(skip-unless (featurep 'ement))
(let ((fns (mapcar #'timer--function timer-idle-list)))
(dolist (f '(bhw/ement-kill-idle-room-buffers
bhw/ement-drop-orphan-image-data
bhw/ement-notifications-trim
bhw/ement-trim-room-timelines
bhw/ement-recycle-maybe))
(should (= 1 (cl-count f fns))))))
(ert-deftest bhw/config-test--ement-search-request-body ()
"The Matrix /search body carries the term, and the room/paging filters.
The homeserver ignores `filter.limit' (tuwunel 1.7 answered a limit-of-2
query with 201 events), so `bhw/ement-search' pages locally instead; what it
does depend on is `filter.rooms' and `next_batch', asserted here."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/ement-search--request-data))
(let ((plain (map-nested-elt
(json-parse-string (bhw/ement-search--request-data "hello" nil nil)
:object-type 'alist)
'(search_categories room_events)))
(filtered (map-nested-elt
(json-parse-string
(bhw/ement-search--request-data "hi" '("!a:example.org") "28")
:object-type 'alist)
'(search_categories room_events))))
(should (equal "hello" (map-elt plain 'search_term)))
(should (equal "recent" (map-elt plain 'order_by)))
(should-not (map-elt plain 'filter))
(should-not (map-elt plain 'next_batch))
(should (equal ["!a:example.org"] (map-nested-elt filtered '(filter rooms))))
(should (equal "28" (map-elt filtered 'next_batch)))))
(ert-deftest bhw/config-test--ement-search-newest-first ()
"Search results insert newest-first.
`ement-room--insert-event' bakes its `node<' into `cl-labels', so the only
seam is the `<-fn' it hands to `ement-room--ewoc-node-before'. If upstream
ever stops routing placement through that call, the advice goes quiet and
results silently revert to oldest-first, which this catches."
:tags '(bhw-config)
(skip-unless (boundp 'bhw/ement-search--newest-first))
(cl-flet ((ewoc-of (values)
(let ((ewoc (ewoc-create (lambda (n) (insert (format "%s\n" n))))))
(dolist (n values ewoc) (ewoc-enter-last ewoc n)))))
(with-temp-buffer
(let ((descending (ewoc-of '(30 20 10)))
(ascending (ewoc-of '(10 20 30))))
(let ((bhw/ement-search--newest-first t))
;; 25 belongs just under 30, and 40 belongs above everything.
(should (equal 30 (ewoc-data (ement-room--ewoc-node-before descending 25 #'<))))
(should-not (ement-room--ewoc-node-before descending 40 #'<)))
(let ((bhw/ement-search--newest-first nil))
;; Unadvised order is untouched: 25 belongs just after 20.
(should (equal 20 (ewoc-data (ement-room--ewoc-node-before ascending 25 #'<))))
(should-not (ement-room--ewoc-node-before ascending 5 #'<)))))))
(ert-deftest bhw/config-test--environment ()
"Paths and external binaries the config depends on exist."
:tags '(bhw-config)
(dolist (path (list +project-maria-dir+
+project-jerome-dir+
(concat +project-maria-dir+ "project-jerome.bib")
bhw/config-tests--org-source
org-directory))
(should (file-exists-p path)))
(dolist (bin '("mbsync" "mu" "sdcv" "hunspell"))
(should (executable-find bin))))
(ert-deftest bhw/config-test--leader-bindings ()
"Leader-key entry points are bound to their expected, callable targets."
:tags '(bhw-config)
(dolist (spec '(("o e" . mu4e)
("o w" . elfeed)
("o m" . bhw/biome-detroit)
("o T" . transmission)
("o c" . ement-notifications)
("o C" . ement-connect)
("s c" . bhw/ement-room-view)
("s C" . bhw/ement-search-all-rooms)
("s f" . org-node-find)
("s x" . lexic-search-word-at-point)
("s SPC" . citar-open)
("d" . claude-code-ide-menu)
("a" . bhw/custom-agenda)
("n r" . org-clock-analyzer)))
(let ((target (lookup-key doom-leader-map (kbd (car spec)))))
(should (eq target (cdr spec)))
(should (fboundp target)))))
(ert-deftest bhw/config-test--transmission-probe-fails-fast ()
"The RPC probe gives up quickly instead of blocking on a dead port.
`transmission-make-network-process' connects synchronously, and under WSL2
mirrored networking a closed IPv4 loopback port drops the SYN rather than
refusing it, so an unguarded connect freezes Emacs for the whole TCP SYN
backoff -- measured at 134s here. The probe must bound that wait."
:tags '(bhw-config)
(require 'transmission)
(let* ((transmission-host "127.0.0.1")
(transmission-service 59119) ; nothing listens here
(start (float-time))
(reachable (bhw/transmission-reachable-p 0.2)))
(should-not reachable)
(should (< (- (float-time) start) 2.0))))
(ert-deftest bhw/config-test--advice-spot-checks ()
"Config-managed advice is present (or absent) exactly as designed."
:tags '(bhw-config)
(should (advice-member-p #'bhw/always-save-advice #'save-buffers-kill-emacs))
(when (featurep 'transmission)
(should (advice-member-p #'bhw/transmission-require-daemon-a
#'transmission-make-network-process)))
(when (featurep 'org)
(should-not (advice-member-p #'doom-set-jump-a #'org-mark-ring-push)))
(when (featurep 'office-to-org)
(should (advice-member-p #'office-to-org--dired-find-file-advice
#'dired-find-file)))
(when (featurep 'claude-code-ide)
(should (advice-member-p #'bhw/claude-code-ide-workspace-dir-a
#'claude-code-ide--get-working-directory))))
(defmacro bhw/config-test--with-edna-fixture (spec &rest body)
"Run BODY visiting a throwaway org file built from SPEC.
SPEC is (VAR ID UPSTREAM-STATE), where VAR is bound to the buffer
visiting the file. The file holds an UPSTREAM-STATE heading carrying
ID, and a TODO heading blocked on `ids(ID)'.
The fixture is deliberately inert in a live session: no `org-todo' call
(that would fire `org-after-todo-state-change-hook', `org-clock'
resolution and state logging for real), `org-mode-hook'/`find-file-hook'
are muted so org-mem and friends never see the file, `org-id-locations'
is a private copy so the real id table is untouched, and the buffer is
marked unmodified before being killed so nothing can prompt."
(declare (indent 1))
(let ((var (nth 0 spec)) (id (nth 1 spec)) (state (nth 2 spec)))
`(let* ((dir (make-temp-file "bhw-edna-" t))
(file (expand-file-name "t.org" dir))
(org-id-locations (copy-hash-table
(if (hash-table-p org-id-locations)
org-id-locations
(make-hash-table :test 'equal))))
(org-mode-hook nil)
(find-file-hook nil)
(kill-buffer-hook nil)
(org-inhibit-startup t)
(org-agenda-files nil))
(unwind-protect
(progn
(with-temp-file file
(insert (format "* %s Upstream\n:PROPERTIES:\n:ID: %s\n:END:\n\n"
,state ,id)
(format "* TODO Downstream\n:PROPERTIES:\n:BLOCKER: ids(%s)\n:END:\n"
,id)))
(puthash ,id file org-id-locations)
(let ((,var (find-file-noselect file)))
(unwind-protect
(with-current-buffer ,var
(goto-char (point-min))
(search-forward "Downstream")
(org-back-to-heading t)
,@body)
(with-current-buffer ,var (set-buffer-modified-p nil))
(kill-buffer ,var))))
(delete-directory dir t)))))
(ert-deftest bhw/config-test--org-edna-blocks-on-ts-ids ()
"Edna's `ids(...)' resolves ts-style org-ids, and blocks only on open ones.
`org-id-method' is `ts', so ids are not UUIDs. Upstream Edna reads an
unquoted `ids(...)' argument with `read' and only stringifies the
resulting symbol when `org-uuidgen-p' matches, so `org-edna-finder/ids'
signals `wrong-type-argument' on a ts id -- which the
`bhw/accept-non-uuid-ids' advice repairs.
Both blocking directions are asserted because `org-edna-run' wraps every
form in `condition-case-unless-debug' and treats *any* error as a block:
a broken finder is indistinguishable from a correctly blocked entry
until you check an entry that ought to be free."
:tags '(bhw-config)
(skip-unless (and (featurep 'org-edna) (bound-and-true-p org-edna-mode)))
(bhw/config-test--with-edna-fixture (buf "20260830T000000.000001" "TODO")
;; Quoted and unquoted spellings must agree -- and the id is in the
;; fixture's private `org-id-locations', so neither triggers the
;; whole-corpus `org-id-update-id-locations' rescan.
(should (equal (org-edna-finder/ids (intern "20260830T000000.000001"))
(org-edna-finder/ids "20260830T000000.000001")))
(should (org-entry-blocked-p)))
(bhw/config-test--with-edna-fixture (buf "20260830T000000.000002" "DONE")
(should-not (org-entry-blocked-p))))
(ert-deftest bhw/config-test--org-depend-not-hooked ()
"org-depend never shares Edna's hooks.
Both claim BLOCKER/TRIGGER with incompatible syntax; org-depend adds
itself to the hooks at load time, so a stray `require' anywhere is
enough to double-run every trigger."
:tags '(bhw-config)
(should-not (memq 'org-depend-block-todo org-blocker-hook))
(should-not (memq 'org-depend-trigger-todo org-trigger-hook)))
(ert-deftest bhw/config-test--harness-registered ()
"The reload-test harness is hooked exactly once on each reload hook."
:tags '(bhw-config)
(should (= 1 (cl-count 'bhw/config-tests-record-reload-h
doom-before-reload-hook)))
(should (= 1 (cl-count 'bhw/config-tests-run-h doom-after-reload-hook))))
(ert-deftest bhw/config-test--mcp-tool-registry ()
"Each custom MCP tool is registered exactly once with a defined function.
Guards the `bhw/claude-mcp-register' dedupe against upstream's
`add-to-list' whole-spec matching, which duplicates edited tools on
every `doom/reload'."
:tags '(bhw-config)
(skip-unless (and (boundp 'claude-code-ide-mcp-server-tools)
claude-code-ide-mcp-server-tools))
(dolist (name '("current-buffer-info" "current-selection" "list-buffers"
"org-node-find" "org-agenda-todos" "org-clock-status"
"citar-lookup" "describe-symbols" "buffer-sync" "key-lookup"
"doom-reload" "doom-reload-status" "messages-tail"
"last-backtrace"))
(let ((specs (cl-remove-if-not
(lambda (s) (and (keywordp (car-safe s))
(equal (plist-get s :name) name)))
claude-code-ide-mcp-server-tools)))
(should (= 1 (length specs)))
(should (functionp (plist-get (car specs) :function))))))
(ert-deftest bhw/config-test--mcp-buffer-context ()
"current-buffer-info reports the user's buffer, never the terminal.
The session context's :buffer is the ghostel terminal running Claude;
resolution must prefer :last-active-buffer."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/claude-mcp-current-buffer-info))
(with-temp-buffer
(rename-buffer "bhw-mcp-test-user-buf" t)
(let ((user-buf (current-buffer)))
(cl-letf (((symbol-function 'claude-code-ide-mcp-server-get-session-context)
(lambda (&optional _)
(list :buffer (messages-buffer)
:last-active-buffer user-buf))))
(let ((out (bhw/claude-mcp-current-buffer-info)))
(should (string-match-p "bhw-mcp-test-user-buf" out))
(should-not (string-match-p (regexp-quote "*Messages*") out)))))))
(ert-deftest bhw/config-test--mcp-false-decode ()
"JSON false/null atoms reach tools as nil via the validate-args advice.
Upstream's `json-parse-string' has no :false-object, so JSON false
decodes to the truthy keyword :false."
:tags '(bhw-config)
(skip-unless (featurep 'claude-code-ide-mcp-http-server))
(should (equal '(nil)
(claude-code-ide-mcp-http-server--validate-args
'((include_non_file . :false))
'((:name "include_non_file" :type boolean :optional t))))))
(ert-deftest bhw/config-test--mcp-output-hygiene ()
"Formatting helpers: truncation indicator, propertyless brief, AND-match."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/claude-mcp--join-capped))
(should (string-match-p "(\\+2 more"
(bhw/claude-mcp--join-capped '("a" "b" "c") 1)))
(should (equal "a\nb\nc" (bhw/claude-mcp--join-capped '("a" "b" "c") 3)))
(should (equal "x" (bhw/claude-mcp--brief (propertize "x" 'face 'bold))))
(should (bhw/claude-mcp--match-all-p '("team" "lencioni")
"lencioni five dysfunctions team"))
(should-not (bhw/claude-mcp--match-all-p '("team" "zzz") "lencioni team")))
(ert-deftest bhw/config-test--mcp-org-todo-row ()
"The agenda row formatter emits state, priority, path, tags, sched, effort."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/claude-mcp--org-todo-row))
(with-temp-buffer
(insert "* AREA: test\n"
"** TODO [#A] Fixture task :x:\n"
"SCHEDULED: <2026-01-01 Thu>\n"
":PROPERTIES:\n:EFFORT: 0:30\n:END:\n")
(delay-mode-hooks (org-mode))
(goto-char (point-min))
(forward-line 1)
(let ((row (bhw/claude-mcp--org-todo-row)))
(should row)
(dolist (needle '("TODO" "[#A] Fixture task" "path: AREA: test"
"tags: x" "sched: <2026-01-01" "effort: 0:30"))
(should (string-match-p (regexp-quote needle) row))))))
(ert-deftest bhw/config-test--mcp-reload-status-logic ()
"doom-reload-status distinguishes ok / partial-failure states."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/claude-mcp-doom-reload-status))
(let ((bhw/claude-mcp--reload-requested-at 100.0)
(bhw/config-load-stamp 200.0)
(bhw/config-reload-start-time 150.0)
(bhw/config-tests-last-report '(201.0 . "9/9 passed")))
(should (string-match-p "reload OK" (bhw/claude-mcp-doom-reload-status)))
(should (string-match-p "9/9 passed" (bhw/claude-mcp-doom-reload-status)))
(let ((bhw/config-load-stamp 50.0))
(should (string-match-p "FAILED partway"
(bhw/claude-mcp-doom-reload-status))))))
(ert-deftest bhw/config-test--claude-workspace-project-root ()
"The Claude session directory follows the workspace, not the buffer.
A stamped `+workspace-project' parameter wins outright (unless its
directory is gone); otherwise the project root shared by most of the
workspace's file buffers does."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/claude-code-ide--workspace-root))
(should (equal (expand-file-name (file-name-as-directory doom-user-dir))
(bhw/claude-code-ide--workspace-root doom-user-dir nil)))
;; A workspace can outlive the directory it was stamped with.
(should-not (bhw/claude-code-ide--workspace-root
"/tmp/bhw-no-such-workspace-project/" nil))
(let ((bufs (list (generate-new-buffer " *bhw-ws-a1*")
(generate-new-buffer " *bhw-ws-a2*")
(generate-new-buffer " *bhw-ws-b1*")))
(files '("/tmp/bhw-ws-a/f1.el" "/tmp/bhw-ws-a/f2.el"
"/tmp/bhw-ws-b/f3.el")))
(unwind-protect
(progn
(cl-loop for buf in bufs for file in files do
(with-current-buffer buf
(setq buffer-file-name file
default-directory (file-name-directory file))))
(cl-letf (((symbol-function 'doom-project-root)
(lambda (&optional dir)
(and (stringp dir)
(cond ((string-prefix-p "/tmp/bhw-ws-a/" dir)
"/tmp/bhw-ws-a/")
((string-prefix-p "/tmp/bhw-ws-b/" dir)
"/tmp/bhw-ws-b/"))))))
;; The majority project wins over any single buffer, and a
;; workspace whose buffers are all outside a project resolves
;; to nothing rather than to the current buffer's directory.
(should (equal "/tmp/bhw-ws-a/"
(bhw/claude-code-ide--workspace-root nil bufs)))
(should-not (bhw/claude-code-ide--workspace-root nil nil))))
(dolist (buf bufs)
(with-current-buffer buf (setq buffer-file-name nil))
(kill-buffer buf)))))
(ert-deftest bhw/config-test--org-capture-prompt-order ()
"Capture asks for the title first, then the metadata, in template order.
The headline is a `%^{Title}' prompt rather than `%?' so the minibuffer
chain starts with the title; `%?' sits past the drawer so the capture
buffer opens on the body."
:tags '(bhw-config)
(skip-unless (boundp 'org-capture-templates))
(let ((todo (nth 4 (assoc "t" org-capture-templates)))
(appt (nth 4 (assoc "a" org-capture-templates))))
(dolist (tpl (list todo appt))
(should (string-match-p "%\\^{Title}" tpl))
(should (string-suffix-p "%?" tpl)))
(should (< (string-match "%\\^{Title}" todo)
(string-match "EFFORT" todo)))
(should (< (string-match "%\\^{Title}" appt)
(string-match "SCHEDULED" appt)
(string-match "LOCATION" appt)
(string-match "EFFORT" appt)))))
(ert-deftest bhw/config-test--wait-prompts-for-date ()
"Switching to WAIT calls the scheduler; other states leave the entry alone.
Also pins the reschedule log being muted, which is what keeps the WAIT
state note from being displaced by `org-add-log-setup'."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/org-schedule-on-wait))
(should (memq 'bhw/org-schedule-on-wait org-after-todo-state-change-hook))
(let ((calls 0) (log-during t) (noninteractive nil))
(cl-letf (((symbol-function 'org-schedule)
(lambda (&rest _) (cl-incf calls) (setq log-during org-log-reschedule))))
(let ((org-state "TODO")) (bhw/org-schedule-on-wait))
(should (= calls 0))
(let ((org-state "WAIT")) (bhw/org-schedule-on-wait))
(should (= calls 1))
(should-not log-during)
;; Declining the date must not escape into `org-todo'.
(cl-letf (((symbol-function 'org-schedule)
(lambda (&rest _) (signal 'quit nil))))
(let ((org-state "WAIT"))
(should (progn (bhw/org-schedule-on-wait) t)))))))
(defmacro bhw/config-tests--with-zombie-clock (&rest body)
"Run BODY with Org's clock variables in the half-dead state.
The marker points at no buffer, so `org-clocking-p' is nil, but the
mode-line hook and heading are still installed — exactly what a failed
`org-clock-out' leaves behind."
(declare (indent 0))
`(let ((org-clock-marker (make-marker))
(org-clock-hd-marker (make-marker))
(org-clock-heading "Zombie task")
(org-clock-current-task "Zombie task")
(org-clock-start-time (time-subtract nil (* 9 60 60)))
(org-mode-line-string "[9:03/2:00] (Zombie task)")
(org-clock-mode-line-timer nil)
(org-clock-idle-timer nil)
(global-mode-string (list "" 'org-mode-line-string)))
,@body))
(ert-deftest bhw/config-test--clock-zombie-detected-and-cleared ()
"A clock display that outlived its clock is detected and torn down.
`org-clock-out' leaves this behind whenever the clocking buffer dies
under it: the marker points nowhere, so `org-clocking-p' is nil and
clock-out answers \"No active clock\" forever, while the mode-line
string keeps ticking."
:tags '(bhw-config)
(skip-unless (and (fboundp 'bhw/org-clock-zombie-p)
(fboundp 'bhw/org-clock-clear-zombie)))
(bhw/config-tests--with-zombie-clock
(setq org-clock-mode-line-timer (run-with-timer 3600 nil #'ignore))
(unwind-protect
(progn
(should (bhw/org-clock-zombie-p))
(should (equal (bhw/org-clock-clear-zombie) "Zombie task"))
(should-not (bhw/org-clock-zombie-p))
(should-not (memq 'org-mode-line-string global-mode-string))
(should-not org-clock-mode-line-timer)
(should-not org-clock-current-task)
(should (equal org-mode-line-string "")))
(when (timerp org-clock-mode-line-timer)
(cancel-timer org-clock-mode-line-timer)))))
(ert-deftest bhw/config-test--clock-not-zombie-after-clean-clock-out ()
"The state a successful `org-clock-out' leaves is not read as a zombie.
Clock-out resets neither `org-clock-start-time' nor
`org-mode-line-string', so neither can be the signal; only the
mode-line hook and the timers, which it does tear down, may be. A
false positive here would hijack an ordinary clock-in."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/org-clock-zombie-p))
(bhw/config-tests--with-zombie-clock
(setq org-clock-current-task nil
global-mode-string (list ""))
(should-not (bhw/org-clock-zombie-p))))
(ert-deftest bhw/config-test--clock-in-resolves-zombie-first ()
"`bhw/clock-in-dwim' clears a stale display and resolves the dangling clock.
Without this branch the command falls through to `org-clock-in' and
starts a second clock on top of the unclosed CLOCK line."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/clock-in-dwim))
(let ((resolved 0) (clocked-in 0) (clocked-out 0))
(bhw/config-tests--with-zombie-clock
(cl-letf (((symbol-function 'org-resolve-clocks)
(lambda (&rest _) (cl-incf resolved)))
((symbol-function 'org-clock-in)
(lambda (&rest _) (cl-incf clocked-in)))
((symbol-function 'org-clock-out)
(lambda (&rest _) (cl-incf clocked-out))))
(bhw/clock-in-dwim))
(should-not (memq 'org-mode-line-string global-mode-string)))
(should (= resolved 1))
(should (= clocked-in 0))
(should (= clocked-out 0))))
(defmacro bhw/config-tests--with-org-tasks (&rest body)
"Run BODY in a temp Org buffer holding two tasks, clock idle and clean.
`task-a' and `task-b' are bound to markers on the two headings.
`org-mode-hook' is muted because `org-clock-load' hangs off it, and a
test must not go reading the real clock-persistence file."
(declare (indent 0))
`(let ((org-mode-hook nil)
(org-clock-marker (make-marker))
(org-clock-hd-marker (make-marker))
(org-clock-heading "")
(org-clock-current-task nil)
(org-clock-start-time nil)
(org-clock-mode-line-timer nil)
(org-clock-idle-timer nil)
(org-mode-line-string "")
(global-mode-string (list ""))
(bhw/org-clock-confirm-switch t))
(with-temp-buffer
(delay-mode-hooks (org-mode))
(insert "* TODO Task A\n* TODO Task B\n")
(goto-char (point-min))
(let ((task-a (point-marker)))
(forward-line 1)
(let ((task-b (point-marker)))
(ignore task-a task-b)
,@body)))))
(ert-deftest bhw/config-test--clock-in-uses-task-at-point ()
"With nothing running, `bhw/clock-in-dwim' clocks the heading at point.
The command used to go straight to the history picker, which is the
wrong answer when point is already sitting on the task to start."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/org-clock-in-at-point))
(let (clocked-heading picked)
(bhw/config-tests--with-org-tasks
(goto-char task-b)
(cl-letf (((symbol-function 'org-clock-in)
(lambda (&optional arg &rest _)
(if arg
(setq picked t)
(setq clocked-heading (org-get-heading t t t t))))))
(bhw/clock-in-dwim)))
(should (equal clocked-heading "Task B"))
(should-not picked)))
(ert-deftest bhw/config-test--clock-in-resolves-stale-before-switching ()
"A clock running past `bhw/org-clock-stale-hours' is resolved, not banked.
The staleness check sits ahead of the task-at-point branch on purpose:
switching tasks closes the forgotten clock, which would record its whole
overnight span as worked time."
:tags '(bhw-config)
(skip-unless (boundp 'bhw/org-clock-stale-hours))
(let ((resolved 0) (clocked 0))
(bhw/config-tests--with-org-tasks
(set-marker org-clock-marker task-a)
(set-marker org-clock-hd-marker task-a)
(setq org-clock-heading "Task A"
org-clock-start-time
(time-subtract nil (* (1+ bhw/org-clock-stale-hours) 60 60)))
(goto-char task-b)
(cl-letf (((symbol-function 'org-resolve-clocks)
(lambda (&rest _) (cl-incf resolved)))
((symbol-function 'org-clock-in)
(lambda (&rest _) (cl-incf clocked))))
(bhw/clock-in-dwim)))
(should (= resolved 1))
(should (= clocked 0))))
(ert-deftest bhw/config-test--clock-in-confirms-task-switch ()
"Moving the clock to another task is confirmed; declining changes nothing.
Clocking a new task closes the running task's CLOCK line, so a stray
keypress on the wrong heading writes a real, wrong record. Point on the
task that is already clocking means clock out, and asks nothing."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/org-clock-in-at-point))
(let ((clocked 0) (asked 0))
(bhw/config-tests--with-org-tasks
(set-marker org-clock-marker task-a)
(set-marker org-clock-hd-marker task-a)
(setq org-clock-heading "Task A"
org-clock-start-time (current-time))
(goto-char task-b)
(cl-letf (((symbol-function 'org-clock-in)
(lambda (&rest _) (cl-incf clocked)))
((symbol-function 'org-clock-out)
(lambda (&rest _) (cl-incf clocked 10))))
(cl-letf (((symbol-function 'y-or-n-p)
(lambda (&rest _) (cl-incf asked) nil)))
(should-error (bhw/clock-in-dwim) :type 'user-error))
(should (= clocked 0))
(cl-letf (((symbol-function 'y-or-n-p)
(lambda (&rest _) (cl-incf asked) t)))
(bhw/clock-in-dwim))
(should (= clocked 1))
;; Same task as the running clock: clock out, no question.
(goto-char task-a)
(bhw/clock-in-dwim)))
(should (= asked 2))
(should (= clocked 11))))
(ert-deftest bhw/config-test--clock-menu-layout-parses ()
"The clock transient exists and its layout parses.
`transient-define-prefix' parses the layout when the definition runs, so
a malformed group would otherwise stay invisible until the first time
the menu was opened."
:tags '(bhw-config)
(skip-unless (require 'transient nil t))
(should (fboundp 'bhw/org-clock-menu))
(should (get 'bhw/org-clock-menu 'transient--prefix))
(should (get 'bhw/org-clock-menu 'transient--layout)))
(ert-deftest bhw/config-test--clock-history-saved-on-clock-events ()
"Clock history is written on every clock event, not only at `kill-emacs'.
A daemon that goes down with the WSL VM never runs `kill-emacs-hook',
and the header-only save file that leaves behind stays empty: the next
graceful exit has no history left to write."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/org-clock-save-h))
(should (memq org-clock-persist '(t history)))
(dolist (hook '(org-clock-in-hook org-clock-out-hook org-clock-cancel-hook))
(should (memq 'bhw/org-clock-save-h (symbol-value hook)))))
(defun bhw/config-tests--elided-view (header)
"Render HEADER through `bhw/mu4e-view-elide-recipients'; return the To: line."
(with-temp-buffer
(insert header "\nSubject: hi\n\nbody\n")
(bhw/mu4e-view-elide-recipients)
(goto-char (point-min))
(let ((out ""))
(while (not (eolp))
(let ((ov (seq-find (lambda (o) (overlay-get o 'display))
(overlays-at (point)))))
(if ov
(progn (setq out (concat out (substring-no-properties
(overlay-get ov 'display))))
(goto-char (overlay-end ov)))
(setq out (concat out (string (char-after))))
(forward-char 1))))
out)))
(ert-deftest bhw/config-test--mu4e-view-elides-recipients ()
"Long To:/Cc: lists collapse behind a marker; short ones are left alone.
Address separators are found structurally, so commas inside quoted display
names and RFC 5322 comments do not count, and folded headers are unfolded
first so the survivors stay on one screen line."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/mu4e-view-elide-recipients))
(let ((bhw/mu4e-view-recipients-shown 2))
;; short enough already
(should (equal (bhw/config-tests--elided-view "To: a@x.com, b@x.com")
"To: a@x.com, b@x.com"))
;; long list
(should (equal (bhw/config-tests--elided-view
"To: a@x.com, b@x.com, c@x.com, d@x.com, e@x.com")
"To: a@x.com, b@x.com … +3 more"))
;; commas inside quotes and comments are not separators
(should (equal (bhw/config-tests--elided-view
"To: \"Doe, Jane\" <j@x.com>, a@x.com (Ann, boss), b@x.com, c@x.com")
"To: \"Doe, Jane\" <j@x.com>, a@x.com (Ann, boss) … +2 more"))
;; folded header is unfolded, then elided
(should (equal (bhw/config-tests--elided-view
"To: a@x.com,\n\tb@x.com,\n\tc@x.com,\n\td@x.com")
"To: a@x.com, b@x.com … +2 more"))))
(ert-deftest bhw/config-test--mu4e-view-recipients-toggle ()
"`bhw/mu4e-view-toggle-recipients' round-trips the elision overlays."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/mu4e-view-toggle-recipients))
(with-temp-buffer
(let ((bhw/mu4e-view-recipients-shown 2))
(insert "To: a@x.com, b@x.com, c@x.com, d@x.com\n\nbody\n")
(bhw/mu4e-view-elide-recipients)
(let ((ov (car (overlays-in (point-min) (point-max)))))
(should ov)
(should (overlay-get ov 'display))
(should (overlay-get ov 'help-echo))
(bhw/mu4e-view-toggle-recipients)
(should-not (overlay-get ov 'display))
(bhw/mu4e-view-toggle-recipients)
(should (overlay-get ov 'display))))))
(ert-deftest bhw/config-test--mu4e-view-quit-returns-to-headers ()
"Quitting the mu4e view is remapped onto the headers-returning quit.
mu4e's own `mu4e-view-quit' only kills the view buffer in the
single-window case, leaving what comes next to window history."
:tags '(bhw-config)
(skip-unless (and (boundp 'mu4e-view-mode-map)
(fboundp 'bhw/mu4e-view-quit-to-headers)))
(should (eq (lookup-key mu4e-view-mode-map [remap mu4e-view-quit])
'bhw/mu4e-view-quit-to-headers)))
(ert-deftest bhw/config-test--tangled-files-keep-cookie-on-line-1 ()
"Every tangled elisp file carries its file-variable cookie on line 1.
Emacs reads `lexical-binding' from the first line only, so org's
`:comments link' tangle link above it loads the file with dynamic
binding: closures stop closing and timers die with `void-variable'.
Each target keeps its cookie in its own `:comments no' block."
:tags '(bhw-config)
(dolist (spec '(("config.el" . "lexical-binding: t")
("init.el" . "lexical-binding: t")
("tests/config-tests.el" . "lexical-binding: t")
("private-packages/ement-config.el" . "lexical-binding: t")
("private-packages/org-clock-analyzer.el" . "lexical-binding: t")
("tests/org-clock-analyzer-tests.el" . "lexical-binding: t")
("packages.el" . "no-byte-compile: t")))
(let ((file (expand-file-name (car spec) doom-user-dir)))
(when (file-exists-p file)
(with-temp-buffer
(insert-file-contents file nil 0 500)
(let ((line1 (buffer-substring (point-min) (line-end-position))))
(should (string-match-p "-\\*-.*-\\*-" line1))
(should (string-match-p (regexp-quote (cdr spec)) line1))))))))
(ert-deftest bhw/config-test--config-loaded-lexically ()
"config.el really was loaded lexically, not merely marked as such.
`bhw/config--lexical-probe' hands back a closure; under dynamic binding
calling it signals `void-variable' instead of returning t."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/config--lexical-probe))
(should (funcall (bhw/config--lexical-probe))))
The analyzer’s own suite lives beside the package (both hand-written); loading it
here puts it under the same tag, so doom/reload runs it too.
(ert-deftest bhw/config-test--org-clock-analyzer-wired ()
"org-clock-analyzer is autoloaded, configured and reachable from the clock menu."
:tags '(bhw-config)
(should (file-exists-p (expand-file-name "private-packages/org-clock-analyzer.el"
doom-user-dir)))
(should (fboundp 'org-clock-analyzer))
(should (fboundp 'org-dblock-write:clock-analyzer))
(should (string-suffix-p "archived-tasks/*.org" (car (last org-clock-analyzer-files))))
(skip-unless (and (require 'transient nil t) (fboundp 'bhw/org-clock-menu)))
(should (string-match-p "org-clock-analyzer"
(format "%S" (get 'bhw/org-clock-menu 'transient--layout)))))
;; Package-level tests for the hand-written analyzer live beside it; load them so
;; `doom/reload' runs them under the same bhw-config tag (reload time, never startup).
(let ((file (expand-file-name "tests/org-clock-analyzer-tests.el" doom-user-dir)))
(when (file-exists-p file)
(condition-case err
(load file nil 'nomessage)
(error (message "bhw/config-tests: could not load %s: %S" file err)))))
Habits are a three-part arrangement that is easy to break one piece at a time:
org-habit has to be loaded at all (Doom leaves org-modules empty), the
today-only rule is what keeps habits out of both Calendar blocks, and the
conflict checker has to override that same rule or it can never see a future
occurrence. The last assertion runs the real detector against a scratch habit
a week out, so it fails if any of the three regresses.
(ert-deftest bhw/config-test--org-habits-wired ()
"Habits load, stay out of Calendar, and still register as conflicts.
`org-agenda-get-scheduled' hides habits on every day but today while
`org-habit-show-habits-only-for-today' is on -- that is what excludes
them from the Calendar blocks, and it is also what would blind
org-conflict to a future habit occurrence without the :around advice."
:tags '(bhw-config)
(skip-unless (require 'org-habit nil t))
(should (featurep 'org-habit))
(should org-habit-show-habits)
(should org-habit-show-habits-only-for-today)
;; Every block headed "Calendar" suppresses habits explicitly.
(let ((calendars 0))
(dolist (command org-agenda-custom-commands)
(when (consp (nth 2 command))
(dolist (blk (nth 2 command))
(let ((lprops (and (consp blk) (nth 2 blk))))
(when (equal (cadr (assq 'org-agenda-overriding-header lprops))
"Calendar\n")
(setq calendars (1+ calendars))
(should (assq 'org-habit-show-habits lprops))
(should-not (cadr (assq 'org-habit-show-habits lprops))))))))
(should (= calendars 2)))
;; And the conflict checker still sees a habit a week out, which it cannot
;; do unless `bhw/org-conflict-agenda-bindings-a' is active.
(skip-unless (fboundp 'org-conflict-conflict-p))
(should (advice-member-p #'bhw/org-conflict-agenda-bindings-a 'org-conflict-conflict-p))
(let* ((file (make-temp-file "bhw-habit" nil ".org"))
(day (time-add nil (days-to-time 7)))
(stamp (format-time-string "<%Y-%m-%d %a 06:00-06:30 +1d>" day))
(rest (nthcdr 3 (decode-time day)))
;; 06:10-06:40 that day, overlapping the habit's 06:00-06:30.
(start (append (list 0 10 6) rest))
(end (append (list 0 40 6) rest)))
(unwind-protect
(progn
(with-temp-file file
(insert "* TODO Habit probe\nSCHEDULED: " stamp
"\n:PROPERTIES:\n:STYLE: habit\n:END:\n"))
(let ((org-agenda-files (list file))
(org-agenda-sticky nil))
(should (org-conflict-conflict-p start end t nil))))
(when (get-file-buffer file) (kill-buffer (get-file-buffer file)))
(delete-file file))))
The agenda prefix is the one place where a one-column slip is invisible in the
source and glaring on screen. org-agenda-format-item pads the start time
to five columns and leaves the end time ragged, so 7:00-8:15 rendered a
column narrower than 17:30-18:30 and every row under it stepped left. Three
settings hold the cell steady — the %-11t width, the %6s leader width and
org-agenda-time-leading-zero — and losing any one of them reintroduces the
drift. A fourth way in is a leader too wide for the field: org’s stock
org-agenda-timerange-leaders writes =(1/99): = for a multi-day range, eight
columns into a six-column slot, and only that row steps right. This renders
all the shapes a real agenda row can take and asserts they agree on where the
todo keyword starts.
(ert-deftest bhw/config-test--agenda-prefix-is-fixed-width ()
"Every agenda row starts its todo keyword in the same column.
The time cell has three shapes (a range, a start with no end, and no
time at all) and the scheduled/deadline leader four widths; all seven
combinations must produce the same prefix width."
:tags '(bhw-config)
(skip-unless (require 'org-agenda nil t))
(should org-agenda-time-leading-zero)
(should (equal "%-11t %6s" (cdr (assq 'agenda org-agenda-prefix-format))))
;; The leaders have to fit the %6s field, or right-aligning them clips
;; nothing but does push the keyword column out for that row alone.
(dolist (leader (append org-agenda-deadline-leaders
org-agenda-scheduled-leaders))
(should (<= (length (format leader 2)) 6)))
;; The multi-day-range leader lands in that same field and takes two
;; specifiers, day-of-range and range length. Org's default is wider than
;; the field at every value.
(should (equal "" (nth 0 org-agenda-timerange-leaders)))
(dolist (n '(1 9 10 99))
(should (<= (length (format (nth 1 org-agenda-timerange-leaders) n 99)) 6)))
(let ((org-agenda-remove-tags t)
;; `org-compile-prefix-format' stores its result in whatever
;; `org-agenda-buffer' names, which outside a real agenda run is a
;; buffer killed some time ago -- "Selecting deleted buffer". nil
;; sends it to the temp buffer below instead.
(org-agenda-buffer nil)
(org-prefix-format-compiled nil)
(columns '()))
(with-temp-buffer
(org-compile-prefix-format 'agenda)
(dolist (case `(("" "<2026-08-31 Mon 7:00-8:15>") ; single-digit hours
("" "<2026-08-31 Mon 17:30-18:30>") ; two-digit hours
("" "<2026-08-31 Mon 9:00>") ; start, no end
("" nil) ; no time at all
("D: " nil) ("S 6: " nil) ("OD 2: " nil)
;; Day 1 and day 99 of a 99-day block.
(,(format (nth 1 org-agenda-timerange-leaders) 1 99)
"<2026-08-31 Mon 9:00-10:25>")
(,(format (nth 1 org-agenda-timerange-leaders) 99 99)
"<2026-08-31 Mon 9:00-10:25>")))
(let ((line (org-agenda-format-item
(nth 0 case) "TODO Probe" nil "cat" nil (nth 1 case))))
(push (cons (substring-no-properties line)
(string-match "TODO Probe" line))
columns))))
(should (= 1 (length (delete-dups (mapcar #'cdr columns)))))))
The day total appended to each date header is written by a
org-agenda-finalize-hook, and finalize is not once-per-buffer: the idle
prewarm build and a later org-agenda-redo on the same sticky buffer both run
it, which used to leave the header reading W36 (8:15) (8:15). Running the
hook twice over one buffer is the whole test.
(ert-deftest bhw/config-test--agenda-day-total-is-idempotent ()
"A second finalize pass does not append the day total twice."
:tags '(bhw-config)
(skip-unless (require 'org-agenda nil t))
(with-temp-buffer
(insert "Monday August 31 2026 W36\n")
(put-text-property (point-min) (1- (point-max))
'org-agenda-date-header t)
(goto-char (point-min))
(bhw/org-agenda-insert-efforts)
(let ((once (buffer-string)))
(goto-char (point-min))
(bhw/org-agenda-insert-efforts)
(should (equal once (buffer-string)))
;; And the total really was added on the first pass.
(should (string-match-p "W36 ([0-9]+:[0-9][0-9])\\'"
(string-trim once))))))
The Ement room picker offered ement--format-room output, which for a bridged
DM is the contact’s display name plus an opaque room ID — four rooms reading
John Doe (<!...>): "" with nothing to choose between them. Three
things fix that, and each can regress on its own: the network label, the
liveness filter that drops Google Voice’s per-call portals, and the completion
table that keeps candidates unique. Uniqueness is the one that fails
silently: ement-complete-room maps the chosen string back to a room with
string= (ement-lib.el:1112), so two rooms sharing a candidate string would
always open the first.
;; These build `ement-room' structs directly rather than requiring Ement: a
;; `require' here would load a heavy package on every reload, for tests that
;; only exercise our own pure functions. When Ement is not loaded they skip.
(defun bhw/config-tests--ement-room (&rest args)
"Return an `ement-room' for the picker tests.
ARGS is a plist of :name, :protocol, :network, :channel and :messages."
(let ((bridge (make-ement-event
:type "m.bridge"
:content `((protocol (id . ,(plist-get args :protocol)))
(network (displayname . ,(plist-get args :network)))
(channel (id . ,(plist-get args :channel)))))))
(make-ement-room
:id "!test:example.org"
:display-name (plist-get args :name)
:latest-ts 0
:state (list bridge)
:timeline (make-list (or (plist-get args :messages) 0)
(make-ement-event :type "m.room.message"
:content '((body . "hi")))))))
(ert-deftest bhw/config-test--ement-room-label-names-the-network ()
"A bridged room is labelled with its network, and never doubly so."
:tags '(bhw-config)
(skip-unless (featurep 'ement))
;; Google Voice reports no network name, so the short tag is used.
(should (equal "Jane Doe (GV)"
(bhw/ement-room-label
(bhw/config-tests--ement-room :name "Jane Doe"
:protocol "gvoice" :channel "t.+15551234"))))
;; Slack names its workspace, which beats the protocol.
(should (equal "Jane Doe (Acme Workspace)"
(bhw/ement-room-label
(bhw/config-tests--ement-room :name "Jane Doe (Acme Workspace)"
:protocol "slackgo" :network "Acme Workspace"
:channel "T1-D1"))))
;; A Slack group DM repeats the workspace after every member; collapse it.
(should (equal "John Doe, Rita (Acme Workspace)"
(bhw/ement-room-label
(bhw/config-tests--ement-room
:name "John Doe (Acme Workspace), Rita (Acme Workspace)"
:protocol "slackgo" :network "Acme Workspace" :channel "T1-C1"))))
;; A room named after its own workspace must not become "X (X)".
(should (equal "Book Club"
(bhw/ement-room-label
(bhw/config-tests--ement-room :name "Book Club"
:protocol "whatsapp" :network "Book Club"
:channel "1@g.us"))))
;; An unbridged Matrix room keeps its name untouched.
(should (equal "#emacs" (bhw/ement-room-label
(make-ement-room :id "!n:example.org" :display-name "#emacs")))))
(ert-deftest bhw/config-test--ement-room-live-p-drops-call-portals ()
"Empty Google Voice per-call portals are hidden; real conversations are not."
:tags '(bhw-config)
(skip-unless (featurep 'ement))
;; mautrix-gvoice opens a `c.'-keyed portal per call, named after the contact.
(should-not (bhw/ement-room-live-p
(bhw/config-tests--ement-room :name "John Doe"
:protocol "gvoice" :channel "c.ABCDEF")))
;; The `t.'-keyed thread carries the actual messages.
(should (bhw/ement-room-live-p
(bhw/config-tests--ement-room :name "John Doe"
:protocol "gvoice" :channel "t.+15550143"
:messages 3)))
;; A call portal that did accumulate messages is kept.
(should (bhw/ement-room-live-p
(bhw/config-tests--ement-room :name "Richard Roe"
:protocol "gvoice" :channel "c.ABCDEF" :messages 1)))
;; Other protocols are never filtered on channel prefix.
(should (bhw/ement-room-live-p
(bhw/config-tests--ement-room :name "Sam (WA)"
:protocol "whatsapp" :channel "c.15550143@s.whatsapp.net"))))
(ert-deftest bhw/config-test--ement-picker-advice-is-idempotent ()
"Reloading does not stack the picker advice, and non-completion callers are spared."
:tags '(bhw-config)
(skip-unless (featurep 'ement))
(should (advice-member-p #'bhw/ement--format-room-a 'ement--format-room))
(should (advice-member-p #'bhw/ement-complete-room-a 'ement-complete-room))
(let ((n 0))
(advice-mapc (lambda (f _props)
(when (eq f #'bhw/ement--format-room-a) (cl-incf n)))
'ement--format-room)
(should (= 1 n)))
;; Only the two completion call sites pass the symbol `topic'; every other
;; caller formats prompts and messages and must keep upstream's form.
(let ((room (make-ement-room :id "!p:example.org" :display-name "Room")))
(should (string-match-p "!p:example.org" (ement--format-room room)))
(should (equal "Room" (ement--format-room room 'topic)))))
(ert-deftest bhw/config-test--blog-feed-url-matches-publish-directory ()
"The Atom feed's base URL points at where the pages are actually served.
project-isidore maps ^/assets/ onto its own source root, so the published
blog lives under /assets/blog/. The feed used to be built against
https://bhw.name/, which 404'd every entry link and the feed's self URL."
:tags '(bhw-config)
(require 'url-parse)
(let ((url-path (url-filename (url-generic-parse-url bhw/blog-base-url)))
(dir (file-name-as-directory
(expand-file-name bhw/blog-publish-directory))))
(should (string-suffix-p "/" url-path))
(should (string-suffix-p url-path dir))))
(ert-deftest bhw/config-test--blog-feed-lists-only-live-posts ()
"Only pages with a surviving Org source become feed entries.
The sitemap, hand-placed files, lockfiles, and HTML orphaned by a renamed
or deleted post would otherwise stay in the feed pointing at dead URLs."
:tags '(bhw-config)
(let* ((root (make-temp-file "bhw-blog-test" t))
(src (file-name-as-directory (expand-file-name "src" root)))
(out (file-name-as-directory (expand-file-name "out" root))))
(unwind-protect
(progn
(make-directory src)
(make-directory out)
(dolist (f '("post.org" "archive.org"))
(write-region "" nil (expand-file-name f src)))
(dolist (f '("post.html" "archive.html" "orphan.html" ".#post.html"
"LICENSE"))
(write-region "" nil (expand-file-name f out)))
(let ((bhw/blog-source-directory src)
(bhw/blog-publish-directory out))
(should (equal '("post.html") (bhw/blog-post-html-files)))))
(delete-directory root t))))
(ert-deftest bhw/config-test--blog-publish-entry-point-is-top-level ()
"`bhw/publish-blog' is reachable without ox-publish being loaded first.
It used to be defined inside the `after! ox-publish' body, so a fresh
session had no such command until something else pulled ox-publish in."
:tags '(bhw-config)
(should (commandp #'bhw/publish-blog))
(with-temp-buffer
(insert-file-contents (expand-file-name "config.el" doom-user-dir))
(goto-char (point-min))
;; Column 0, i.e. not nested inside the `after!' form.
(should (re-search-forward "^(defun bhw/publish-blog ()" nil t))))
(ert-deftest bhw/config-test--tangle-touches-unchanged-targets ()
"`org-babel-tangle' leaves every target's mtime fresh, even when it skips it.
Without this, an edit that produces byte-identical tangle output makes
`bhw/config-test--tangle-fresh' fail forever."
:tags '(bhw-config)
(should (advice-member-p #'bhw/touch-tangled-files 'org-babel-tangle))
(let ((n 0))
(advice-mapc (lambda (f _props)
(when (eq f #'bhw/touch-tangled-files) (cl-incf n)))
'org-babel-tangle)
(should (= 1 n)))
;; The advice is a `:filter-return', so it must hand the list back intact.
(let ((file (make-temp-file "bhw-touch")))
(unwind-protect
(progn
(set-file-times file 0)
(should (equal (list file) (bhw/touch-tangled-files (list file))))
(should (> (float-time (file-attribute-modification-time
(file-attributes file)))
0)))
(delete-file file))))
(ert-deftest bhw/config-test--headline-ids-are-timestamps ()
"Headings get a timestamp `ID'; an existing CUSTOM_ID is left alone.
ox-html honours CUSTOM_ID unconditionally, so the older slug anchors must
survive -- bhw.name links to two of them -- while the ID gives every other
heading an anchor that outlives a heading rename and a publish-cache wipe."
:tags '(bhw-config)
(require 'org-id)
(let ((org-id-method 'ts)
(org-id-prefix nil)
;; Neither the id database nor recentf should learn about a buffer
;; that exists only for this test.
(org-id-track-globally nil)
(org-id-overriding-file-name "/nonexistent/bhw-config-test.org"))
(with-temp-buffer
(insert "* Alpha\n* Beta\n:PROPERTIES:\n:CUSTOM_ID: Beta-slug\n:END:\n")
(delay-mode-hooks (org-mode))
(bhw/ensure-headline-ids)
(let ((ids (org-map-entries (lambda () (org-entry-get nil "ID"))))
(customs (org-map-entries (lambda () (org-entry-get nil "CUSTOM_ID")))))
(should (= 2 (length ids)))
(should (cl-every (lambda (id)
(string-match-p "\\`[0-9]\\{8\\}T[0-9]\\{6\\}\\.[0-9]+\\'" id))
ids))
(should (equal ids (delete-dups (copy-sequence ids))))
(should (equal '(nil "Beta-slug") customs))))))
(ert-deftest bhw/config-test--headline-ids-skip-fileless-buffers ()
"Stamping is a no-op without a file, rather than erroring out of the export.
`org-id-add-location' signals in a buffer visiting no file, and this runs as
`:before' advice on the export functions."
:tags '(bhw-config)
(require 'org-id)
(let ((org-id-overriding-file-name nil))
(with-temp-buffer
(insert "* Alpha\n")
(delay-mode-hooks (org-mode))
(should-not (bhw/ensure-headline-ids))
;; One heading, and it came back without an ID -- `org-map-entries'
;; returns one element per entry, so the list itself is never nil.
(should (equal '(nil) (org-map-entries (lambda () (org-entry-get nil "ID"))))))))
(ert-deftest bhw/config-test--blog-publish-stamps-ids-first ()
"The blog project stamps ids via `:preparation-function', not export advice.
`org-html-publish-to-html' runs before `org-publish-org-to' visits the file,
so `:before' advice there would walk whatever buffer was current."
:tags '(bhw-config)
(skip-unless (boundp 'org-publish-project-alist))
(should (eq 'bhw/blog-stamp-headline-ids
(plist-get (cdr (assoc "blog" org-publish-project-alist))
:preparation-function)))
(should (bound-and-true-p org-html-prefer-user-labels)))
(ert-deftest bhw/config-test--blog-sitemap-formats-directory-entries ()
"Tree-style sitemaps feed directories through `:sitemap-format-entry' too.
A directory has no title and no post date, so formatting it like a post
produced a bogus bold entry instead of the sub-directory's name."
:tags '(bhw-config)
(skip-unless (fboundp 'bhw/org-sitemap-date-entry-format))
(let ((project (assoc "blog" org-publish-project-alist)))
(should (equal "notes" (bhw/org-sitemap-date-entry-format "notes/" 'tree project)))
(should (equal "notes/" (bhw/org-sitemap-date-entry-format "notes/" 'list project)))))
The startup-deferral check cannot run at reload time — by then everything is
loaded, so featurep tells you nothing about the startup path. This script
boots a throwaway Doom daemon (exactly how the real systemd daemon starts) and
reports what got loaded eagerly. Run it by hand after changing package
deferrals: bash ~/.config/doom/tests/startup-deferral-check.sh.
# Boot a throwaway Doom daemon and report what got loaded eagerly.
# Not part of the on-reload ERT suite; run manually after deferral changes.
set -euo pipefail
SOCK=doom-startup-test
emacsclient -s "$SOCK" --eval '(kill-emacs)' >/dev/null 2>&1 || true
emacs --fg-daemon="$SOCK" >/dev/null 2>&1 &
EMACS_PID=$!
for _ in $(seq 1 120); do
if emacsclient -s "$SOCK" --eval 't' >/dev/null 2>&1; then break; fi
sleep 1
done
echo "emacs-init-time: $(emacsclient -s "$SOCK" --eval '(emacs-init-time)')"
echo "eagerly loaded features (target: nil):"
emacsclient -s "$SOCK" --eval "(seq-filter #'featurep '(org org-agenda mu4e consult-mu ement elfeed calc lexic biome transmission claude-code-ide org-node))"
emacsclient -s "$SOCK" --eval '(kill-emacs)' >/dev/null 2>&1 || kill "$EMACS_PID" >/dev/null 2>&1 || true
1.5. Emacs Navigation & Searching
Search is divided into two categories: File name search, and File content search.
It is highly recommended to swap ESC and Caps Lock. It is also highly recommended to swap ctrl and alt for ergonomic reasons as well. Search for a “space cadet keyboard” and you will see that the “Emacs pinky” is not intended. One is to use the strongest digit of your finger, the thumb, to activate most key chords, regardless if you use Evil Vim Emulation or not. C-g will also act as ESC in situations where fd or ESC fail you.
SPC SPC acts as M-x
1.5.1. Searching - File Content
- Scope: Computer (All Files) using fd-find. Note org-recoll as an option to search all file contents, such as all PDF’s in Project Jerome.
Scope: Project (projectile.el defines this as any directory you have defined as the “root directory” by placing a .projectile file in said directory) Thanks to: the silver searcher (ag) as it allows as to fuzzy search.
-G*.cljs -w time - search for the word “time” in all .cljs files
-tclojure time - search for “time” in all .{clj,cljs,cljc} files
-uno\ due\ tre - search for the string “uno duo tre”
-C5 foo - search for “foo” but show 5 lines of context before and after the match.
-(?:^|[^\w-])time(?:[^\w-]|$) - search for lisp-word “time”, i.e. search for the full word “time” while considering “-” to be a word characer
- Search for
Key Effect SPC / Also performs search project, same as SPC s p SPC * Performs SPC / command with symbol under cursor SPC p t Opens up treemacs sidebar in project view g d With cursor in function, search that function SPC SPC custom project maria consult Scope: File/Buffer Thanks to: Helm-occur
Key Effect SPC s c Clears the red underlining of search matched text ? Helm-occur with symbol under cursor - Jumps within visible file/buffer or visible buffer search (buffer navigation)
My scheme for navigating the visible buffer:
- For short hops within a line or a couple of lines in a buffer I
- Jumps within visible file/buffer or visible buffer search (buffer navigation)
My scheme for navigating the visible buffer:
/t/F/T. ct) etc are goldies. Remember s and p stand for sentence and
paragraph respectively when used in context with a vim operator. Ex. d a s
translates to “delete around a sentence”.
- For medium hops across all visible frames, windows and buffers, I
use
avy-goto-word-or-subword-1on s, since s in vim is basically useless (see r and c). These are fully compatible with vim commands. So e.g. I used s <two char sequence>all the time for quickly deleting blocks of text. - Hoping past what I can see, but still within the file, helm-swoop simply
outclasses /. I almost never use /, so rebind it to
helm-swoopin your evil config.
avy-goto-char-timer > type more than one letter. timer can be shortened or lengthened.
| Key | Effect |
|---|---|
| SPC j j | Avy-timer > Jump to specific letter |
| SPC j w | Avy-word > Jump to specific word |
| SPC j b | Go to previous jump location |
| g ; | Go to last edit |
| ctrl-o | jump back |
1.5.2. Searching - File Name
<- Navigation - Buffer & Window Manipulation
We only need one command for this: helm-for-files. Use C-h f to find
out docstrings, but basically it runs through our buffer list (what is a
buffer?), then our recentf list, then our bookmarks, our file caches, then
files in current dir before resorting to helm-fd.
See Unix locate (implemented as mlocate, plocate etc) and Unix find
(implemented as GNU find and fd)
This command will allow you to find files ANYWHERE. As long as you know the pathname, of course. A primer on fd.
1.5.3. Searching - Directory
| Key | Effect |
|---|---|
| SPC f d | Call helm-find-files with a prefix |
| SPC f b | Access bookmarks list |
1.5.4. Searching - World Wide Web
Google alternatives:
Wikipedia https://hn.algolia.com/ Appending reddit to search term https://search.marginalia.nu/ Duck Duck go, Bing etc.
Web Scraping
1.5.5. Searching - Handling Directories with Dired
| Key | Effect |
|---|---|
| ( | Toggles simple view |
| 0w | (dired-copy-filename-as-kill &optional ARG |
| S | Toggle sort by A-Z and last edited |
Explanation of w command: Copy names of marked (or next ARG) files into the kill ring. The names are separated by a space. With a zero prefix arg, use the absolute file name of each marked file.
1.5.6. Navigation - Project Manipulation
What is a project? Spacemacs uses projectile.el (pre-installed) to manage projects.
- For those with coding backgrounds, any directory with a git repo will automatically be considered a project.
- Absent a .git folder, a .projectile file will mark it as a project to projectile.
| Key | Effect |
|---|---|
| SPC SPC then type * | *=projectile-add-known-project |
| SPC p p | Switch Projects |
Stale (deleted) projects are pruned from the switch prompt automatically
via projectile-auto-cleanup-known-projects; see Projectile Config.
1.5.7. Search and Replace
- The rx Structured Regexp Notation
- Emacs Regexp Primer
- Vim search tips
- nfdn: Bulk Search & Replace Commands for Files and Buffers in Emacs
How do I search and replace a section of the buffer?
Turn on and use line numbers, SPC t n a.
:5,12s/foo/bar/g
How do I yank all matching lines into one buffer?
:g/^{pattern}/yank A
This runs the global command to yank any line that matches ^{pattern} and put it in register a. Because a is uppercase, instead of just setting the register to the value, it will append to it. Since the global command run the command against all matching lines, as a result you will get all lines appended to each other.
What this means is that you probably want to reset the register to an empty string before starting: :let @a=“”.
And naturally, you can use the same with any named register. How can I execute vim commands on matching patterns?
:g/{pattern}/normal nd$
Explanation: On each line, where pattern matches, execute the following normal mode commands nd$. With the cursor at the start of the line, ’n’ jumps to the pattern, and ’d$’ deletes to the end of the line.
How to execute a vim commands on every single line?
:%norm fED
This tells vim to press fED on each line as if you had typed it in normal mode. fE will move the cursor forward to the first E, and D deletes everything until the end of the line.
How to capture all subsequent text on a line after a pattern match
\(.*\)
captures all subsequent text on the line.
Incrementally replace a given string pic1, pic1, pic1 > pic1, pic2, pic3 etc.
C-vand highlight 1- M-x cua-rectangle-mark-mode
M-n and set appropriate values
See the following if the above does not work EmacsWiki: Numbers In Registers.
How do I remove all blank lines from a buffer?
This is a frequent question so I figured I’d mention the solution here:
You want to remove all empty (blank) lines from a buffer. How do you do it? Well, it’s super easy.
Mark what you want to change (or use C-x h to mark the whole buffer) and run this:
M-x flush-lines RET ^$ RET
And you’re done. So what does that mean? Well, M-x flush-lines will flush (remove) lines that match a regular expression, and ^$ contain the meta-characters ^ for beginning of string and $ for end of string. Ergo, if the two meta-characters are next to eachother, it must be a blank line.
We can also generalize it further and remove lines that may have whitespace (only!) characters:
M-x flush-lines RET ^\s-*$ RET
In this case \s- is the syntax class (type C-h s to see your buffer’s syntax table) for whitespace characters. The * meta-character, in case you are not a regexp person, means zero or more of the preceding character.
Source: https://emacs.stackexchange.com/questions/48526/how-do-i-delete-all-blank-lines-in-a-buffer
How do I match any candidate within a range of characters
[0-9] or [A-Z] or [a-z]
How to I reuse the matched pattern?
\0 or \1
https://unix.stackexchange.com/questions/35206/replace-using-vim-reuse-part-of-the-search-pattern
How do I insert a newline?
\n
:%s/Ver. [0-9]../\0\n/g
How do I remove page break or form feed characters?
First be sure to in fundamental-mode to properly see the ^L or ^M chars. In
EmacsLisp, the form-feed character is written `?\f’, and `\f’ represents it in
strings. (`\f’ is also used in C.) M-: (replace-string “\f” “”)
How do I match a pattern only at the beginning of a line?
Use the ^ character.
Regular Expressions
The pattern that matches substrings in parentheses having no other ( and ) characters in between (like (xyz 123) in Text (abc(xyz 123)) is
\([^()]*\) %s/<chapter[^<>]*>//g Details:
\( - an opening round bracket (note that in POSIX BRE, ( should be used, see sed example below) [^()]* - zero or more (due to the * Kleene star quantifier) characters other than those defined in the negated character class/POSIX bracket expression, that is, any chars other than ( and ) \) - a closing round bracket (no escaping in POSIX BRE allowed)
1.5.8. Navigation - Buffer & Window Manipulation
- Buffers = instances of files, SPC b. All buffers have ONE major mode. Can have multiple minor modes.
- Windows = Display area that a buffer is shown, SPC w
- Frame = what is conventionally understood as a window. Has the minimize, maximize and close in top right/left.
| Key | Effect |
|---|---|
| SPC # | Switch to Window number, hjkl directional keys work too. |
| SPC tab | Switch between previous buffer, current buffer in window |
| SPC b b | Access recent files, open buffers and bookmarks in one place |
| SPC f b | Access just bookmarks > Useful for bookmarking directories |
| SPC w / | Split window vertically |
| SPC w - | Split window horizontally |
| SPC t g | Enable Golden Ratios |
| C-f | Page down |
| C-b | Page up |
Major / Minor Mode
| Key | Effect |
|---|---|
| SPC m | Major mode leader. Access to all bindings specific to major mode |
| , | Major mode leader. Access to all bindings specific to major mode |
Minor mode can be reached with SPC t. See spacemacs documentation.
1.5.9. Navigation - Marks and Registers
| Key | Effect |
|---|---|
| C-SPC | Set-mark-command = different from evil-set-marker (m) which is for buffer scope |
| SPC r m | helm-all-mark-rings = access earlier marks set by the previous command |
Above is the emacs way, utilizing registers. For vim(evil)-style marking (primarily used within a buffer-wide scope)
| Key | Effect |
|---|---|
| m a | drop a mark named a, m b to drop a mark named b etc. |
| ’ a | goto line mark named a |
| ` a | goto exact pointer mark named a |
The above marks and registers offer a greater degree of manual control. If
you would like something similar to a back button in a web browser, please
M-x helm-for-files “evil-jumps.el” under the evil package.
Convenient functions also from the evil package.
| Key | Effect |
|---|---|
| g ; | goto-last-change |
| g i | evil-insert-resume |
1.6. Text Manipulation
1.6.1. Vim Style Shortcuts
- Jim Dennis on understanding Vi
- Jared Carroll’s definitive guide to Vim Text Objects
- Ismail Badawi - The Compositional Nature of Vim
- Helpful Image
- Vi in the context of EVIL (Emacs emulation of vim)
Find and replace whole words in vim You can use \< to match the beginning of a word and \> to match the end:
%s/\<word\>/newword/g
How to include forward slash in vi search & replace? Here are two ways:
escape the / which is the default substitute separator: :s/usrbin/\/usr\/bin use another substitute separator, e.g., using the hash # character: :s#usrbin#/usr/bin. Note that there are characters that you can’t use as a separator: ", \, |
1.6.2. Emacs Style Shortcuts
Emacs 12.5 Documentation on Rectangles has useful information
| Key | Effect |
|---|---|
| C-x h | mark-whole-buffer |
| C-x r t | Replace rectangle contents with string on each line (string-rectangle) |
| M-x “undo” | In addition to SPC a u for the tree, this command can be used for a specific region |
| C-Backspace | Calls backward-kill-sexp which kill previous symbolic expression |
| delete-whitespace-rectangle | Mark region, then call this command. |
| C-M-\ | Indent-region |
| C-q C-l | Insert linebreak |
Emacs Macro
The Emacs way to define macros is available behind the prefix: SPC K
To start recording a keyboard macro:
Press SPC K k (uppercase then lowercase) to start recording a macro. Perform the actions that should be recorded. Press SPC K K (uppercase twice) to stop recording the macro.
To execute the last recorded macro press: SPC K K The macro can be executed again by pressing K one or more times. The single character replay works until another key than K is pressed.
1.6.3. Evil Multiple Cursors
grm - evil-mc-make-all-cursors gru - evil-mc-undo-all-cursors grs - evil-mc-pause-cursors grr - evil-mc-resume-cursors grf - evil-mc-make-and-goto-first-cursor grl - evil-mc-make-and-goto-last-cursor grh - evil-mc-make-cursor-here grj - evil-mc-make-cursor-move-next-line grk - evil-mc-make-cursor-move-prev-line M-n - evil-mc-make-and-goto-next-cursor grN - evil-mc-skip-and-goto-next-cursor M-p - evil-mc-make-and-goto-prev-cursor grP - evil-mc-skip-and-goto-prev-cursor C-n - evil-mc-make-and-goto-next-match grn - evil-mc-skip-and-goto-next-match C-t - evil-mc-skip-and-goto-next-match C-p - evil-mc-make-and-goto-prev-match grp - evil-mc-skip-and-goto-prev-match
1.6.4. Spell Checking
In addition to the layer README, know that your personal Dictionary is located at: ~/.aspell.en.pws
| Key | Effect |
|---|---|
| C-; | flyspell-auto-correct-previous-word, repeat for next suggestion |
Note, flyspell-auto-correct-previous-word only corrects what’s visible on the screen (Thank God).
1.7. Layouts and Workspaces
Refer to official spacemacs documentation on the same wording.
1.8. Help & Errors & Troubleshooting
1.8.1. General Help & Documentation
When Emacs freezes for some unknown reason, end the process with
pkill -9 emacs27
When updating org, or any large and complex packages, if you run into bugs it is
good to delete all .elc files. This is because any changes to macros require the
recompilation of elisp files. Delete all .elc files under ~/.emacs.d and then
run M-x spacemacs/recompile-elpa.
cd ~/.emacs.d && find . -name '*.elc' -print0 | xargs -0 -r rm -rf
Install Emacs from source
I wrote the initial tutorial for Windows 10, for Emacs was also my introduction into the world of UNIX and Lisp. That, combined with the desire to remind my future forgetful self was the reason for the surface-level depth and hand holding steps. Now if you count yourself comfortable in such an environment, I can think of a few good reasons to install Emacs from source at this point in time.
- When Emacs 29 is configured with the flag
--with-native-compilationEmacs Lisp bytecode is translated to C and then machine code, yielding performance benefits. - WSLg uses Wayland and Emacs 29.0.50 has the pure GTK feature instead of relying on the older X window system. This leads to a smoother user experience on multiple, high DPI monitors.
- Debian does not build emacs with the flag
--with-xwidgetsfor security reasons. With this feature enabled, Emacs is able to embed a Webkit browser widget inside a buffer. Compared to Emacs EWW which has HTML and limited CSS support, xwidget-webkit offers an experience closer to a conventional web browser. - Emacs 29 introduces tree-sitter support, a powerful parsing library that enhances its understanding of source code. With this integration, Emacs gains features like precise syntax highlighting, accurate indentation and easier extensibility.
Currently Debian bookworm packages Emacs 28.2. And for the record, I find such attention to stability perfectly reasonable. I am not familiar with the typical C build process, so Mr. Batsov’s advice helped. Onto the build process,
sudo apt update
# Install build dependencies ./configure will tell you if a build dependency is missing.
sudo apt install git build-essential libgtk-3-dev libgnutls28-dev \
libtiff5-dev libgif-dev libjpeg-dev libpng-dev libxpm-dev \
libncurses-dev texinfo autoconf libxml2-dev libwebp-dev \
librsvg2-dev libsqlite3-dev liblcms2-dev libgpm-dev libotf-dev \
libacl1-dev libjansson4 libjansson-dev libgccjit-12 libgccjit-12-dev \
gcc-12 g++-12 libtree-sitter-dev
export CC=/usr/bin/gcc-12 CXX=/usr/bin/gcc-12
sudo git clone git://git.sv.gnu.org/emacs.git /usr/local/src/emacs
cd /usr/local/src/emacs
sudo ./autogen.sh
# See ./configure --help for more options. "C-h v"
# 'system-configuration-options' to see what your Emacs is built with.
sudo ./configure --with-mailutils --with-wide-int --with-pgtk \
--with-native-compilation=aot --with-json --with-tree-sitter
# gconf is deprecated in favour of gsettings.
# I have 4 threads.
sudo make -j4
# Default install in /usr/local/ and emacs-29.0.50 binary under
# /usr/local/bin.
sudo make install
# If you keep your Emacs source folder around, it will know how to uninstall
# or to git pull from master and rebuild.
sudo make uninstall
# sudo git clean -dfX in case of rebuild.
1.8.2. Specific Troubleshooting and Known Issues
Copy paste from WSL2 to Windows is broken?
Paste something from the kill ring via
SPC r y. Or restart your computer. Emacs on WSLg: Copying to the Windows Clipboard | Lukas Barth- Whichkey Lag Due to issue described here: justbur/emacs-which-key#226 Lots of time spent inside which-key–maybe-replace
How to use the universal argument in spacemacs, as C-u is evil-scroll-up?
SPC ualso seeevil-want-C-u-scrollvariable.- If you run into graphical issues with VcXsrv or X2Go try starting emacs in
emas -nwmode first, to generate .elc (compiled emacs lisp) files. - Should the numbering of windows be off after resuming from sleep, such that
when creating a new frame window one
SPC 1cannot be selected:SPC F Dshould delete the “hidden” frame.
1.8.3. Performance
Use the Emacs profiler SPC h T to track performance issues.
If poorly written Emacs lisp packages aren’t a problem, then look at garbage collection.
Afterwards, use SPC h p to browse installed packages and uninstall any
extraneous packages.
Lastly look at the enabled global minor modes and disable any that are unneeded.
WSL-specific:
If you feel input delay and general sluggishness, as in if you press the d
key once and multiple d’s are entered, then it could be likely that Windows
firewall scanning is causing the slowdown. Windows Security > Virus &
threat protection settings > Exclusions > Add or remove exclusions >
Add Folder \\wsl.localhost\Debian and Add Process msrdc.exe.