My org-babel based emacs configuration

Last update:

This is my emacs configuration file that is loaded with org-babel-load-file in the Emacs init file. The intent is to have as much of my Emacs configuration in here as possible. The system works as a literal programming system where with a tangle the elisp code that actually makes up my configuration is extracted automatically and loaded.

This document is irregularly published and lives in different locations:

Table of Contents

  1. Files for configuration
  2. Preparing for lift-off
  3. X Resources 1. Things that I'm sure of I want 2. Things I see potential for, but not sure of
  4. Package Management
  5. Personal information
    1. Authorization
    2. Encrypting information
  6. Global Generic settings
    1. Runtime state and cache locations
  7. Internationalization and multi-language features
  8. Visual
    1. Mode-line and status info
      1. Mode-line
      2. Status information in tab-bar
    2. Theme
    3. Miscellaneous other visual settings follow.
    4. Lines
    5. Client dependent settings
    6. Context dependent visualization
      1. Flycheck
      2. Math rendering
      3. Highlighting tags and keywords
  9. Buffers and files
  10. Modes
  11. Org-mode
    1. Initialization of Orgmode
    2. Capturing information
    3. Workflow
    4. Marking items as DONE
    5. Registering creation time of todo items
    6. Scheduling items
    7. Visual settings
    8. Agenda customization
      1. Calendaring
    9. Babel / Literate programming
    10. Refiling
    11. Exporting to other formats
    12. Journaling
    13. Publishing
      1. Writefreely
      2. Hugo
    14. Encrypting information in org-mode
    15. Dynamic behaviour
    16. Old configuration
  12. Markdown
    1. Claiming the Markdown file extensions
    2. Folding and startup visibility
    3. Keeping the rendered view across a revert
    4. The mode itself
  13. Key and mouse bindings
    1. First, unsetting the keys I don't want.
    2. Setting keys
    3. Key bindings
      1. Global
        1. What should have been in emacs
        2. Special keys
        3. Resizing and switching windows and frames
      2. For active regions
    4. Other key and mouse related settings
  14. Terminals, Character encodings and emulation
    1. Terminal
    2. Process handling
    3. Log files
  15. Completion
    1. Ad 1. Bind completion always involves TAB-key
    2. Ad 2. Completion should always produce something
    3. Ad 3. Inline completion when possible
    4. Ad 4. Mode specialization
    5. Ad 5. Open completion window predictably
    6. Ad 6. Guaranteed response-time
  16. Editing control
    1. Navigation
    2. Searching
    3. Outlining
  17. Remote editing
  18. Browser integration
  19. Messaging and chatting
    1. Mail
      1. Composing mail
        1. Attachments
      2. Sending mail
      3. Generic mail message settings
      4. RFC 3676 hardening
        1. Adaptive-fill guard (encode + decode)
        2. Per-line unstuff (RFC 3676 §4.4)
        3. Trailing-whitespace guard (compose → send)
      5. Attachment rendering
        1. Attachment summary in header
      6. Mu4e specific settings
        1. Main screen
        2. Header view
        3. Message view
        4. Addon packages
      7. Sieve
    2. Elfeed
    3. IRC
    4. Mastodon
  20. Development settings
    1. Generic
    2. Reference & documentation
      1. Context sensitive
      2. Reference information
      3. RFC document reader
      4. Man pages
      5. Epub documents
    3. Coding styles
    4. Language support
      1. Python
      2. Haskell
      3. Go
      4. Rust
      5. Lisp-like
        1. Emacs Lisp
        2. Scheme
        3. Common lisp
      6. Gcode
      7. Openscad
      8. SQL
    5. GIT integration
      1. Magit
      2. Agent Integration – Commit Helper
      3. Committing automatically
      4. Miscellaneous
        1. Online pastebin services
        2. Grepping files
    6. AItools
      1. Anvil suite
        1. Memory provider paths
        2. Indexing org files
        3. Titles from org keywords
        4. Memory types anvil does not know
        5. No TTL expiry
        6. Keeping the memory index live
        7. Fast mode and visited buffers
        8. Deferred session-end summarization
    7. Ansible
  21. Finale

Files for configuration

Next to this main configuration file (mrb.org), two other files are involved in the configuration:

  1. early-init.el emacs' early initialization file;

    ;;; early-init.el --- Early init -*- lexical-binding: t -*-
    ;; Redirect user-emacs-directory to this file's actual location so that
    ;; straight.el and other tools find repos/build under ~/.config/emacs/
    ;; rather than the legacy ~/.emacs.d/ default.
    (setq user-emacs-directory
          (file-name-directory (or load-file-name buffer-file-name)))
        
    ;; Disable package.el in favor of straight.el
    ;; This MUST be in early-init.el before Emacs tries to load package.el
    (setq package-enable-at-startup nil)
        
    (startup-redirect-eln-cache ".cache/eln/")
        
    (provide 'early-init)
    
  2. init.el the main init file, which is used to generate mrb.el from mrb.org.

    ;;;; Package --- Emacs initialisation of mrvdb -*- lexical-binding: t -*-
    ;;; Commentary:
    ;; Emacs initialisation starting point
    ;;
    ;; I want to have as little in here as possible.  The configuration is
    ;; org-babel based.  This means the bootstrap here is to load a proper
    ;; (part of) org-mode and be on our way.
        
    ;;; Code:
        
    (require 'cl-lib)                       ; for cl-remove-if
        
    ;; Set gc really large, during load, after that we are going to use gcmh
    ;; See: https://akrl.sdf.org/#orgc15a10d has the same as I had for years
    (setq gc-cons-threshold (* 16 1024 1024 1024))
        
    ;; HACK: Disable Org-mode that was shipped with Emacs and add one I control
    (setq load-path (cl-remove-if (lambda (x) (string-match-p "org$" x)) load-path))
    (add-to-list 'load-path (expand-file-name "straight/repos/org/lisp" user-emacs-directory))
        
    ;; My org file is posted using writefreely, which uses local variables
    ;; we need them before the call to org-babel
    (add-to-list 'safe-local-variable-values '(writefreely-post-id . "wf83bq5jwz"))
    (add-to-list 'safe-local-variable-values '(writefreely-post-token . nil))
        
    ;; config-file var gets used in mrb.el as well, not sure I like that
    (setq config-file (expand-file-name "mrb.org" user-emacs-directory))
        
    ;; Load personal configuration (gitignored, not published).
    ;; Copy personal.example.el to personal.el and fill in real values.
    ;; This needs to come before the org-babel section, as that uses the variables.
    (let ((personal-file (expand-file-name "personal.el" user-emacs-directory)))
      (if (file-exists-p personal-file)
          (load personal-file)
        (warn "personal.el not found — copy personal.example.el and fill in values")))
        
    ;; This produces mrb.el which is then loaded. It checks datetime before tangling.
    (org-babel-load-file config-file)
        
    ;; END init.el
        
    ;;; init.el ends here
    

Ideally these files should be produced by org-babel as well, just like the main configuration, but that generates a dependency because these two files need to be available from the beginning.

Small snippet to have last modified timestamp inserted when exporting this file (see line above which has the call to last_modified)

git log -1 --pretty="format:%ci" "$emacs_dir/mrb.org"

Preparing for lift-off

The first thing I want to take care of is to make customization possible and stored in a place to my liking. I want to load this first so anything in the configuration I define explicitly overrides it. The custom file is really only for safe-local-variables support; variables that get set automtically by reading a .dir-locals.el file for example. All actual persistent configuration goes in this file only.

;;; -*- lexical-binding: t -*-
;; Must be first: λ is used throughout all subsequent src blocks
(defmacro λ (&rest cdr) (cons 'lambda cdr))

(defun mrb/org-file (relative-path)
  "Expand RELATIVE-PATH relative to org-directory."
  (expand-file-name relative-path org-directory))

(defun mrb/emacs-file (relative-path)
  "Expand RELATIVE-PATH relative to user-emacs-directory."
  (expand-file-name relative-path user-emacs-directory))

(defun mrb/home-file (relative-path)
  "Expand RELATIVE-PATH relative to the user's home directory."
  (expand-file-name relative-path (getenv "HOME")))

(setq custom-file (mrb/emacs-file "custom.el"))
(load custom-file)

(defun mrb/clean-custom-file--read-safe-entry (custom-file-path)
  "Parse CUSTOM-FILE-PATH and return (PARSE-OK . ENTRY) for safe-local-variable-values."
  (with-temp-buffer
    (insert-file-contents custom-file-path)
    (goto-char (point-min))
    (let ((parse-ok t)
          forms)
      (condition-case err
          (while t (push (read (current-buffer)) forms))
        (end-of-file nil)
        (error
         (lwarn 'mrb :warning "custom.el parse error: %S - skipping cleanup" err)
         (setq parse-ok nil)))
      (cons parse-ok
            (when parse-ok
              (let* ((cvars-form (cl-find 'custom-set-variables forms :key #'car))
                     (entries (cdr cvars-form)))
                (cl-find 'safe-local-variable-values entries
                          :key (λ (e) (caadr e)))))))))

(defun mrb/clean-custom-file--write (custom-file-path safe-entry)
  "Write a clean CUSTOM-FILE-PATH with only SAFE-ENTRY, then clear in-memory saved state."
  (with-temp-file custom-file-path
    (insert ";;;; Custom file for safe-local-variable approvals only -*- lexical-binding: t -*-\n")
    (insert ";; Auto-generated variables removed by mrb/clean-custom-file.\n")
    (insert ";; All face and configuration customizations are in mrb.org.\n\n")
    (insert "(custom-set-variables\n ")
    (insert (if safe-entry
                (pp-to-string safe-entry)
              "'(safe-local-variable-values\n   '())"))
    (insert ")\n"))
  ;; Clear in-memory saved state so custom-save-all doesn't re-pollute custom.el on exit.
  (mapatoms
   (λ (sym)
     (unless (eq sym 'safe-local-variable-values)
       (when (get sym 'saved-value)      (put sym 'saved-value nil))
       (when (get sym 'customized-value)  (put sym 'customized-value nil))
       (when (get sym 'saved-face)        (put sym 'saved-face nil))
       (when (get sym 'customized-face)   (put sym 'customized-face nil))))))

(defun mrb/clean-custom-file (custom-file-path)
  "Clean CUSTOM-FILE-PATH, keeping only safe-local-variable-values."
  (when (file-exists-p custom-file-path)
    (let* ((result (mrb/clean-custom-file--read-safe-entry custom-file-path))
           (parse-ok (car result))
           (safe-entry (cdr result)))
      (when parse-ok
        (mrb/clean-custom-file--write custom-file-path safe-entry)))))

(add-hook 'after-init-hook (λ () (mrb/clean-custom-file custom-file)))

X Resources

X resource settings for Emacs are generated and loaded here. This keeps display configuration in sync with the elisp configuration (fonts, colors, widget styling).

! Emacs X Resources
! Generated from mrb.org - do not edit manually
! Reload with: xrdb -load ~/.Xdefaults

! Font configuration (synced with base16-adjustments default face)
! height 120 (12pt) at 120 DPI = pixelsize=20 (matches kitty font_size 12.0)
Emacs.font: Iosevka Mrb-12:pixelsize=20

! Menu bar and tool bar
Emacs.menuBar: off
Emacs.toolBar: off

! Menu and dialog styling (Nord theme colors)
Emacs*menu*.background: #3B4252
Emacs*menu*.foreground: #D8DEE9
Emacs*menu*.font: Noto Sans-12
Emacs.pane.menubar.background: #3B4252
Emacs.pane.menubar.foreground: #D8DEE9
Emacs.dialog*.background: #5E81AC
Emacs.dialog*.foreground: #D8DEE9

! Widget appearance
Emacs*shadowThickness: 1

After tangling, load the generated file:

(when (display-graphic-p)
  (call-process "xrdb" nil nil nil "-load"
                (mrb/home-file ".Xdefaults")))

The config file mrb.org gets opened a lot because I tend to fiddle with this config at least once a day. So, it warrants it's own keybinding, but it will have to wait until 13.3 before we can use the bind-key package.

(defun mrb/open-config ()
  "Open the main Emacs configuration file for editing."
  (interactive)
  (find-file config-file))

Also, it helps to have a config-reload option sometimes.

  (defun mrb/reload-config ()
    "Reload init file, clearing warnings buffer first so post-reload warnings are fresh.
If mrb.org is visited but unmodified and its on-disk copy is newer, revert
first so `org-babel-tangle-file' picks up external edits (e.g. from MCP
tools) instead of tangling stale buffer text. Global auto-revert usually
handles this, but the reload path shouldn't depend on file-notify timing."
    (interactive)
    (when (get-buffer "*Warnings*")
      (with-current-buffer "*Warnings*"
        (let ((inhibit-read-only t))
          (erase-buffer))))
    (let ((org-buf (find-buffer-visiting (mrb/emacs-file "mrb.org"))))
      (when (and org-buf
                 (not (buffer-modified-p org-buf))
                 (not (verify-visited-file-modtime org-buf)))
        (with-current-buffer org-buf (revert-buffer t t t))))
    (load-file (mrb/emacs-file "init.el")))

  (defun mrb/check-emacs-version-support (required-version message)
    "Warn if current Emacs version is below REQUIRED-VERSION (e.g., \"29.1\").
MESSAGE describes the unsupported feature. Supports formats: x, x.y, x.y.z.
REQUIRED-VERSION can be a string or integer; integers are converted to strings."
    (let ((version-str (if (stringp required-version)
                           required-version
                         (number-to-string required-version))))
      (when (version< emacs-version version-str)
        (warn "Emacs %s: %s (requires Emacs %s+)"
              emacs-version message version-str))))

Most of the time the editing process starts at the command line. Because I use emacs in server mode, this needs a little script. The script does two things:

  1. check if we already have the emacs server, if not, start it;
  2. treat input from stdin a little bit special.

While we are here, define a snippet which should go in shell scripts. We will repeat this per language at some point.

[ Org-mode generated this file from a code block, changes will be overwritten ]

And edit the file obviously.

# <<tangle-header>>
# Wrapper for emacs-client usage to handle specific cases:
# - no filename given
# - stdin editing
# - force tty
# - adjustment to systemd invocation

# Swap `:` for `echo` (or `set -x`) to enable debug tracing. Must be a
# zero-returning no-op when disabled — under `set -e`, a `false`-based
# body kills the script on the first `debug` call.
debug() { : "$@"; }

# Strict mode: fail loudly on unbound vars, command errors, broken pipes.
set -euo pipefail

ME=$(basename $0)
SNAME=server
SFILE=$XDG_RUNTIME_DIR/emacs/$SNAME

# Default argument needs our socket in any case
ARGS="--socket-name=$SNAME --suppress-output"

# Ensure availability of commands
ensure() {
    if ! command -v $1 >/dev/null 2>&1; then
        echo "ERROR: command '$1' is needed"
        exit 1
    fi
}

ensure notify-send
ensure systemctl

# Get full paths to emacs commands via command lookup. `command -v`
# exits non-zero (and `set -e` aborts the script) if emacsclient is
# missing — no separate `ensure` needed.
EC=$(command -v emacsclient)

# Do we already have an emacs?
# emacsclient with ALTERNATE_EDITOR="" can auto-spawn a daemon (and
# emacs 30+ correctly passes --daemon=$SFILE to it, so the socket path
# is fine). We still start the service ourselves because the auto-spawn
# is unsupervised: no systemd auto-restart, no journal logs, and it
# binds the same socket path systemd's ExecStart wants — causing
# "Emacs server already running" failures on the next supervised start.

# This goes along an emacs.service file obviously.

# Probe daemon liveness rather than just checking the socket file.
# `-S $SFILE` is true even for a stale socket left behind by a daemon
# that exited without unlinking (SIGKILL, segfault), which causes
# `emacsclient` to fail with "Connection refused" while the script
# skipped the service-start path. A live probe handles both cases.
if ! $EC --socket-name=$SNAME -e t >/dev/null 2>&1; then
    debug "Daemon not responding, starting Emacs service..."
    notify-send "Starting Emacs server, this might take a while...."
    systemctl --user reset-failed emacs 2>/dev/null  # clear failed state
    systemctl --user stop emacs 2>/dev/null          # evict zombie unit
    rm -f $SFILE                                     # clear stale socket
    systemctl --user start emacs
    # `systemctl start` returns at sd_notify(READY=1), which can predate
    # full init (mu4e, theme, native-comp). Wait until the daemon's main
    # loop actually services requests before proceeding.
    for _ in $(seq 1 100); do
        $EC --socket-name=$SNAME -e t >/dev/null 2>&1 && break
        sleep 0.1
    done
fi

# Final assertion: the daemon must be responsive now. Distinct from a
# mere `-S $SFILE` check — that would pass for a stale socket and let
# the script exec emacsclient straight into "Connection refused".
$EC --socket-name=$SNAME -e t >/dev/null 2>&1 \
    || { echo "ERROR: Emacs daemon not responding on $SFILE"; exit 1; }

# Handle special 'stdin' case: spool to a temp file and run emacsclient
# in WAIT mode so the file stays alive until Emacs has read it. The old
# recursion path passed --no-wait for "edit" and "edit-frame" flavors,
# so emacsclient returned immediately and the subsequent rm raced ahead
# of Emacs's visit — the file was unlinked mid-read.
if [ $# -ge 1 ] && [ "$1" = "-" ]; then
    TMPSTDIN=$(mktemp /tmp/emacsstdinXXX)
    cat > "$TMPSTDIN"
    shift
    STDIN_ARGS="--socket-name=$SNAME --suppress-output"
    case "$ME" in *frame*) STDIN_ARGS="$STDIN_ARGS --create-frame" ;; esac
    $EC $STDIN_ARGS "$TMPSTDIN" "$@"
    rm -f "$TMPSTDIN"
    exit 0
fi

## If we explicitly get asked to start terminal, do so. Iterate args
## explicitly — substring match on "$@" misfires on embedded tokens
## (e.g. an argument containing "-nw" would trigger tty mode).
force_tty=0
for a in "$@"; do
    case $a in -nw|-tty|-t|--no-window-system) force_tty=1; break ;; esac
done
if [ "$force_tty" = 1 ]; then
    # We were forcing terminal, skip the whole wait, frame, no-frame stuff
    debug "Forced tty startup"
    # FIXME this may create twice the -tty argument to start terminal version, but it's harmless
    ARGS=$ARGS' -tty'
else
    # We're basically expecting X windows, but if not, we good
    # force tty: call this like 'DISPLAY="" edit ....' see my aliases
    if [[ ${DISPLAY:-} ]]; then
        debug "X-display value seen, using GUI"
        case $ME in
            "edit")
                ARGS=$ARGS' --no-wait'
                ;;
            "edit-wait")
                ARGS=$ARGS' '
                ;;
            "edit-frame")
                ARGS=$ARGS' --no-wait --create-frame'
                ;;
            "edit-wait-frame")
                ARGS=$ARGS' --create-frame'
                ;;
            *)
                echo "Wrong calling name..."
                ;;
        esac
    else
        # tty always waits, as it is in the terminal, create-frame has no meaning
        debug "Starting tty emacs"
        ARGS=$ARGS' -tty'
    fi
fi

# Go with the original arguments
debug $EC ${ARGS} "$@"
exec $EC ${ARGS} "$@"

The above script is the result of trying to get the server based setup of Emacs a little better. The thoughts leading to the above script are included below.

Emacs can run as a server meaning that its process is always active and a specific client program called emacsclient can be used to connect to it to do the actual user interaction and let me edit and use the lisp interpreter.

It's far from trivial, for me at least, on how to properly configure this, assuming you have some special wishes. I've certainly spent my time on trying to get it right.

Things that I'm sure of I want

Here's what I know for sure on what I want:

  1. I want to have at least one server process for 'default' editing so emacs does not have to start a process for every file; the goal here is to have fast opening of files to edit. This is critical. I'm assuming that for this a server-like-setup is needed.
  2. Given that I tweak emacs a lot, it must be very easy to either disable the server temporarily or have it restart easily so I can test new changes without shutting me out of emacs completely.
  3. The previous item also implies that I want to run multiple independent emacs-en next to each other, regardless if they are run as a daemon or not.
  4. Emacs must work on a non X-windows screen, but extra effort to get that is OK.
  5. Be able to use '–' as placeholder for stdin to be able to pipe information directly into editor
  6. allow specific handlers, like mailto:

Things I see potential for, but not sure of

Having a server/servers opens a couple of options which I may like, but not sure on how to do or even if I understand them properly

Package Management

Package handling, do this early so emacs knows where to find things. Package handling basically means two things:

  1. getting and installing packages, by default handled by package.el;
  2. configuring packages, just elisp, but I use the use-package macro typically.

As I want to have version controlled package installation that is easily reproducible, straight.el seem a logical choice, so let's start by bootstrapping that.

(defvar bootstrap-version)
(setq straight-repository-branch "develop") ; Need this for new org-contrib location
(let ((bootstrap-file
       (mrb/emacs-file "straight/repos/straight.el/bootstrap.el"))
      (bootstrap-version 5))
  (unless (file-exists-p bootstrap-file)
    (with-current-buffer
        (url-retrieve-synchronously
         "https://raw.githubusercontent.com/raxod502/straight.el/develop/install.el"
         'silent 'inhibit-cookies)
      (goto-char (point-max))
      (eval-print-last-sexp)))
  (load bootstrap-file nil 'nomessage))

Now that we have straight we will use it from the use-package macro.

(setq use-package-always-ensure nil   ; Make sure this is nil, so we do not use package.el
      use-package-verbose nil         ; disabled to reduce startup/reload overhead
      ;; Default t (since use-package 29.1) parks :custom values in a sentinel
      ;; "use-package" theme that isn't in custom-enabled-themes, so a later
      ;; `defcustom' (e.g. mu4e loaded after init via mu4e-alert's after-init
      ;; hook) won't apply them. mrb/clean-custom-file already prevents the
      ;; custom-file pollution this flag was meant to avoid.
      use-package-use-theme nil)

;; Guarded named advice so use-package recognizes λ in :hook/:bind positions.
;; macroexpand-1 expands (λ ...) to (lambda ...) which use-package already handles.
(defun mrb/use-package-maybe-expand (v)
  "Expand V with macroexpand-1 unless it already starts with a known form."
  (if (and (consp v) (not (memq (car v) '(lambda quote function))))
      (macroexpand-1 v)
    v))

(defun mrb/use-package-recognize-filter (args)
  "Expand λ forms before use-package-recognize-function inspects them."
  (cons (mrb/use-package-maybe-expand (car args)) (cdr args)))

(defun mrb/use-package-normalize-filter (args)
  "Expand λ forms before use-package-normalize-function normalizes them."
  (list (mrb/use-package-maybe-expand (car args))))

(unless (advice-member-p #'mrb/use-package-recognize-filter 'use-package-recognize-function)
  (advice-add 'use-package-recognize-function :filter-args #'mrb/use-package-recognize-filter))
(unless (advice-member-p #'mrb/use-package-normalize-filter 'use-package-normalize-function)
  (advice-add 'use-package-normalize-function :filter-args #'mrb/use-package-normalize-filter))

;; Route :custom-face through `custom-set-faces' so specs land in the
;; `user' theme. The upstream handler emits `face-spec-set ... 'face-defface-spec',
;; the lowest-priority spec slot, which any loaded theme's theme-face entry
;; clobbers. Only relevant while `use-package-use-theme' is nil (we set it
;; that way to fix a `:custom' regression; see commit c6f3edd).
(defun mrb/use-package-handler-custom-face (name _keyword args rest state)
  "Replacement for `use-package-handler/:custom-face' that targets the user theme."
  (use-package-concat
   (list `(apply #'custom-set-faces
                 (list ,@(mapcar (lambda (def) `(backquote ,def)) args))))
   (use-package-process-keywords name rest state)))
(advice-add 'use-package-handler/:custom-face
            :override #'mrb/use-package-handler-custom-face)

;; From this point on we should be able to use `use-package
(use-package straight
  :custom
  ;; straight-host-usernames is set in personal.el
  (straight-use-package-by-default t))

;; FROM THIS POINT use-package should work as intended, i.e. using straight.

;; Need to install dependencies of use-package manually, why??
(use-package diminish)
(use-package buttercup)

Runtime performance analysis lives in its own file, lisp/mrb-perf-analysis.el, autoloaded on first use. It runs cold/warm benchmarks with IO and GC diagnostics to identify whether operations are IO-bound, GC-bound, or CPU-bound, and appends results to perf-analysis.org for cross-run comparison. The file only loads when one of its commands is first invoked via the keys bound below.

(use-package mrb-perf-analysis
  :straight (:type built-in)
  :load-path "lisp"
  :commands (mrb/perf-analyze mrb/perf-show-analysis mrb/perf-profile)
  :bind (("C-c P a" . mrb/perf-analyze)
         ("C-c P h" . mrb/perf-show-analysis)
         ("C-c P p" . mrb/perf-profile)))

I want to delegate garbage collection to someone who knows more than I do and made a package for it. The idea is still similar to what I had for years, i.e. large threshold on an idle-timer to do garbage collection. The package just adds some convenience, so I dont have to think about it other than making the threshold large on startup.

(use-package gcmh
  :custom
  (gcmh-verbose 1)
  (gcmh-high-cons-threshold (* 16 1024 1024 1024))
  :config
  (diminish 'gcmh-mode)
  (gcmh-mode 1))

As emacs lives inside the systemd environment it is not automatically the case that my shell environment is inherited. In many cases, this is not a problem, but for those cases that expect the same environment, I'm making explicit that the enviroment needs to be from the shell. We should do this a soon as use-package is useable.

(use-package exec-path-from-shell
  :demand t
  :custom
  (exec-path-from-shell-arguments '("-l"))  ; Login shell only (no interactive flag)
  :config
  (exec-path-from-shell-initialize))

This makes sure that things like TeX and git and other programs run in the same context as they would in my shell.

Personal information

I'd like to arrange 3 things related to personal information:

  1. register the proper identification to use;
  2. Make sure the proper authentication information is stored;
  3. Store this information privately.

So, identification, authorization and encryption.

Seems like a good idea to start configuration with some details about me. The idea being that anything personal goes here and nowhere else. For one, this means my name only appears in this section in this document. Most of the variables I just made up, but some are used in other places too.

Personal variables are loaded from personal.el (see personal.example.el for template).

Authorization

Several things I use within Emacs need authorization, such as tramp, jabber, erc etc. The authorization can come from several sources; ideally as few as possible. Many packages in Emacs have support for a .netrc like mechanism, others want to use the key ring in GNOME. The variables auth-sources defines the sources available.

I want to use systems which are desktop independent, so things like the gnome key ring are out because they depend on the gnome environment being present, which I can not guarantee, nor want to related to authentication. The situation which I want to prevent is that if gnome is broken, I can't authenticate to services I need.

I have a gpg-agent daemon configured which manages gpg and ssh keys, protected by a hardware key. Let's make the system as simple as we can for now and just store passwords in the gpg protected store only, i.e. the password-store program.

;; Use only password-store
(use-package password-store)
(use-package auth-source-pass
  :straight (:type built-in)
  :init
  (auth-source-pass-enable)
  :after password-store
  :custom
  ;; Make sure it's the only mechanism
  (auth-sources '(password-store))
  (auth-source-gpg-encrypt-to (list user-mail-address)))

;; I like the pass interface, so install that too
(use-package pass
  :after password-store)

Encrypting information

I need a way to store some sensitive information without that being published, should I decide some day to push this config somewhere.

When needed, the gpg key is used to encrypt information.

;; Use my email-address for encryption
(setq-default epa-file-encrypt-to user-mail-address)
;; Make sure we always use this
(setq-default epa-file-select-keys nil)

For org-mode, there is a way to encrypt sections separately. See 11.14 for the details on the settings for this.

Next to inline content in org that needs encryption, there is also content that needs encrypting which is more suitable to store in a separate file for several reasons.

Global Generic settings

Section contains global settings which are either generic or I haven't figured out how to classify it under a better section.

Let's begin with the setup of some fundamentals like if we want tabs or spaces, how to select stuff and what system facilities to use.

UI and display settings:

(setq frame-resize-pixelwise t)

Magit's status buffer wants the whole frame. display-buffer has no action function for that, so this one finds a window the ordinary way and then deletes its siblings.

(defun mrb/display-buffer-fullframe (buffer alist)
  "Display BUFFER alone on the frame."
  (when-let ((window (or (display-buffer-reuse-window buffer alist)
                         (display-buffer-same-window buffer alist)
                         (display-buffer-pop-up-window buffer alist)
                         (display-buffer-use-some-window buffer alist))))
    (delete-other-windows window)
    window))

Native compilation settings:

(setq package-native-compile t                                    ; compile packages natively
      native-comp-async-report-warnings-errors 'silent)           ; suppress native-comp warnings

I'm of the spaces-no-tabs religion:

(setq-default indent-tabs-mode nil)     ; do not insert tabs when indenting by default
(setq-default tab-width 4)              ; 4 spaces by default

I want to prevent producing files which have trailing spaces, because they serve no purpose at all. Removing these spaces is less trivial than it seems and I am not pretending to know what the best strategy is. The stripspace package seems to be exactly made for this.

(use-package stripspace
  :diminish stripspace-local-mode
  :hook ((prog-mode . stripspace-local-mode)
         (text-mode . stripspace-local-mode)
         (conf-mode . stripspace-local-mode))
  :custom
  ;; We do now want diffs that are to verbose on unclean files
  (stripspace-only-if-initially-clean t)
  ;; make sure we keep in the right position after stripping
  (stripspace-restore-column t))

;; FIXME these settings do not belong here
(setq
 delete-by-moving-to-trash t          ; move files to the trash instead of rm
 select-enable-clipboard t            ; use the clipboard in addition to kill-ring

 warning-minimum-level 'error
 large-file-warning-threshold nil
 next-line-add-newlines nil
 kill-whole-line t

 auto-window-vscroll nil

 compilation-scroll-output t
 custom-safe-themes t
 disabled-command-function #'ignore  ; don't prompt or write to init.el for disabled functions
 explicit-shell-file-name "/bin/bash"
 gud-gdb-command-name "gdb --annotate=1"
 reb-re-syntax 'string
 sgml-xml-mode t
 warning-suppress-types '((comp) (undo))
 x-select-enable-clipboard-manager nil)

;; Map backspace to DEL and delete to C-d
(when (display-graphic-p)
  (normal-erase-is-backspace-mode t))

;; Let marks be set when shift arrowing, everybody does this
(setq shift-select-mode t)
(delete-selection-mode 1)

;; Only require to type 'y' or 'n' instead of 'yes' or 'no' when prompted
(setq use-short-answers t)

;; Use auto revert mode globally
;; This is safe because emacs tracks if the file is saved in the editing buffer
;; and if so, it will not revert to the saved file.
(use-package autorevert
  :straight (:type built-in)
  :diminish auto-revert-mode

  :custom
  (auto-revert-interval 10) ; increased from 5 to reduce filesystem checks during typing
  (global-auto-revert-non-file-buffers t)  ; things like dired buffers

  :config
  (global-auto-revert-mode t)

  (defun mrb/org-restore-context-after-revert ()
    "Reveal org-mode heading context around point after buffer revert."
    (when (derived-mode-p 'org-mode)
      (org-fold-reveal)))

  (add-hook 'after-revert-hook #'mrb/org-restore-context-after-revert)

  (defun mrb/auto-revert-use-truename (orig-fn)
    "Resolve symlinks before adding file notification watch."
    (let ((buffer-file-name (when buffer-file-name
                              (file-truename buffer-file-name))))
      (funcall orig-fn)))

  (advice-add 'auto-revert-notify-add-watch :around #'mrb/auto-revert-use-truename))

;; Should this be here?
;; Try to have urls and mailto links clickable everywhere
(setq goto-address-url-mouse-face 'default)
(defun mrb/goto-address-enable ()
  "Enable goto-address-mode in the current buffer."
  (goto-address-mode 1))
(define-global-minor-mode global-goto-address-mode
  goto-address-mode mrb/goto-address-enable)
(global-goto-address-mode t)

;; Shorten urls for mode specific syntax
(defun mrb/shortenurl-at-point ()
  "Replace the URL at point with a shortened link in the current mode's syntax."
  (interactive)
  (let ((url (thing-at-point 'url))
        (bounds (bounds-of-thing-at-point 'url)))
    (kill-region (car bounds) (cdr bounds))

                                        ; Leave url as is, unless mode has specific link syntax
    (insert (format (cond ((eq major-mode 'org-mode)         "[[%1s][%2s]]")
                          ((eq major-mode 'md-mode) "[[%2s][%1s]]")
                          (t "%1s"))
                    url
                    (truncate-string-to-width url 40 nil nil "…")))))
(bind-key "C-c s" 'mrb/shortenurl-at-point)

Make life easier if we have sudo, so we can just edit the files and be done with them if possible

(use-package sudo-save)

Using pdf-tools for PDF handling. This is a lot better than docview. I'm a bit annoyed that they are not one package, they are very similar.

A PDF takes the whole frame, hung off mode activation rather than display-buffer-alist: emacsclient -n file.pdf reaches the buffer through server-switch-buffer, which calls switch-to-buffer and consults no display-buffer rules at all.

The mode hook runs before the buffer is displayed, so it cannot call delete-other-windows directly — under find-file-noselect that would collapse the frame around whatever buffer happened to be selected, leaving the PDF invisible. Deferring to the command loop lets the display settle first, and the window check makes it a no-op when the PDF ended up somewhere unshown.

The configuration is saved only when the frame was actually collapsed, so quitting a PDF that opened into an existing split leaves the other windows alone. It is stored in a weak-keyed table keyed on the buffer rather than a buffer-local: a revert re-runs the major mode, and kill-all-local-variables would discard it, while the weak key drops the entry when a PDF is closed some other way.

(defvar mrb/pdf-previous-window-configurations
  (make-hash-table :test #'eq :weakness 'key)
  "Window configuration to restore per PDF buffer, for buffers that took the frame.")

(defun mrb/pdf-view-fullframe ()
  "Give the current PDF the whole frame, once it is actually displayed."
  (let ((buffer (current-buffer))
        (configuration (current-window-configuration)))
    (run-at-time 0 nil
                 (lambda ()
                   (when-let ((window (get-buffer-window buffer)))
                     (unless (one-window-p 'nomini (window-frame window))
                       (puthash buffer configuration
                                mrb/pdf-previous-window-configurations)
                       (delete-other-windows window)))))))

(defun mrb/pdf-view-quit ()
  "Kill this PDF and restore the window configuration it replaced."
  (interactive)
  (let ((configuration (gethash (current-buffer)
                                mrb/pdf-previous-window-configurations)))
    (remhash (current-buffer) mrb/pdf-previous-window-configurations)
    (kill-current-buffer)
    (when configuration
      (set-window-configuration configuration))))

(use-package pdf-tools
  :demand t                             ; prevent configuring while opening pdf files
  :magic ("%PDF" . pdf-view-mode)
  :hook ((pdf-view-mode . (λ () (cua-mode 0)))
         (pdf-view-mode . mrb/pdf-view-fullframe))
  :commands (pdf-info-getannots mrb/pdf-extract-info)
  :bind ( :map pdf-view-mode-map
          ("h"   . 'pdf-annot-add-highlight-markup-annotation)
          ("t"   . 'pdf-annot-add-text-annotation)
          ("D"   . 'pdf-annot-delete)
          ("C-s" . 'isearch-forward)
          ("s-c". 'pdf-view-kill-ring-save)
          ("m"   . 'mrb/mailfile)
          ("q"   . 'mrb/pdf-view-quit)
          ([remap pdf-misc-print-document] . 'mrb/pdf-misc-print-pages)
          )

  :custom
  (pdf-annot-activate-created-annotations t) ; automatically annotate highlights
  (pdf-view-resize-factor 1.1)              ; more fine-grained zooming
  (pdf-view-midnight-colors '("#DCDCCC" . "#383838")) ; Not sure what this is
  (pdf-misc-print-program-executable "/usr/bin/lp")
  (pdf-view-display-size 'fit-page)        ; scale to fit page by default

  :config
  (add-to-list 'pdf-view-incompatible-modes 'display-line-numbers-mode) ; disable line numbers in PDF view
  (setq pdf-info-epdfinfo-program (mrb/emacs-file "straight/repos/pdf-tools/server/epdfinfo"))
  (pdf-tools-install) ; epdfinfo check and further init.
  (require 'pdf-annot)
  (define-key pdf-annot-edit-contents-minor-mode-map (kbd "<return>")   #'pdf-annot-edit-contents-commit)
  (define-key pdf-annot-edit-contents-minor-mode-map (kbd "<S-return>") #'newline)

  (add-to-list 'revert-without-query ".+\\.pdf") ; pdf is always a reflection of disk file

  (defun mrb/pdf-misc-print-pages(filename pages &optional interactive-p)
    "Wrapper for `pdf-misc-print-document` to add page selection support"
    (interactive (list (pdf-view-buffer-file-name)
                       (read-string "Page range (empty for all pages): "
                                    (number-to-string (pdf-view-current-page)))
                       t) pdf-view-mode)
    (let ((pdf-misc-print-program-args
           (if (not (string-blank-p pages))
               (cons (concat "-P " pages) pdf-misc-print-program-args)
             pdf-misc-print-program-args)))
      (pdf-misc-print-document filename))))

Integrate this with orgmode as well. org-pdftools provides a link type, so we can link into pdf documents. pdf-tools-org provides utility functions to help export pdf annotations into org mode and vice versa

(use-package org-pdftools               ; this brings in org-noter as well
  :after (org pdf-tools)
  :hook (org-mode . org-pdftools-setup-link))

(use-package pdf-tools-org
  :demand t  ; when should this be loaded? goes on pdftools load now
  :after (org pdf-tools)
  :straight (:host github :repo "machc/pdf-tools-org")
  )

I tend to compile Emacs with ImageMagick support, let's make sure we use it too.

(when (fboundp 'imagemagick-register-types)
  (imagemagick-register-types))

Quick install of package that exports qrencode-region function. I use this mainly to send links or package tracking codes to my mobile.

(use-package qrencode)

Runtime state and cache locations

Almost every package defaults its storage to user-emacs-directory, which fills the config root with runtime data. Two subtrees keep that separate: .state/ for anything whose loss costs information (bookmarks, IDs, history, credentials, accepted TLS exceptions) and .cache/ for anything regenerable. The built-in packages below have no other configuration, so their path settings are gathered here; packages that already have a block of their own set theirs there instead.

(use-package bookmark
  :straight (:type built-in)
  :defer t
  :custom
  (bookmark-default-file (mrb/emacs-file ".state/bookmarks")))

(use-package org-id
  :straight (:type built-in)
  :defer t
  :custom
  (org-id-locations-file (mrb/emacs-file ".state/org-id-locations")))

(use-package project
  :straight (:type built-in)
  :defer t
  :custom
  (project-list-file (mrb/emacs-file ".state/projects")))

(use-package ielm
  :straight (:type built-in)
  :defer t
  :custom
  (ielm-history-file-name (mrb/emacs-file ".state/ielm-history.eld")))

(use-package multisession
  :straight (:type built-in)
  :defer t
  :custom
  (multisession-directory (mrb/emacs-file ".state/multisession/")))

(use-package nsm
  :straight (:type built-in)
  :defer t
  :custom
  (nsm-settings-file (mrb/emacs-file ".state/network-security.data")))

(use-package url
  :straight (:type built-in)
  :defer t
  :custom
  (url-configuration-directory (mrb/emacs-file ".cache/url/")))

(use-package tramp
  :straight (:type built-in)
  :defer t
  :custom
  (tramp-persistency-file-name (mrb/emacs-file ".state/tramp"))
  (tramp-default-method "sshx")
  (tramp-syntax 'default)
  (tramp-terminal-type "dumb")
  (tramp-default-proxies-alist '(("^ha$" "^root$" "/sshx:ha:")))

  :config
  (add-to-list 'tramp-connection-properties
               (list ".*" "locale" "LC_ALL=C")))

(use-package persist
  :defer t
  :init
  (setq persist--directory-location (mrb/emacs-file ".state/persist")))

tramp and persist have to be set here rather than next to their users. tramp-cache.el reads tramp-persistency-file-name in a top-level form as it loads, and persist resolves its directory when a package calls persist-defvar at that package's load time — for activities, ement and mastodon-profile here. Both settings therefore have to be in place before those packages load. persist--directory-location is a plain defvar, so it needs setq in :init; :custom would create an orphan.

All of tramp's configuration lives in this one block; the remote-editing settings are explained under 17. docker loads tramp at startup (docker-utils requires it), so nothing about tramp can move later than this.

Internationalization and multi-language features

If anything multi-language should work, UTF-8 encoding is a must, so let's make sure we try to use that everywhere

(use-package mule
  :straight (:type built-in)
  :init
  (setq default-input-method 'TeX)
  (prefer-coding-system 'utf-8)
  :config
  (set-terminal-coding-system 'utf-8)
  (set-keyboard-coding-system 'utf-8))

For conveniently editing accented characters like 'é' and 'è' there are quite a few options to reach that result. I have dead characters configured as an option in the operating system, but that is far from ideal, especially when programming. As I hardly need those characters outside of emacs, i can leave that option as needed and optimize Emacs to my needs.

The fallback is C-x 8 C-h which gives specific shortcuts for special characters which are available. For the exotic characters that will do just fine. For the more common characters the C-x 8 prefix is to complex.

After evaluating some systems, the TeX input method suits me the best. I'd like to enable that globally by default, which needs two things:

  1. enable multi-language editing by default (input-method is only active in a multi-language environment)
  2. set the default input-method to tex

There is however a problem, the TeX input method assumes the first character / string produced will always be the choice I need, without allowing selecting an alternative. This turns out to be due to quail-define-package which determines the way the completions work. The problem is the DETERMINISTIC argument of the function, that is set to 't'. (8th argument). While I am at it, I also changed the FORGET-LAST-SELECTION (7th argument) to nil, so the last selection is remembered.

For this to work properly we have to define a whole new input-method based on a copy of latin-ltx.el

The personal rules below are added from every text-mode hook, so they pass no-decode-map. describe-char (via quail-find-key) replaces the TeX package's decode map, a list, with a char-table; the append path then signals (wrong-type-argument listp t) and aborts the rest of buffer setup. The decode map only serves to show which keys produce a character, and skipping it also stops it growing on each run.

;; No input method will be active by default, so for each mode where
;; it needs to be active we need to activate it (by a hook for example)
(defun mrb/set-default-input-method ()
  "Activate the TeX input method and register personal Quail rules."
  (interactive)
  (activate-input-method "TeX")

  ;; Define a few omissions which I use regularly
  (let ((quail-current-package (assoc "TeX" quail-package-alist)))
    (quail-define-rules ((append . t) (no-decode-map . t))
                        ("\\bitcoin" ?฿)
                        ("\\cmd" ?⌘)
                        ("\\shift" ?⇧)
                        ("\\alt" ?⎇)
                        ("\\option" ?⌥)
                        ("\\return" ?⏎)
                        ("\\tab" ?↹)
                        ("\\backspace" ?⌫)
                        ("\\delete" ?⌦)
                        ("\\plusminus" ?±)
                        ("\\_1" ?₁)
                        ("\\_2" ?₂)
                        ;; Greek shortcuts: \g + phonetic letter (a=alpha, b=beta, gg=gamma, ...)
                        ;; Note: \ge overrides \ge→≥ (use \geq); \gg overrides \gg→≫
                        ("\\ga" ?α) ("\\gA" ?Α)
                        ("\\gb" ?β) ("\\gB" ?Β)
                        ("\\gc" ?χ) ("\\gC" ?Χ)
                        ("\\gd" ?δ) ("\\gD" ?Δ)
                        ("\\ge" ?ε) ("\\gE" ?Ε)
                        ("\\gf" ?φ) ("\\gF" ?Φ)
                        ("\\gg" ?γ) ("\\gG" ?Γ)
                        ("\\gh" ?η) ("\\gH" ?Η)
                        ("\\gi" ?ι) ("\\gI" ?Ι)
                        ("\\gk" ?κ) ("\\gK" ?Κ)
                        ("\\gl" ?λ) ("\\gL" ?Λ)
                        ("\\gm" ?μ) ("\\gM" ?Μ)
                        ("\\gn" ?ν) ("\\gN" ?Ν)
                        ("\\go" ?ο) ("\\gO" ?Ο)
                        ("\\gp" ?π) ("\\gP" ?Π)
                        ("\\gq" ?θ) ("\\gQ" ?Θ)
                        ("\\gr" ?ρ) ("\\gR" ?Ρ)
                        ("\\gs" ?σ) ("\\gS" ?Σ)
                        ("\\gt" ?τ) ("\\gT" ?Τ)
                        ("\\gu" ?υ) ("\\gU" ?Υ)
                        ("\\gw" ?ω) ("\\gW" ?Ω)
                        ("\\gx" ?ξ) ("\\gX" ?Ξ)
                        ("\\gy" ?ψ) ("\\gY" ?Ψ)
                        ("\\gz" ?ζ) ("\\gZ" ?Ζ))
    ;; \ge and \gg already have TeX mappings (≥ and ≫); append=t unions
    ;; them into a candidate vector and DETERMINISTIC picks the old one first.
    ;; Force-replace with append=nil so our Greek rules win.
    (quail-defrule-internal "\\ge" ?ε (quail-map) nil)
    (quail-defrule-internal "\\gg" ?γ (quail-map) nil)))


;; Set the default language environment and make sure the default
;; input method is activated
(add-hook 'set-language-environment-hook 'mrb/set-default-input-method)
;; And now we can set it
(set-language-environment "UTF-8")
;; Make sure our timestamps within emacs are independent of system locale
;; This is mostly for orgmode I guess.
(setq system-time-locale "nl_NL.UTF-8")


;; Activate it for all text modes
(add-hook 'text-mode-hook 'mrb/set-default-input-method)

For even more esoteric characters we have to do some extra work. No font provides all Unicode characters. While I previously had a manual solution, I'm now relying on the package uni-code-fonts to do the proper mapping. The first run will be very slow, but the information gets cached. That caching goes through pcache, pulled in via ucs-utils, so its directory is pointed at .cache/ too — it is a plain defvar, hence setq in :init rather than :custom.

The problem with applying this function is that we need to be 'done' with our visual initialization or otherwise they'll do nothing (at least that I can see). So, let's group our corrections in a function and call that when we are done with our visual (X) init.

  (use-package pcache
    :init
    (setq pcache-directory (mrb/emacs-file ".cache/pcache/")))

  (use-package unicode-fonts
    :demand t
    )

  (defun mrb/unicode-font-corrections()
    "Set up Unicode font mappings with version-aware support."
    (interactive)
    (mrb/check-emacs-version-support 29 "unicode-fonts caching")
    (unicode-fonts-setup)
    ;; Add a line like the following for each char displaying improperly

    ;; mu4e uses Ⓟ,Ⓛ and Ⓣ, let's correct all letters
    (set-fontset-font t '(?Ⓐ . ?Ⓩ) "Symbola") ; noto line-height too high

    ;; Prefer scalable color emoji over Symbola/Noto Sans Symbols for
    ;; characters with an emoji presentation (✅ ⚠ ❌ etc.).  Requires a
    ;; COLR/COLRv1 font — the CBDT bitmap Noto Color Emoji has a single
    ;; 109 ppem strike and Emacs falls through to the next entry.  Install
    ;; noto-fonts-emoji-colrv1 (AUR) and a fontconfig rejectfont for
    ;; NotoColorEmoji.ttf so only the vector file is selected.
    ;; The :registry "iso10646-1" is required: entries added without it are
    ;; silently skipped during rendering, so Symbola wins the fallback race.
    ;; Target nil (current frame's fontset), not t (default fontset) — this
    ;; function runs per-frame via server-after-make-frame-hook, and changes
    ;; to fontset-default don't propagate to already-built frame fontsets.
    ;; Chars in the Misc Symbols / Dingbats blocks (⚠ ✔ ✂ ❤ …) live under
    ;; the `symbol' script in char-script-table.  Emacs then routes them
    ;; through the frame's primary font when it has any glyph for them,
    ;; bypassing our fontset override.  Reclassify to `emoji' the chars
    ;; that Noto Color Emoji actually covers, so the color entry wins
    ;; without disturbing arrows / dashes / punctuation.  Setting
    ;; `use-default-font-for-symbols' globally to nil would achieve the
    ;; same for emoji but forces → — · off Iosevka Mrb too.
    (when (member "Noto Color Emoji" (font-family-list))
      (let* ((emoji-entity
              (car (list-fonts (font-spec :family "Noto Color Emoji"
                                          :registry "iso10646-1")
                               (selected-frame) 1)))
             (emoji-obj (and emoji-entity
                             (open-font emoji-entity 20 (selected-frame)))))
        (when emoji-obj
          (dolist (range '((#x2600 . #x26FF)
                           (#x2700 . #x27BF)))
            (cl-loop for cp from (car range) to (cdr range)
                     when (and (eq (aref char-script-table cp) 'symbol)
                               (font-has-char-p emoji-obj cp))
                     do (aset char-script-table cp 'emoji))))))

    ;; Noto Color Emoji glyphs fill a full square em-square; at 1:1 with
    ;; Iosevka Mrb the emoji looks ~1.3× too tall/wide.  Size the emoji
    ;; font at ~80% of the *effective* primary size (frame font ×
    ;; text-scale factor) so proportions stay right at any zoom.
    (when (member "Noto Color Emoji" (font-family-list))
      (mrb/emoji-refresh-fontset)))

  (defvar mrb/emoji-scale 0.8
    "Emoji glyph size relative to the primary font size.")

  (defun mrb/emoji-effective-size (&optional frame)
    "Return the effective primary-font pixel size in FRAME, factoring
in the current buffer's `text-scale-mode-amount' if active."
    (let* ((base (font-get (face-attribute 'default :font frame) :size))
           (steps (if (boundp 'text-scale-mode-amount) text-scale-mode-amount 0))
           (step (if (boundp 'text-scale-mode-step) text-scale-mode-step 1.2)))
      (round (* base (expt step steps)))))

  (defvar mrb/emoji--applied-size nil
    "Last emoji pixel size applied to the fontset.
Used to skip redundant work in high-frequency hooks like
`window-state-change-functions'.")

  (defun mrb/emoji-refresh-fontset (&optional frame)
    "Re-apply Noto Color Emoji fontset entries at the current scale.
Called on frame creation, frame-font changes, text-scale changes,
and window state changes.  No-op if the target size has not changed."
    (when (and (display-graphic-p frame)
               (member "Noto Color Emoji" (font-family-list)))
      (let* ((effective (mrb/emoji-effective-size frame))
             (emoji-size (max 8 (round (* effective mrb/emoji-scale)))))
        (unless (eq emoji-size mrb/emoji--applied-size)
          (let ((emoji (font-spec :family "Noto Color Emoji"
                                  :registry "iso10646-1"
                                  :size emoji-size))
                (fs (face-attribute 'default :fontset frame)))
            (dolist (range '((#x2600 . #x26FF)   ; Misc Symbols (⚠ …)
                             (#x2700 . #x27BF)   ; Dingbats (✅ ❌ …)
                             (#x1F300 . #x1F5FF) ; Misc Symbols and Pictographs
                             (#x1F600 . #x1F64F) ; Emoticons
                             (#x1F680 . #x1F6FF) ; Transport and Map
                             (#x1F900 . #x1F9FF) ; Supplemental Symbols and Pictographs
                             (#x1FA70 . #x1FAFF))) ; Symbols and Pictographs Extended-A
              (set-fontset-font fs range nil)
              (set-fontset-font fs range emoji nil 'prepend))
            (setq mrb/emoji--applied-size emoji-size)
            (clear-face-cache t))))))

  (defun mrb/emoji-refresh-on-window-change (&rest _)
    "Wrapper that reapplies the emoji fontset for the selected frame.
Window/buffer hooks pass a window or frame argument we don't care
about — we always target the selected frame."
    (mrb/emoji-refresh-fontset (selected-frame)))

  (add-hook 'text-scale-mode-hook 'mrb/emoji-refresh-fontset)
  (add-hook 'after-setting-font-hook 'mrb/emoji-refresh-fontset)
  ;; text-scale is buffer-local while the fontset is frame-wide, so a
  ;; scale change in buffer A leaves emoji stuck at that size when the
  ;; user switches to buffer B with a different scale.  Re-run on any
  ;; window state change so the emoji size follows the visible buffer.
  (add-hook 'window-state-change-functions 'mrb/emoji-refresh-on-window-change)

So, when characters do not show properly, the steps to take now are:

  1. Find a font which has the char
  2. Map the character(-range) to that font
  3. Optional: define a convenient way to type the character

To make my life a bit easier, using the all the icons package for using special symbols.

(use-package all-the-icons
  :demand t)  ; Load unconditionally; package handles display type internally

The above in itself does nothing. It just makes using the icons easier and consistent. Other packages. With the command M-x all-the-icons-install-fonts the latest version of the proper fonts will be installed automaticaly, so run that once in a while.

Visual

Many settings have to do with how the screen looks and behaves in a visual way. Things like screen layout, colors, highlighting etc. fall into this category.

Let's set up some basics first, background dark, some default frame and cursor properties:

(setq mrb/cursor-type 'box)
(setq mrb/cursor-color "DarkOrange")

(setq-default frame-background-mode 'dark)

;; Only show cursor in the active window.
(setq-default cursor-in-non-selected-windows nil)

;;Default frame properties frame position, color, etc
(setq default-frame-alist
      `((cursor-type . ,mrb/cursor-type )
        (cursor-color . ,mrb/cursor-color)
        (internal-border-width . 24)
        (mouse-color . "white")))

Mode-line and status info

One thing that always bothered me is the repeated mode-line for every buffer with information that only needs to be displayed once. Like the new-mail indicator or time-display for example. This section explores a configuration to scratch that itch.

Local information, that is per buffer or per mode, should be displayed in the mode-line at the bottom of each visible buffer. There will be exceptions to this for special buffers where this does not make sense.

Global information, typically at least what global-mode-string holds, needs to be displayed only once (per frame). This will be handled by tab-bar-mode at the top of each frame, under the menu-bar.

Mode-line

The format of the mode-line I would like is inspired by nano modeline:

[ status name (primary) secondary ]

where:

Both primary and secondary are buffer and/or mode-specific Per mode configuration of the mode-line contents is possible..

(defun mrb/modeline-counters ()
  "Display page numbers for pdf-view-mode, line numbers for other modes."
  (if (eq major-mode 'pdf-view-mode)
      (format "[%d/%d]" (pdf-view-current-page) (pdf-cache-number-of-pages))
    (format "[%s/%d]" (format-mode-line "%l") (line-number-at-pos (point-max)))))

(defun mrb/modeline-right-align ()
  "Right-align modeline content with space to the right, accounting for counter width and total window width."
  (let* ((counter (mrb/modeline-counters))
         (counter-width (string-width counter))
         (total-width (window-total-width))
         (align-pos (- total-width counter-width 4)))
    (propertize " " 'display `(space :align-to ,align-pos))))

;; Mmodeline using only built-in Emacs.
(setq-default mode-line-format
              (list
               "%e" mode-line-front-space
               '(:propertize ("" mode-line-mule-info mode-line-client mode-line-modified mode-line-remote)
                             display (min-width (5.0)))
               mode-line-frame-identification
               mode-line-buffer-identification
               '(vc-mode vc-mode)
               "  "
               mode-line-modes
               '(:eval (mrb/modeline-right-align))
               '(:eval (mrb/modeline-counters))))

Status information in tab-bar

The format of the status-bar is similar to the mode-line:

[ primary secondary ]

where:

One of the elements in the status bar will be a list of buffers which need attention for some reason (irc, compilation etc.). For this the tracking package will be used, which is part of circe, but has its own install recipe. I only use it for irc right now.

(use-package tracking
  :custom
  (tracking-max-mode-line-entries 1)
  (tracking-shorten-buffer-names-p nil)

  :config
  (tracking-mode 1))

this fills information in the global-mode-string which is used in the tab-bar format later on.

In the tab-bar we will display messages, like in the echo area at the bottom. Messages in the bottom are sometimes a bit annoying, so we set up a filtering mechanism so we can leave out messages that are often in the way.

;; The order of this block is important
;; for handling messages, make sure inhibit message is inserted first
(add-to-list 'set-message-functions 'inhibit-message)

;; if that is the case, the inhibit-message regexps start working
;; Add all messages here that are annoying, they won't get into the message area then
(setq inhibit-message-regexps
      '("^\\(Wrote .+\\)$"
        "^\\(Saving file .+\\)$"
        "^\\(Saving all Org buffers... done\\)$"
        "^\\(Garbage collecting.+\\)$"
        "^\\(\\[mu4e\\] .+\\)$"
        "^\\(Some IDs share title, see M-x org-mem-list-title-collisions\\)$"
        ))

;; Everything is not a good idea, if there is a question
;; (setq inhibit-message-regexps
;;       '("^\\(.+\\)$"))

;; but handle the message separately for the tabbar
(setq mrb/tab-bar-message (current-message))
(add-to-list 'set-message-functions
             (λ (message) (setq mrb/tab-bar-message (or message ""))))

   ;; Tab bar configuration for global information display
   (use-package tab-bar
     :after base16-theme
     :custom
     (tab-bar-format '(tab-bar-format-menu-bar ; easy entry into menu when needed
                       tab-bar-format-history  ; only when history mode active, shows arrows then
                       mrb/tab-bar-format-primary
                       tab-bar-format-align-right
                       mrb/tab-bar-format-secondary))
     (tab-bar-position t "Position tab-bar under the menu-bar")
     (tab-bar-mode t "Enable tab-bar-mode unconditionally")
     (global-tab-line-mode nil "Make sure tab-line-mode is disabled")

     :custom-face
     ;; TODO tab-bar should ideally be (:inherit mode-line) but the theme's explicit
     ;;      :background definition prevents inheritance from overriding. Must use
     ;;      explicit colors here to override the theme's tab-bar definition.
     (tab-bar ((t (:background "#4c566a" :foreground "#eceff4" :height 1.2
                               :box (:line-width 1 :color "#5e81ac")))))

     :config
     (display-time-mode -1) ; make sure it is off, it will appear on right of tab-bar else

     (defun mrb/current-message-tab-bar()
       "Show the current message in the tab-bar, after massaging it a bit."
       (seq-reduce (λ (text subst)
                     (string-replace (car subst) (cdr subst) text))

                   ;; List of substitution string pairs
                   '(("\n" . "⮐") ; display return as character,prevent multiline somewhat
                     ("\"" . ""))  ; no need for the quotes here? I think.
                   (propertize mrb/tab-bar-message 'face '(warning))))

     (defun mrb/tab-bar-primary-available-chars (inner-width menu-px lead-px sec-px ch-px)
       "Compute character count available for the primary tab-bar message.
Pixel arguments: INNER-WIDTH is frame inner width, MENU-PX is the menu-bar
icon width, LEAD-PX is the leading space before the message, SEC-PX is the
secondary content width, CH-PX is the per-character advance width."
       (floor (/ (max 0 (- inner-width menu-px lead-px sec-px)) ch-px)))

     (defun mrb/tab-bar-format-primary ()
       "Format primary tab-bar element, truncating message to prevent tab-bar overflow."
       (let* ((secondary-str (concat
                              " "
                              (format-mode-line tracking-mode-line-buffers)
                              (format-mode-line global-mode-string)))
              (msg-raw (mrb/current-message-tab-bar))
              (available
               (if (and (fboundp 'string-pixel-width) (display-graphic-p))
                   ;; GUI: pixel-accurate calculation.
                   ;; The tab-bar face uses a larger font (height 1.2x).  The
                   ;; per-character advance width differs from the buffer font.
                   ;; Measure it as the difference between 2-char and 1-char
                   ;; pixel widths to avoid the first-character overhead.
                   (let* ((ch-px (max 1 (- (string-pixel-width (propertize "xx" 'face '(warning tab-bar)))
                                           (string-pixel-width (propertize "x"  'face '(warning tab-bar))))))
                          (lead-px (string-pixel-width (propertize " " 'face 'tab-bar)))
                          (sec-s (let ((s (copy-sequence secondary-str)))
                                   (add-face-text-property 0 (length s) 'tab-bar t s)
                                   s))
                          (sec-px (string-pixel-width sec-s))
                          (menu-str (let ((items (tab-bar-format-list (list 'tab-bar-format-menu-bar))))
                                      (when items (nth 2 (car items)))))
                          (menu-px (if menu-str
                                       (let ((s (copy-sequence menu-str)))
                                         (add-face-text-property 0 (length s) 'tab-bar t s)
                                         (string-pixel-width s))
                                     0)))
                     (mrb/tab-bar-primary-available-chars
                      (frame-inner-width) menu-px lead-px sec-px ch-px))
                 ;; TTY: character-based fallback
                 (max 0 (- (frame-width) 3 (string-width secondary-str)))))
              (msg (if (> available 0)
                       (truncate-string-to-width msg-raw available nil nil "…")
                     "…")))
         (concat " " msg)))

     (defun mrb/tab-bar-format-secondary ()
       (concat
        (propertize " " 'display `(raise 0.2))
        (format-mode-line tracking-mode-line-buffers) ; buffers that need attention
        (format-mode-line global-mode-string))))

Theme

I'm using the Nord color palette. This palette defines 16 colors.

There is a base16 theme which uses exactly these colors and there are varieties which use the 16 colors and some derivatives of it.

Although I think the 16 color system is a bit simplistic and, for emacs at least, I will need to customize more later on, the main lure of this system is that I can use the same set for many of my programs I use (emacs, xterm, i3, dunst etc.) which sounds attractive. So, I'm starting with the base16-nord theme and see where this leaves me.

  (use-package base16-theme
    :custom
    ;; Do config here and finally load the theme
    (base16-theme-distinct-fringe-background nil)
    (base16-theme-highlight-mode-line 'contrast)
    (base16-theme-256-color-source "colors") ; This gives way better colors with terminal emacs?

    :init
    ;; Break the gnus-group-{mail,news}-N ↔ -empty inheritance cycle that
    ;; upstream base16-theme would otherwise trigger against the Emacs
    ;; default face specs. Clearing the numbered face's :inherit under the
    ;; `user' theme first lets base16-nord's inverse spec apply cleanly.
    ;; Affects only gnus faces we don't use.
    (apply #'custom-set-faces
           (cl-loop for kind in '("mail" "news")
                    nconc (cl-loop for n from 1 to 6
                                   collect `(,(intern (format "gnus-group-%s-%d" kind n))
                                             ((t (:inherit unspecified)))))))
    (load-theme 'base16-nord t)

    :custom-face
    ;; Adjust only emacs core faces here, the rest should be close to their repective packages

    ;; Default font configuration
    (default ((t (:family "Iosevka Mrb" :slant normal :weight normal :height 120 :width normal))))

    ;; Keep `fixed-pitch' on the same family as `default'. Faces that measure
    ;; text in columns use it as their reference — md-mode sizes rendered
    ;; table columns with it — and a different family there yields widths
    ;; computed from the wrong character width.
    (fixed-pitch ((t (:family "Iosevka Mrb"))))

    (font-lock-comment-face ((t (:foreground "bisque3"))))
    (font-lock-doc-face ((t (:inherit font-lock-comment-face))))

    (button ((t (:foreground "#d08770" :weight semi-bold))))

    (show-paren-match ((t (:foreground "#eceff4" :background "#81a1c1" :inherit nil :weight normal))))
    (show-paren-mismatch ((t (:foreground "#2e3440" :background "#bf616a" :inherit nil :weight normal))))

    (region ((t (:foreground unspecified :background "#3b4252" :inherit nil :weight normal))))
    (fringe ((t (:foreground "#a3be8c"))))

    (hl-line ((t (:foreground unspecified :background "#5e81ac"))))

    (line-number-current-line ((t (:background "#4c566a"))))

    (cursor ((t (:background ,mrb/cursor-color))))
    (mouse ((t (:background "white"))))

    (header-line ((t (:foreground "#eceff4" :background "#4c566a"))))
    (mode-line ((t (:background "#4c566a" :foreground "#eceff4" :height 1.2
                                :box (:line-width 1 :color "#5e81ac")))))

    (mode-line-inactive ((t (:background "#2e3440" :foreground "#a3be8c" :height 1.2
                                         :box (:line-width 1 :color "#434c5e")))))

    (completions-annotations ((t (:inherit (italic font-lock-comment-face)))))

    :config
    ;; base16-nord sets `org-hide' to base02 (#4c566a) — visibly lighter than the
    ;; default background — so `org-hide-leading-stars' leaves faint dots instead
    ;; of empty space. Sync org-hide's foreground to `default' background after
    ;; any theme change so it always matches, regardless of which theme is active.
    (defun mrb/sync-org-hide-to-bg (&rest _)
      "Set `org-hide' foreground to `default' background on the selected frame.
Skips terminal frames where `default' background reports \"black\" — running
during daemon startup before any GUI frame exists would freeze that useless
value on org-hide. Runs again when a graphic frame first appears."
      (when (display-graphic-p)
        (let ((bg (face-attribute 'default :background)))
          (when (stringp bg)
            (set-face-attribute 'org-hide nil :foreground bg)))))
    (add-hook 'enable-theme-functions #'mrb/sync-org-hide-to-bg)
    (add-hook 'server-after-make-frame-hook #'mrb/sync-org-hide-to-bg)
    (mrb/sync-org-hide-to-bg))

Miscellaneous other visual settings follow.

;; no splash screen
(setq inhibit-startup-screen  t
      inhibit-startup-message t
      initial-scratch-message nil)

(use-package mic-paren
  :custom
  (paren-highlight-at-point nil)

  :config
  (paren-activate))

;; Font locking has always been the major performance hog for me, so here's the current
;; state of variables trying to minimize typing delays. The 3 position indication in
;; comments is the  experience playing with it:
;;   ? unknown effect
;;   ! needed can't change
;;   + minor effect, but noticeable
;;  ++ major effect, very noticeable
;; +++ very high effect on performance (mostly subjective, but supported by some light profiling)
(setq font-lock-support-mode 'jit-lock-mode)
(setq jit-lock-defer-time 0.25)   ; defer fontification for 250ms after typing stops
(setq jit-lock-stealth-time nil)  ; disable stealth fontification, use deferred only


;; Make underlining nicer
(setq  underline-minimum-offset 3)

;; Show color of '#RRGGBB texts
(use-package rainbow-mode
  :diminish
  :hook (conf-mode css-mode css-ts-mode)   ; css-ts-mode: the tree-sitter remap
  :custom
  ;; Colour R's colour names (white, tan, grey50, ...) in all buffers, not only ess-mode
  rainbow-r-colors t)

Lines

The most important element of an editor is probably how lines of text are displayed. This warrants its own section.

The problem is that 'line' needs clarification because there can be a difference between a logical line and a visual line. It's sometimes important that things stay on one logical line but are displayed over multiple lines. This may be different per mode.

I recently reversed my stance on this and am now enabling visual line mode everywhere (meaning one logical line can be smeared out over multiple display lines. This also enables word-wrap which breaks lines visually between words instead of at random characters.

To help me see the difference between visual lines and logical lines I let emacs display indicators in the fringe and define a function to help me unfill existing paragraphs.

;; Set some defaults
(setq-default word-wrap t               ; why would i want to have no wrapping by word?
              fill-column 100
              truncate-lines nil)

(setq sentence-end-double-space nil)    ; A period ends a sentence, period. relevant for fill

(use-package visual-fill-column
  :commands (turn-on-visual-fill-column-mode))

;; Similar to mail messages, use vertical bar for wrapped paragraphs
(setq visual-line-fringe-indicators
      '(vertical-bar nil))

;; For all text modes use visual-line-mode
(add-hook 'text-mode-hook 'visual-line-mode)

;; From:https://www.emacswiki.org/emacs/UnfillParagraph
(defun unfill-paragraph (&optional region)
  "Takes a multi-line paragraph and makes it into a single line of text."
  (interactive (progn (barf-if-buffer-read-only) '(t)))
  (let ((fill-column (point-max))
        ;; This would override `fill-column' if it's an integer.
        (emacs-lisp-docstring-fill-column t))
    (fill-paragraph nil region)))

;; Similar to M-q for fill, define M-Q for unfill
(bind-key "M-Q" 'unfill-paragraph)

Line-numbering is off by default, it tends to slow things down, but if I want it on, I almost always want them relative

(use-package display-line-numbers
  :straight (:type built-in)
  :config
  (setq-default display-line-numbers-mode nil)
  :custom
  (display-line-numbers-type 'relative))

Client dependent settings

Because most of my usage involves having a long lasting emacs daemon, some settings only come into scope once a client connects to that daemon. Most of these settings have to do with appearance and are related to having X available. Anyways, some settings need to be moved to the event when a client visits the server, so we can still apply these settings transparently.

Note that if this code is evaluated any call to emacsclient (be that from external or, more importantly Magit) will try to run this code and magit will fail if there's an error in the next section. Take extra care here.

;; Universal settings that work on all frame types (GUI and terminal)
(defun mrb/run-universal-settings ()
  "Apply settings that work on both GUI and terminal frames."
  (menu-bar-mode -1)   ;; No menu-bar (works in terminal too)
  (tab-bar-mode t))    ;; We *do* want the tab-bar

;; GUI-specific settings that require graphic display
(defun mrb/run-gui-settings ()
  "Apply settings that only work on GUI frames."
  (when (display-graphic-p)
    (tool-bar-mode -1)   ;; No tool-bar
    (scroll-bar-mode -1) ;; No scroll-bar
    (tooltip-mode -1)    ;; No tooltips
    (setq fringe-mode '(14 . 14)) ;; Fringe, left and right for the continuation characters
    (set-fringe-mode fringe-mode)
    (setq indicate-buffer-boundaries 'left)
    (mrb/unicode-font-corrections)))

;; Main dispatcher: runs appropriate settings based on frame type
(defun mrb/run-client-settings (&optional frame)
  "Run client settings for FRAME (or current frame if nil).
Applies universal settings to all frames, GUI settings only to graphic frames."
  (interactive)
  (with-selected-frame (or frame (selected-frame))
    (mrb/run-universal-settings)
    (mrb/run-gui-settings)))

;; Use appropriate hook based on startup mode
(if (daemonp)
    ;; Daemon mode: wait for frame to be created by server
    (add-hook 'server-after-make-frame-hook 'mrb/run-client-settings)
  ;; Non-daemon mode: apply settings to initial frame immediately
  (mrb/run-client-settings)
  ;; Also apply to any subsequently created frames
  (add-hook 'after-make-frame-functions 'mrb/run-client-settings))

Context dependent visualization

Next to the information registered in the theme which controls the overall look and feel, there is additional visualization which depend on context dependent factors, such as:

Flycheck

Flycheck is a syntax checking package which seems to have better support than flymake, which is built-in. I've no configuration for flymake specifically, but some packages enable it automatically (elpy for example). Where applicable, I gather the necessary disable actions here first.

Now we can start configuring flycheck

(use-package flycheck-pos-tip)

(use-package flycheck
  ;; Use popup to show flycheck message
  :after flycheck-pos-tip
  :config
  (with-eval-after-load 'flycheck
    (flycheck-pos-tip-mode)))

Currently actively configured are:

Flyspell is similar to flycheck but for text languages. I'm setting some of the flyspell faces to the flycheck faces so they are consistent

(use-package flyspell
  :straight (:type built-in)
  :custom-face
  (flyspell-incorrect ((t (:inherit flycheck-error))))
  (flyspell-duplicate ((t (:foreground "#2e3440" :inherit flycheck-warning))))
  :custom
  (flyspell-mark-duplications-flag nil "Flyspell Duplicates as warning")
  (flyspell-issue-message-flag nil)
  (ispell-alternate-dictionary "/usr/share/hunspell/nl_NL.dic")
  (ispell-use-framepop-p t))

(use-package guess-language
  :custom
  (guess-language-languages '(en de nl)))

Still on the wish-list:

Math rendering

Mathematics requires some specific rendering. In simple cases, using the proper unicode symbols may suffice, but for anything other than the most basis equations or formulas, some help is needed.  $\LaTeX$ is the defacto standard for this and there's plenty of support for that in emacs.

For $\TeX$ and $\LaTeX$ fragments I use the texfrag package. This allows to insert math fragments inside documents.

Example:

$$e^{ix} = cos x + i sinx$$

(use-package texfrag
  :disabled t
  :diminish

  :custom
  (texfrag-prefix "")

  :config
  (texfrag-global-mode 1))

Highlighting tags and keywords

In many modes, but especially in orgmode, the use of tags and keywords is abundant. In the set of packages that Nicolas P. Rougier has made for emacs, there's also a gem which generates SVG labels from keyword matches.

The use of that package is simple, just create a list of keyword matches in the svg-tag-tags variable and match those up with a command to create a tag.

I have at least the following usecases in mind:

  1. Render my orgmode keywords as tags;
  2. Use in mu4e to render the tags
  3. perhaps some TODO keywords in code, I use that quite a bit.

The first is the easiest, as the format of org-todo-keyword-faces is nigh perfect to transform into a list that can be used by svg-tag-mode.

;; Trickiest here is to know when org-todo-keyword-faces has been filled
(use-package svg-tag-mode
  :after (org org-journal base16-theme)

  :config
  ;; Only enable in GUI frames where SVG rendering works
  (defun mrb/maybe-enable-svg-tag-mode ()
    "Enable svg-tag-mode only in graphic displays."
    (when (display-graphic-p)
      (svg-tag-mode 1)))

  (add-hook 'org-mode-hook #'mrb/maybe-enable-svg-tag-mode)

  ;; Readymade svg-tag-tags generator from org keywords
  (defun mrb/gen-svg-tags (kf)
    "Generate a `svg-tag-tags element from a `org-todo-keyword-faces element"
    (let ((k (concat "\\(" (car kf) "\\) ")) ; pass keyword, not the space
          (f (cdr kf)))
      `(,k . ((lambda (tag) (svg-tag-make tag :face ',f :inverse t :margin 0 ))))))

  ;; This gets initialized the first time svg-tag-mode is called, so then it must have
  ;; the proper keywords in org-todo-keyword-faces
  (setq svg-tag-tags (mapcar 'mrb/gen-svg-tags org-todo-keyword-faces))

  ;; See: https://github.com/rougier/svg-tag-mode/issues/27
  ;; Org agenda does not use font-lock, so needs separate overlay to render
  (defun mrb/org-agenda-show-svg ()
    (let* ((case-fold-search nil)
           (keywords (mapcar #'svg-tag--build-keywords svg-tag--active-tags))
           (keyword (car keywords)))
      (while keyword
        (save-excursion
          (while (re-search-forward (nth 0 keyword) nil t)
            (overlay-put (make-overlay
                          (match-beginning 0) (match-end 0))
                         'display  (nth 3 (eval (nth 2 keyword)))) ))
        (pop keywords)
        (setq keyword (car keywords)))))
  (add-hook 'org-agenda-finalize-hook #'mrb/org-agenda-show-svg))

Buffers and files

How do I deal with all those buffers and files?

For starters, make sure that they have unique buffer names so I don't get confused:

(setq uniquify-buffer-name-style 'forward)

For every file-based buffer, I want auto-save to be on, but not in the same location as the file, as that clutters up everything. For that, I add to the list of file-name transforms to have (local) files auto saved in a designated folder)

(setq auto-save-default t
      mrb/auto-save-folder (mrb/emacs-file ".state/auto-save/")
      auto-save-list-file-prefix (concat mrb/auto-save-folder ".saves-"))

(add-to-list 'auto-save-file-name-transforms
             (list "\\(.+/\\)*\\(.*?\\)" (expand-file-name "\\2" mrb/auto-save-folder))
             t)

Auto save helps to automatically recover to the latest save point, but I need a bit more. This is done through versioned backup file. The recovery process is then manual though.

(setq create-lockfiles nil           ; just clutters for me, no real use?
      backup-by-copying t            ; otherwise symlinks galore

      backup-directory-alist '(
                               ("." . "/tmp/emacs/"))

      delete-old-versions t
      kept-new-versions 3
      kept-old-versions 2
      version-control t)

The auto save helps for the minor disasters, my backups help for the major disasters. What else is needed is a 'normal save' but automatically when I forget to do this.

What I am aiming for here is to not have to think about explicitly saving for certain files. Typically when typing stuff in org-mode I just want the stuff saved that I have types so far. For some files, each save is committed if I think the content warrants this (for example if I think going back to an earlier version is a likely event).

(defconst mrb/idle-timer 15
  "Time emacs needs to be idle to trigger the save idle timer")

;; This function does the actual useful thing
(defun mrb/save-timer-callback()
  "Callback function that runs when emacs is idle for `mrb/idle-timer' seconds."
  (org-save-all-org-buffers))

;; Activate the timer
;; The # means: evaluate first ( I keep forgetting this stuff )
(run-with-idle-timer mrb/idle-timer 'always #'mrb/save-timer-callback)

Also, save the location in files between sessions.

;; Minibuffer prompt is a prompt, don't enter it as text.
(setq minibuffer-prompt-properties '(read-only t point-entered minibuffer-avoid-prompt face minibuffer-prompt))
;; Don't echo keystrokes in the minibuffer, prefix will be hinted on after 2 seconds or so.
(setq echo-keystrokes 0)

;; Save places in buffers between sessions
(use-package saveplace
  :straight (:type built-in)
  :init
  (setq-default save-place-mode t))

Bind renaming the visiting file to a new location to C-x-w now that we have rename-visited-file

(bind-key "C-x C-w" 'rename-visited-file)
(bind-key "C-S-s" 'write-file)

For file management itself, dired is the defacto standard, but I do not use it much really. The casual package is helpfule showing the keyboard bindings.

(use-package dired
  :straight (:type built-in)
  ;; auto-revert global is only for file buffers, so explicit enable is needed
  :hook (dired-mode . auto-revert-mode)
  :bind (:map dired-mode-map ("C-o" . 'casual-dired-tmenu)))

Modes

Customization setting for specific modes. Most of the modes I use have a separate section, so this section is only for other modes.

To determine the default major mode; the mode that is started with before all the magic starts is determined by buffer-file-name. If we have it, the normal routine can be followed. If there is no filename yet, the buffer-name is used to determine which mode is needed.

By looking at the code this may have a side-effect, because the buffer-file-name is given a value. Let's try this and see if it gives any issues.

(setq-default major-mode
              (λ ()
                (if buffer-file-name
                    (fundamental-mode)
                  (let ((buffer-file-name (buffer-name)))
                    (set-auto-mode)))))

When emacs does not determine the right mode, I sometimes want a mode-string somewhere in the file. Typically that string gets inserted on a line which is commented out in the proper syntax for that mode.

(defun mrb/buffer-majormode (&optional buffer-or-name)
  "Returns the major-mode of the specified buffer, or
    the current buffer if no buffer is specified"
  (buffer-local-value 'major-mode
                      (if buffer-or-name
                          (get-buffer buffer-or-name)
                        (current-buffer))))

(defun mrb/insert-mode-string()
  "Inserts a mode string for the current mode at beginning of the current buffer"
  (interactive)
  (let ((m (symbol-name (mrb/buffer-majormode))))
    (save-excursion
      (goto-char (point-min))
      (insert "-*- mode:"
              (substring m 0 (string-match "-mode" m))
              " -*-")
      (comment-region (point-min) (point)))))

First bring in a bunch of modes that are used regularly, but have no other config than a map to some extensions. :requires is set to nil for those packages which aret built in.

(use-package adoc-mode             :mode ("\\.asciidoc\\'" "\\.adoc\\'"))
(use-package apache-mode           :mode "\\.htaccess\\'")
(use-package conf-mode
  :straight (:type built-in))
(use-package i3wm-config-mode)
(use-package css-mode
  :straight (:type built-in)
  :mode ("\\.css\\'" "\\.mss\\'"))
(use-package csv-mode              :mode "\\.csv\\'")

(use-package gnuplot)
(use-package gnuplot-mode          :mode "\\.gp\\'")
(use-package js2-mode              :mode "\\.js\\'")
(use-package lua-mode              :mode "\\.lua\\'")
(use-package php-mode              :mode "\\.php\\'")
(use-package sass-mode             :mode "\\.sass\\'")
(use-package udev-mode             :mode "\\.rules\\'")
(use-package dockerfile-mode       :mode "Dockerfile")
(use-package docker)

Now, do the other mode related packages which require a bit of configuration.


(use-package eimp
  :hook (image-mode . eimp-mode))

;; Open scratch buffer by default in the mode we are in at the moment
;; with C-u prefix a mode will be asked to use
(use-package scratch)
;; Don't throw away scratch buffer
(use-package persistent-scratch
  :custom
  (persistent-scratch-save-file (mrb/emacs-file ".state/persistent-scratch"))
  :config
  (persistent-scratch-setup-default))

(use-package nxml-mode
  :straight (:type built-in)
  :hook ((nxml-mode . (λ () (set-input-method nil)))
         (nxml-mode . turn-off-auto-fill))
  :custom
  (nxml-heading-element-name-regexp "\\|.*")
  (nxml-section-element-name-regexp "\\|file\\|.+"))

Org-mode

Orgmode configuration is probably the largest part of my Emacs configuration, because most of the time I spent in Emacs, when not coding, is spent in org-mode.

Initialization of Orgmode

I loosely follow the GTD method for organizing things and I want a quick shortcut to start my main file.

(defun mrb/gtd()
  "Start my GTD system"
  (interactive)
  (find-file org-default-notes-file))

We do not have to load the main orgmode location, because we already did that on the main initialization to get org-babel started.

Also make sure we never load org from internal, this can happen when functions were defined in the included org version and not anymore in newer versions. We want an error, not a silent load of the older function.

(require 'org-agenda)

(use-package org
  :after outline
  :straight (org
             :includes (org-agenda org-capture org-crypt org-datetree org-protocol))
  :hook ((org-mode . turn-on-visual-line-mode)
         (org-mode . turn-on-visual-fill-column-mode)
         (org-indent-mode . (λ () (diminish 'org-indent-mode)))
         (org-agenda-mode . (λ () (hl-line-mode 1))))

  :init
  <<orgmode-keywords>>
  <<gtd-starter>>

  :mode ("\\.txt\\'" "\\.org\'")
  :bind (("C-c g"   . 'mrb/gtd)
         ("C-c a"   . 'org-agenda)
         ("C-c b"   . 'org-switchb)
         ("C-s-s"   . 'org-save-all-org-buffers)
         ("C-c l"   . 'org-store-link)        ; Is this not bound to C-c l by default
         :map org-mode-map
         ("s-."     . 'org-todo)
         ("s->"     . 'mrb/org-cancel-todo)
         ("C-s-."   . 'mrb/force-org-todo)
         ("M-p"     . 'org-set-property)
         ("C-c v"   . (λ () (interactive) (org-cycle-set-startup-visibility)))
         :map org-agenda-mode-map
         ("s-."   . 'org-agenda-todo)
         ("s->"   . 'mrb/org-agenda-cancel-todo)
         ("C-s-." . 'mrb/force-org-agenda-todo)
         ("C-."   . 'org-agenda-schedule)
         ("M-p"   . 'org-set-property))

  :custom-face
  (org-date                 ((t (:foreground "#8fbcbb" :underline nil))))
  (org-verbatim             ((t (:foreground "#8fbcbb"))))
  (org-list-dt              ((t (:foreground "#b48ead"))))
  (org-drawer               ((t (:height 0.7 :inherit font-lock-comment-face))))
  ;; Inline code: light oblique frost, no family of its own so the advance
  ;; width stays that of `default' (Iosevka is duospace, so every weight is
  ;; 10px/char here and code never reflows a table or aligns off).
  (org-code                 ((t (:foreground "#88c0d0" :weight light :slant oblique))))
  (org-block                ((t (:background "#313745"))))
  (org-block-begin-line     ((t (:foreground "dark gray"))))
  (org-headline-done        ((t (:inherit font-lock-comment-face))))
  (org-inlinetask           ((t (:inherit font-lock-comment-face))))

  ;; User defined faces for org-mode
  (mrb/org-todo-keyword-TODO     ((t ( :foreground "#88c0d0" :weight bold))))
  (mrb/org-todo-keyword-DONE     ((t ( :foreground "#a3be8c" :weight bold))))
  (mrb/org-todo-keyword-WAITING  ((t ( :foreground "#bf616a" :weight bold))))
  (mrb/org-todo-keyword-CANCELLED ((t ( :foreground "bisque4" :weight bold))))
  (mrb/org-todo-keyword-BUY      ((t ( :foreground "#d08770" :weight bold))))
  (mrb/org-todo-keyword-HOWTO    ((t ( :foreground "#5e81ac" :weight bold))))
  (mrb/org-todo-keyword-INFO     ((t ( :foreground "#ebcb8b" :weight bold))))
  (mrb/org-todo-keyword-COLLECT  ((t ( :foreground "#a3be8c" :weight bold))))
  (mrb/org-todo-keyword-SOLVE    ((t ( :foreground "#8fbcbb" :weight bold))))
  (mrb/org-todo-keyword-READ     ((t ( :foreground "#b48ead" :weight bold))))
  (mrb/org-todo-keyword-READING  ((t ( :foreground "#bf616a" :weight bold))))
  (mrb/org-todo-keyword-PLAN     ((t ( :foreground "#d8dee9" :weight bold))))


  :config
  <<orgmode-generalconfig>>
  ;; Category icons I have configured elsewhere.
  <<org-agenda-visuals>>

  <<orgmode-itemactions>>

  <<orgmode-scheduling>>

  ;; Allow for archiving and refiling in a date organized tree
  (use-package org-datetree )
  (use-package org-protocol
    :config
    ;; If nothing is specified, create a TODO item
    (setq org-protocol-default-template-key "t")))

Gather generic config variables for orgmode in one section

;; org-directory is set in personal.el
(setq org-metadir (mrb/org-file "_orgmeta/")

      org-use-fast-todo-selection t
      org-use-fast-tag-selection t
      org-use-speed-commands t
      org-fast-tag-selection-single-key 'expert
      org-enforce-todo-dependencies t       ; we do want task dependencies
      org-enforce-todo-checkbox-dependencies nil ; but relax checkbox constraints for it

      ;; We do not do priorities
      org-enable-priority-commands nil

      ;; Agenda settings
      org-agenda-inhibit-startup t
      org-agenda-dim-blocked-tasks nil
      org-agenda-use-tag-inheritance nil

      ;; NOTE: this will get overridden later on by org-mem
      org-agenda-files (mrb/org-file ".agenda_files")
      org-agenda-include-diary t
      org-agenda-start-with-log-mode t
      org-agenda-todo-ignore-scheduled "future"
      org-agenda-ignore-properties '(effort stats appt category)
      org-agenda-todo-ignore-scheduled 'future
      org-agenda-log-mode-items '(closed clock state)
      org-agenda-skip-deadline-prewarning-if-scheduled t
      org-agenda-text-search-extra-files (cons 'agenda-archives (directory-files
                                                                 (expand-file-name (concat org-metadir)) t ".+\\.org"))
      ;; Habits
      org-habit-show-habits-only-for-today nil

      ;; Pressing enter on a link should activate it
      org-return-follows-link t

      ;; Shift+arrow key handling: org-mode commands vs. text selection
      ;; Setting: t = org commands take precedence in org buffers, shift-select works elsewhere
      ;; Trade-off: 'always would enable shift-select everywhere but would disable org-mode's
      ;; shift+arrow commands (S-left/S-right for promote/demote, S-up/S-down for move).
      ;; Current setting (t) is the best compromise: org functionality works fully.
      org-support-shift-select t


      ;; Auto detect blank line need, this is the default, but I explicitly set this
      ;; because it needs to be this or my capturing breaks due to org-capture popup
      org-blank-before-new-entry '((heading . auto) (plain-list-item . auto))

      ;; Use auto-mode-alist for all file links/types
      org-file-apps '((auto-mode . emacs))

      org-fontify-done-headline t
      org-goto-interface 'outline-path-completion
      ;; non nil is just direct children, what an ODD name!!!!
      org-hierarchical-todo-statistics nil
      org-provide-todo-statistics t

      org-log-into-drawer t
      org-log-redeadline 'note
      org-log-reschedule 'time
      org-todo-repeat-to-state "TODO"

      org-modules '(org-habit
                             org-inlinetask
                             org-toc org-mac-iCal org-mouse
                             org-tempo)
      org-remember-default-headline ""
      org-special-ctrl-a/e t
      org-stuck-projects '("-inactive/+TODO" ("TODO" "WAITING") nil "")
      org-track-ordered-property-with-tag nil
      org-startup-indented t
      org-startup-folded 'show2levels
      org-cycle-hide-drawer-startup nil  ; perf: hiding drawers at startup is O(n) over all headings
      org-element-cache-persistent nil  ; perf: cache restore via macroexpand-all costs more than rebuilding

      org-archive-location (concat org-metadir
                                    (format-time-string "archive-%Y.org")
                                    "::datetree/")
      org-default-notes-file (mrb/org-file "GTD.org")
      diary-file (concat org-metadir "DIARY")

      ;; Heading and list behavior
      org-M-RET-may-split-line '((default . t) (headline))
      org-insert-heading-respect-content nil
      org-list-allow-alphabetical t

      ;; Blocking and clocking
      org-clock-x11idle-program-name "xprintidle"
      org-closed-keep-when-no-todo t

      ;; Export and entities
      org-entities-user '(("cmd" "\\cmd{}" nil "⌘" "⌘" "⌘" "⌘"))
      org-html-toplevel-hlevel 3
      org-timestamp-custom-formats '("<%m/%d/%y %a>" . "<%H:%M>")

      ;; Agenda custom commands
      org-agenda-custom-commands
      '(("w" "Waiting For list" tags-todo "-inactive/WAITING"
         ((org-agenda-overriding-header "WAITING FOR-list") (org-agenda-dim-blocked-tasks t)
          (org-agenda-group-by-property "Responsible")))
        ("b" "Buying list"
         ((tags-todo
           "-inactive+buy/-BUY"
           ((org-agenda-overriding-header "Buying list (tagged)")))
          (tags-todo
           "-inactive/BUY"
           ((org-agenda-overriding-header "Buying list (keyword)")))
          (tags-todo
           "-inactive+CATEGORIES=\"BUY\"/-BUY"
           ((org-agenda-overriding-header "Buying list (category)"))))
         nil)
        ("p" "Active project list" tags-todo
         "-ignore-inactive+LEVEL>1-TODO=\"DONE\"-TODO=\"CANCELLED\"-TODO=\"INFO\""
         ((org-agenda-overriding-header "Active project list")
          (org-agenda-skip-function 'mrb/skip-non-projects) (org-agenda-dim-blocked-tasks nil)
          (org-agenda-group-by-property "Group") (org-agenda-sorting-strategy '(alpha-up))))
        ("A" "Active task list" tags-todo "+SCHEDULED=\"\"-inactive/TODO"
         ((org-agenda-group-by-property "Group") (org-agenda-dim-blocked-tasks 'invisible)))
        ("r" "To Review"
         ((tags-todo "SCHEDULED=\"\"+DEADLINE=\"\"-{.}/TODO"
                     ((org-agenda-overriding-header "Untagged items")))
          (tags-todo "-inactive+SCHEDULED=\"\"+DEADLINE=\"\"+TODO=\"TODO\"+{.}"
                     ((org-agenda-overriding-header "Unscheduled active items"))))
         ((org-agenda-dim-blocked-tasks 'invisible)))
        ("c" "Scheduled overview" tags-todo "SCHEDULED<>\"\"|DEADLINE<>\"\"/TODO"
         ((org-agenda-overriding-header "SCHEDULED") (org-agenda-view-columns-initially t)
          (org-agenda-overriding-columns-format "%65ITEM %25Responsible %SCHEDULED %DEADLINE %TAGS")
          (org-agenda-dim-blocked-tasks t)))
        ("l" "Blocked projects and tasks"
         ((tags-todo "+BLOCKED=\"t\"/PROJ"
                     ((org-agenda-overriding-header "Blocked projects")
                      (org-agenda-dim-blocked-tasks t)))
          (tags-todo "+BLOCKED=\"t\"/TODO"
                     ((org-agenda-overriding-header "Blocked tasks") (org-agenda-dim-blocked-tasks t)
                      (org-agenda-group-by-property "Group"))))
         nil nil)
        ("n" "Next Action List [hides blocked/inactive/waiting/INBOX-ed]" tags-todo
         "+SCHEDULED=\"\"+DEADLINE=\"\"-BLOCKED=\"t\"-inactive-habit-ARCHIVE/-WAITING-INFO-HOWTO"
         ((org-agenda-overriding-header "Next Action List") (org-agenda-dim-blocked-tasks 'invisible)
          (org-agenda-group-by-property "CREATED")))
        ("D" "Items ready for archiving" todo "DONE"
         ((org-agenda-overriding-header "Items ready for archiving")
          (org-agenda-group-by-property "CREATED")))
        ("g" "AGENDA"
         ((agenda ""
                  ((org-agenda-filter-preset '("-inactive")) (org-agenda-span 'day)
                   (org-agenda-overriding-header "Day agenda")))
          (tags-todo "carryover/-INFO-WAITING"
                     ((org-agenda-overriding-header "Carry along list")
                      (org-agenda-files `(,(org-journal--get-entry-path))))))
         nil)))

Capturing information

I guess 90% of the information I keep in the main orgmode files starts life originally as a captured item. I use it for:

  1. TODO items;
  2. BUY items;
  3. Journal entries;
  4. Logbook entries;
  5. Weblinks;
  6. Toots.
(use-package org-capture
  :diminish

  :custom
  ;; Do not do the automatic bookmarking when capturing
  (org-capture-bookmark nil)
  (bookmark-set-fringe-mark nil)

  ;; Special config for handling tags while capturing
  <<org-capture-taghandling>>

  :config
  ;; Configuration of all the capture templates
  <<org-capture-templates>>
  ;; Headless capture and recall for programmatic use (AI agent integration)
  <<org-capture-headless>>
  ;; and some shortcuts to run them
  <<org-capture-runners>>

  ;; Not a :custom, we're making this up as we go.
  (setq ocpf-frame-parameters
        '((name . "*capture*")
          (width . 115) (height . 15)
          (tool-bar-lines . 0) (menu-bar-lines . 0)
          (internal-border-width . 5)))

  (defun ocpf--capture-frame-p ()
    "Return t if current frame is the capture frame."
    (equal (cdr (assoc 'name ocpf-frame-parameters))
           (frame-parameter nil 'name)))

  (defun ocpf--funcall (func &rest args)
    "Call FUNC in our capture frame only."
    (when (ocpf--capture-frame-p)
      (apply func args)))

  (defun ocpf--delete-frame (&rest args)
    "Close capture frame."
    (ocpf--funcall 'delete-frame))

  (defun ocpf--delete-other-windows (&rest args)
    "Make sure we have the capture frame to ourselves."
    (ocpf--funcall 'delete-other-windows))

  (defun ocpf--window-system ()
    "Return appropriate window system symbol for current OS."
    (cond ((eq system-type 'darwin) 'ns)
          ((eq system-type 'gnu/linux) 'x)
          ((eq system-type 'windows-nt) 'w32)))

  (defvar mrb/capture-headless nil
    "When non-nil, org-capture bypasses frame creation. For programmatic use.")

  (defun ocpf--org-capture (orig-fun &optional goto keys)
    "Create a new frame and run org-capture, unless mrb/capture-headless is set."
    (if mrb/capture-headless
        (funcall orig-fun goto keys)
      (let ((after-make-frame-functions
             (λ (frame)
               (select-frame frame)
               (funcall orig-fun goto keys))))
        (make-frame
         `((window-system . ,(ocpf--window-system))
           ,@ocpf-frame-parameters))
        (setq header-line-format nil))))

  ;; All capture frames should have one window
  (add-hook 'org-capture-mode-hook 'delete-other-windows)

  ;; Wrap org-capture in our org-capture
  (advice-add 'org-capture :around #'ocpf--org-capture)

  ;; Proper clean up
  (advice-add 'org-capture-finalize :after #'ocpf--delete-frame))



Here are the templates used by org-capture. The todo template is the most used, it is the same as the link template, but does not include a reference to the current context, which is, in most cases, just annoying.

(setq
 org-capture-templates
 `(("b" "Buy"
    entry (function mrb/capture-location) "* BUY %?\n%(mrb/prop-or-nothing :annotation)\n" :prepend t :empty-lines 1)
   ("l" "Link"
    entry (function mrb/capture-location) "* TODO %?\n%(mrb/prop-or-nothing :annotation)\n" :prepend t :empty-lines 1)
   ("t" "Todo"
    entry (function mrb/capture-location) "* TODO %?\n" :prepend t :empty-lines 1)
   ("g" "Generic Log Entry"
    item  (here) "- %u %?")))

(defun mrb/prop-or-nothing(prop)
  "Insert `[:type]: [:property]` when :property has a value, otherwise nothing.
Meant to be used inside org-capture-templates

Example: `%(mrb/prop-or-nothing :annotation)`"

  (let ((prop-value (plist-get org-store-link-plist prop))
        (type (plist-get org-store-link-plist :type)))
    (if (equal prop-value "")
        ""
      (concat (when type (concat type ": ")) prop-value))))

(defun mrb/capture-location()
  "This function is meant to be used inside org-capture-templates
to find a file and location where a captured ITEM should
be stored."
  (interactive)

  ;; Open journal file without creating a journal entry This has the
  ;; side effect that it creates the file and moves TODO items over
  ;; on first call and leaves the cursor at the end of the file.
  (org-journal-new-entry 1)

  ;; Find the id in this file and go there for the capture
  (when-let* ((loc (org-find-entry-with-id "new-todo-receiver")))
    (goto-char loc)))

Define functions for each piece of information captured, so they can be easily bound to keys.

  :init
  (defun mrb/capture-todo ()
    "Capture a TODO item"
    (interactive)
    (org-capture nil "t"))

  (defun mrb/capture-buy ()
    "Capture a BUY item"
    (interactive)
    (org-capture nil "b"))

  (defun mrb/capture-link ()
    "Capture a TODO item, but link to source when we can"
    (interactive)
    (org-capture nil "l"))

  (defun mrb/capture-item-log ()
    "Capture a generic log item under the enclosing section.
Append to the last plain list in the section body if one exists;
otherwise place at the end of the body, before any sub-headings."
    (interactive)
    (widen)
    (org-back-to-heading t)
    ;; NB: `org-end-of-meta-data' with FULL=t skips blank lines too, which
    ;; jumps past an empty section into the next heading. Skip only planning
    ;; and property drawer so we stay inside the enclosing section.
    (org-end-of-meta-data)
    (let* ((body-start (point))
           (section-end (or (save-excursion
                              (and (outline-next-heading) (point)))
                            (point-max))))
      (goto-char section-end)
      (if (re-search-backward "^[ \t]*[-+*] " body-start t)
          (progn
            (goto-char (org-list-get-bottom-point (org-list-struct)))
            ;; bottom-point may land at the start of a non-empty line
            ;; (e.g. an immediately following sub-heading); open a
            ;; blank line so the new item gets its own.
            (unless (or (eobp) (looking-at-p "$"))
              (save-excursion (insert "\n"))))
        (goto-char section-end)
        (skip-chars-backward " \t\n")
        (end-of-line)
        (insert "\n")))
    (org-capture nil "g"))

These capture functions are called from shell scripts in the operating system and have a shortcut key assigned to them. The scripts are produced directly from this document, in a similar way as the main edit script was produced in 2

edit-noframe --eval '(mrb/capture-todo)'
edit-noframe --eval '(mrb/capture-buy)'

Headless capture and recall functions for programmatic use, primarily by Claude Code. These bypass the interactive capture frame so entries can be added from outside Emacs.

(defun mrb/capture-todo-headless (text &optional keyword tags keys captured-by)
  "Capture TEXT without interactive frame using capture template KEYS (default \"t\").
KEYWORD, if given (e.g. \"WAITING\"), transitions the entry after capture.
TAGS is an optional list of tag strings added via org-set-tags.
CAPTURED-BY names the agent/client making the capture (\"hermes\", \"claude\",
\"crush\", \"goose\") and is stored in the CAPTURED_BY property; it defaults to
\"unspecified\" so an unset value is visibly unset rather than wrongly
attributed.  It is the client, not the underlying model.

TEXT may span multiple lines and include nested sub-headings.  The
first line of TEXT is treated as the heading title (it lands at
`%?' in the template); the remainder, if any, becomes the entry
body and is inserted below any meta-data (properties drawer etc.)
that already exists for the heading.

Tags, KEYWORD and CAPTURED_BY are anchored on the captured heading
via a marker recorded immediately after `org-capture' returns.
Without anchoring, `org-set-tags' / `org-set-property' would attach
to whatever sub-heading happens to be at point after body insertion.

Splitting title from body matters because an :after-org-capture
advice (e.g. `mrb/insert-created-timestamp') may have already
added a properties drawer right after the heading line.  Inserting
body at the original point (end of heading line) pushes that drawer
below the body; jumping past meta-data first keeps the drawer where
it belongs and lets `org-set-property' merge CAPTURED_BY into it
cleanly."
  (let ((mrb/capture-headless t)
        (ok nil))
    ;; org-capture sets up the buffer and leaves point at %? (title position)
    (org-capture nil (or keys "t"))
    (unwind-protect
        (let* ((heading-marker (save-excursion
                                 (org-back-to-heading t)
                                 (point-marker)))
               (nl-pos (cl-position ?\n text))
               (title  (if nl-pos (substring text 0 nl-pos) text))
               (body   (and nl-pos (substring text (1+ nl-pos)))))
          ;; Title at %? (heading line)
          (insert title)
          ;; Anchor tags/keyword/property on the heading
          (save-excursion
            (goto-char heading-marker)
            (when keyword (org-todo keyword))
            (when tags (org-set-tags tags))
            (org-set-property "CAPTURED_BY" (or captured-by "unspecified")))
          ;; Body goes past any meta-data the heading now has
          ;; (properties drawer from CREATED advice, blank lines, etc.)
          (when (and body (not (string-empty-p body)))
            (goto-char heading-marker)
            (org-end-of-meta-data t)
            (insert body)
            (unless (bolp) (insert "\n")))
          (set-marker heading-marker nil)
          (setq ok t)
          (org-capture-finalize))
      (unless ok (ignore-errors (org-capture-kill))))))

(defun mrb/list-todos (&optional match)
  "Return TODO entries matching MATCH as a string suitable for external tools.
MATCH is an org-tags-view match string; default selects active TODO entries.
Uses org-agenda-files scope (managed by org-mem), covering all of ~/dat/org/.
Output format per entry: FILE::KEYWORD::HEADING::TAG1,TAG2"
  (let ((results '())
        (match-str (or match "TODO=\"TODO\"")))
    (org-map-entries
     (lambda ()
       (let* ((heading  (substring-no-properties (org-get-heading t t t t)))
              (keyword  (substring-no-properties (or (org-get-todo-state) "")))
              (tags     (mapcar #'substring-no-properties (org-get-tags nil t)))
              (file     (substring-no-properties (or (buffer-file-name) ""))))
         (push (format "%s::%s::%s::%s"
                       (file-name-nondirectory file)
                       keyword
                       heading
                       (if tags (mapconcat #'identity tags ",") ""))
               results)))
     match-str 'agenda)
    (substring-no-properties (mapconcat #'identity (nreverse results) "\n"))))

By default C-c C-c ends the capture, but is normally the shortcut to enter tags, so I define a shortcut to define tags while capturing.

:bind ( :map org-capture-mode-map
        ("C-c C-t" . mrb/add-tags-in-capture)
        :map org-mode-map
        ("C-c i" . mrb/capture-item-log))

:init
(defun mrb/add-tags-in-capture()
  "Insert tags in a capture window without losing the point"
  (interactive)
  (save-excursion
    (org-back-to-heading)
    (org-set-tags-command)))

Workflow

Orgmode used a couple of thing which enable you to steer the workflow for items. Item states are the most prominent ones. Org-mode uses keyword definitions to denote states on items. I keep an Orgmode configuration file (org-config.org) file which contains the description of the workflow in a format suitable to include directly into orgmode files. The configuration of emacs itself is limited to dressing up this configuration with things less suitable to go into that config file. The configuration here and the org config file should be kept in sync.

Adapt the colors of the states I use a bit:

;; Define face specs for our keywords, so they can be used in the
;; theme adjustment like standard faces
(defface mrb/org-todo-keyword-TODO      nil "")
(defface mrb/org-todo-keyword-DONE      nil "")
(defface mrb/org-todo-keyword-WAITING   nil "")
(defface mrb/org-todo-keyword-CANCELLED nil "")
(defface mrb/org-todo-keyword-BUY       nil "")
(defface mrb/org-todo-keyword-HOWTO     nil "")
(defface mrb/org-todo-keyword-INFO      nil "")
(defface mrb/org-todo-keyword-COLLECT   nil "")
(defface mrb/org-todo-keyword-SOLVE     nil "")
(defface mrb/org-todo-keyword-READ      nil "")
(defface mrb/org-todo-keyword-READING   nil "")
(defface mrb/org-todo-keyword-PLAN      nil "")


(setq org-todo-keyword-faces `(
                               ("TODO"      . mrb/org-todo-keyword-TODO)
                               ("DONE"      . mrb/org-todo-keyword-DONE)
                               ("WAITING"   . mrb/org-todo-keyword-WAITING)
                               ("CANCELLED" . mrb/org-todo-keyword-CANCELLED)
                               ("BUY"       . mrb/org-todo-keyword-BUY)
                               ("HOWTO"     . mrb/org-todo-keyword-HOWTO)
                               ("INFO"      . mrb/org-todo-keyword-INFO)
                               ("COLLECT"   . mrb/org-todo-keyword-COLLECT)
                               ("SOLVE"     . mrb/org-todo-keyword-SOLVE)
                               ("READ"      . mrb/org-todo-keyword-READ)
                               ("READING"   . mrb/org-todo-keyword-READING)
                               ("PLAN"      . mrb/org-todo-keyword-PLAN)))

Make sure we keep a clean tag slate when changing tag state. This means that when I move to an active state, remove inactive tags; if something is DONE, remove tags from it and automatically adding a 'buy' tag when a BUY item is created. Note: capturing does not honor this, i.e. when creating a new item.

(setq org-todo-state-tags-triggers
      '(("TODO" ("inactive"))          ; remove inactive tags if moved to any active state
        ("DONE" ("inactive") ("fork")) ; remove tags from any inactive state
        ("BUY"  ("buy" . t))))         ; add buy tag when this is a buying action

To keep the TODO list clean we archive completed entries. The archiving only occurs when an item is in the 'DONE' or 'CANCELLED' state and is not marked as a habit. A guard prevents archiving incomplete TODO items.

  (setq mrb/archivable-todo-states '("DONE" "CANCELLED" "INFO"))

  (defun mrb/can-archive-p (archivable-states)
    "Check if current heading can be archived.
ARCHIVABLE-STATES is a list of TODO states from which archiving is allowed."
    (let ((todo-state (org-get-todo-state)))
      (member todo-state archivable-states)))

  ;; Guard to org-archive-subtree to prevent archiving incomplete items
  (advice-add 'org-archive-subtree :before
              (λ (&rest _args)
                (unless (mrb/can-archive-p mrb/archivable-todo-states)
                  (let ((state (org-get-todo-state))
                        (allowed-states (string-join mrb/archivable-todo-states ", ")))
                    (user-error "Cannot archive: item is in %s state (must be %s)" state allowed-states)))))

In addition to the above I'm experimenting with org-edna (Extensible Dependencies 'N' Actions) which has a bit more flexibility. Notably the blocking of tasks is what I want to peruse a bit, because the builtin system hasn't worked for me. I may still use the builtin way to block items, but perhaps under the dynamic control of org-edna instead of manually specifying order properties etc.

For now, a basic config and enabling of org-edna

(use-package org-edna
  :diminish
  :after org
  :config
  (org-edna-mode))

Marking items as DONE

Marking work as completed should be a smooth process to stop getting in the way of doing the actual work. A shortcut is defined to mark items done in the standard way and have an additional shortcut to mark it done should it be blocked.

When an item changes to the DONE state, a question is asked if the item should be archived, to which the normal answer should be 'Yes' to keep the active file as clean as possible.

  ;; Macro to consolidate duplicated org-todo command logic
  (defmacro mrb/make-org-todo-command (name base-fn &optional state)
    "Create an interactive TODO command that toggles state with org-inhibit-blocking.
NAME is the function name to create, BASE-FN is org-todo or org-agenda-todo,
STATE is an optional target state like \"CANCELLED\"."
    `(defun ,name ()
       ,(format "Toggle org TODO%s." (if state (format " to %s" state) ""))
       (interactive)
       (let ((org-inhibit-blocking t))
         (funcall ',base-fn ,@(if state `(,state) nil)))))

  ;; Create the four todo command variants
  (mrb/make-org-todo-command mrb/force-org-todo org-todo)
  (mrb/make-org-todo-command mrb/force-org-agenda-todo org-agenda-todo)
  (mrb/make-org-todo-command mrb/org-cancel-todo org-todo "CANCELLED")
  (mrb/make-org-todo-command mrb/org-agenda-cancel-todo org-agenda-todo "CANCELLED")

The above may be influenced, or even made redundant, by the org-edna package configuration.

Registering creation time of todo items

Over time it gets a bit messy in my orgmode files. I can not remember when something was created and thus, by judging the time I didn't do anything with the item, decide if it is still important or not.

So, to help with that I created a little glue to make sure each actionable item gets a CREATED property with the date in it on which that item was created. I use the contributed org-expiry for that and adjust it a bit.

I want the property to be name 'CREATED' (I don't remember what the org-expiry default name is, but it is different) and the timestamps inserted must not be active, otherwise they'll appear all over the place in the agenda.

(use-package org-contrib)
(use-package org-expiry
  :custom
  (org-expiry-created-property-name "CREATED")
  (org-expiry-inactive-timestamps  t)

  :config
  <<org-expiry-createdtimestampfunction>>

  )

So, to create the timestamp I need a little helper function which actually inserts it, using org-expiry. There is some additional cursor munging to make sure it is used comfortably during editing. To actually make this active we advice the operations that insert headers: org-insert-todo-heading and org-capture when we are actually capturing a TODO item. Anvil's MCP add-todo gets its own :filter-return advice that runs after the heading is fully built (point on heading, :ID: drawer present), because slotting in at org-todo time produces a stray second drawer once anvil later calls org-id-get-create.

  (defun mrb/insert-created-timestamp(&rest args)
    "Insert a CREATED property using org-expiry.el for TODO entries"
    (org-expiry-insert-created)
    (org-back-to-heading)
    (org-end-of-line))

  ;; for insert todo heading, always add the CREATED property
  (advice-add 'org-insert-todo-heading
              :after #'mrb/insert-created-timestamp)

  ;; for capturing, only when state is in the TODO keywords list active in buffer
  (advice-add 'org-capture
              :after (λ (&rest r)
                       (when (member (org-get-todo-state) org-todo-keywords-1)
                         (mrb/insert-created-timestamp))))

  ;; Anvil's MCP add-todo bypasses both of the above — it builds the
  ;; headline in a temp/file buffer and calls `org-todo' directly,
  ;; before the :ID: drawer exists.  We cannot slot CREATED in at
  ;; `org-todo' time without producing a stray second drawer once
  ;; `org-id-get-create' runs.  Stamp it ourselves after the JSON
  ;; result comes back: parse the returned URI, navigate to the new
  ;; heading (drawer now exists), let org-expiry merge the property.
  (defun mrb/anvil-stamp-created (result)
    "Stamp :CREATED: on the heading anvil just added.
RESULT is the JSON string returned by `anvil-org--tool-add-todo'."
    (let* ((parsed (ignore-errors
                     (json-parse-string result :object-type 'alist)))
           (uri    (and parsed (alist-get 'uri parsed)))
           (uuid   (and uri (string-remove-prefix "org-id://" uri))))
      (when uuid
        (save-window-excursion
          (save-excursion
            (org-id-goto uuid)
            (mrb/insert-created-timestamp)
            (save-buffer)))))
    result)

  (with-eval-after-load 'anvil-org
    (advice-add 'anvil-org--tool-add-todo
                :filter-return #'mrb/anvil-stamp-created))

Related to the above, with some regularity I want to record timestamps, for example for documenting longer during tasks and recording incremental progress or information on them. Orgmode provides the binding 'C-c !' for this which inserts an inactive date-stamp, optionally including the time as well.

Lastly, there's a mechanism in emacs which can automatically insert time-stamps, based on a placeholder in the file and a format variable. I sometimes use that, so I add it to my before save hook. Typically, the format string is file specific and will be held in a file local variable.

(add-hook 'before-save-hook 'time-stamp)

Scheduling items

Orgmode has a number of provisions to schedule items, either explicitly by setting the SCHEDULE property, inferring a deadline by setting the DEADLINE property, thus scheduling the task in an interval before the deadline expires.

The main key for scheduling will be C-. (Control key with dot). A todo is marked to the next state with M-. so this makes sense, at least to me. In plain org and in org-agenda mode this key is used most often, but I expect this to be useful in other modes as well. I will try to u use the same keybinding in those modes as well.

I had schedule-for-today functions earlier, but those really just save me from pressing return in a normal org-schedule function, so their added value was minimal and I have since deleted them.

Visual settings

Having an attractive screen to look at becomes more important if you use the system all day long. Attractive is rather subjective here. For me it mainly consists of functional things. Anyways, this section groups settings for the visual characteristics of orgmode.

I want to hide the leading stars in the out-liner, and do it exactly in the background color. This is redundant actually in my case, as it is also specified in the org config file that I include. Or rather, it is redundant there, because I want it always to be the case.

(setq org-hide-leading-stars t)

For the collapsed items in the outline orgmode uses the variable org-ellipsis to determine what character-sequence should be used to show that the item can be expanded. The variable can contain a string, which will then be used instead of the standard 3 dots, or a face which will then be used to render the standard 3 dots.

;; Use larger ellipsis symbol
(setq org-ellipsis "⋯")

There are a couple of ways within org to emphasize text inline for bold, italics, underlined etc. These are set in the text by enclosing regions with delimiters. I do not want to see these delimiters, but rather render the text. The org-appear package makes this even fancier by showing the characters when inside the marked area, so editing is a lot easier.

(use-package org-appear
  :after org
  :hook (org-mode . org-appear-mode)
  :custom
  (org-hide-emphasis-markers t)    ; Is this used by org-appear mode?
  )

Similar to inline emphasis is the rewriting with pretty entity characters (like 'δ' for example). These characters can be added to the text by adding a '\' before a symbol name ('delta' in the example). I make an exception for the sub- and superscript characters. This happens a lot in variable names etc. and I a big annoyance if those get rendered to subscript all the time.

(setq org-pretty-entities 1)
(setq org-pretty-entities-include-sub-superscripts nil)

Related to that is the display of links. I want them to be explicit most of the time to avoid confusion, but the 'fancy' display is easier at first:

(setq org-link-descriptive t)

For most of the source blocks I want Emacs to render those blocks in their native mode. This had a serious performance problem in the past, but I think it has been solved recently.

(setq org-src-fontify-natively t)

For the headings at each level a * is normally used. As we're in unicode world now, we can do a bit better.

(use-package org-bullets
  :hook (org-mode . (λ () (org-bullets-mode 1))))

The item lists can be made a whole lot more attractive by attaching an icon based on the category an item belongs to. The category assignment itself is done by setting the CATEGORY property explicitly on the item or on the file.

Emoji instead of PNGs: no image files to track, portable across machines. The icon slot in the agenda prefix (%i) is applied via a display text property, which accepts a string as replacement — so we override org-agenda-get-category-icon to look up an emoji in our own alist.

(defvar mrb/org-agenda-category-emoji-alist nil
  "Category → emoji lookup for the org-agenda icon slot.
Populated below via `setq' so config reloads pick up edits (a
plain `defvar' would keep the previously bound value).")

(setq mrb/org-agenda-category-emoji-alist
      '(("Afspraak"  . "📅")
        ("Blogging"  . "✏️")
        ("Car"       . "🚗")
        ("Cobra"     . "🚗")
        ("DVD"       . "💿")
        ("Emacs"     . "🦄")
        ("Finance"   . "💰")
        ("Habitat"   . "🏠")
        ("Habit"     . "♻️")
        ("Hobbies"   . "🎨")
        ("Marcel"    . "🐵")
        ("Mobile"    . "📢")
        ("Sylvia"    . "👩")
        ("Partners"  . "🤝")
        ("Personal"  . "🙂")
        ("Task"      . "📋")
        ("Org"       . "🦄")
        ("Systeem"   . "🛠️")
        ("Wordpress" . "📝")))

(defun mrb/org-agenda-get-category-icon (category)
  "Return an emoji string for CATEGORY, or nil.
Overrides `org-agenda-get-category-icon' so the icon slot uses
Unicode instead of PNG files."
  (cdr (assoc category mrb/org-agenda-category-emoji-alist)))

(advice-add 'org-agenda-get-category-icon :override
            #'mrb/org-agenda-get-category-icon)
(setq org-agenda-category-icon-alist nil)

Meetings from different calendars get their own color in the agenda so the source is obvious at a glance. Each caldata file sets its own #+CATEGORY: (Marcel, Habitat, Sylvia); a finalize-hook walks the built agenda and tints each category label with a Nord-palette face. Font-lock is disabled in the agenda buffer, so keyword-based coloring would be silently ignored — we set the face text property directly.

(defface mrb/agenda-cal-marcel  '((t (:foreground "#81a1c1" :weight bold)))
  "Agenda face for the Marcel calendar's category label.")
(defface mrb/agenda-cal-habitat '((t (:foreground "#a3be8c" :weight bold)))
  "Agenda face for the Habitat calendar's category label.")
(defface mrb/agenda-cal-sylvia  '((t (:foreground "#b48ead" :weight bold)))
  "Agenda face for the Sylvia calendar's category label.")

(defvar mrb/agenda-cal-face-alist
  '(("Marcel"  . mrb/agenda-cal-marcel)
    ("Habitat" . mrb/agenda-cal-habitat)
    ("Sylvia"  . mrb/agenda-cal-sylvia))
  "Category → face mapping for calendar entries in the agenda.")

(defun mrb/agenda-colorize-calendars ()
  "Tint calendar category labels in the current org-agenda buffer.
Font-lock is disabled in org-agenda, so we walk the buffer after
it is built and add a face text property directly."
  (let ((inhibit-read-only t))
    (save-excursion
      (goto-char (point-min))
      (while (not (eobp))
        (let* ((cat  (org-get-at-bol 'org-category))
               (face (cdr (assoc cat mrb/agenda-cal-face-alist))))
          (when face
            (save-excursion
              (when (re-search-forward
                     (concat "\\b" (regexp-quote cat) ":")
                     (line-end-position) t)
                (add-face-text-property
                 (match-beginning 0) (match-end 0) face)))))
        (forward-line 1)))))
(add-hook 'org-agenda-finalize-hook #'mrb/agenda-colorize-calendars)

Showing items in the agenda views reacts to a number of settings. In my setup I want blocked tasks hidden, that is the reason for blocking. Hide tasks which are DONE already and a deadline is coming up, no use showing those; the same goes for tasks which are DONE and are scheduled. In short, anything that does not need my attention needs to be hidden.

(setq
 org-agenda-dim-blocked-tasks t
 org-agenda-skip-deadline-if-done t
 org-agenda-skip-scheduled-if-done t
 org-agenda-skip-archived-trees nil
 ;; Silently ignore missing/unreadable files so comment lines in .agenda_files
 ;; and stale entries from previous sessions don't cause errors.
 org-agenda-skip-unavailable-files t
 )

After experimenting with automatic aligning of tags I've come to the conclusion I want to have it manual. For now, I set the tags to align flush right with column 110 but ideally I'd want something more dynamic (in the sense that visual-line mode is also dynamic)

(setq org-tags-column -95)
(defun mrb/org-align-all-tags ()
  "Wrap org-align-tags to be interactive and apply to all"
  (interactive)
  (org-align-tags t))
(bind-key "M-\"" 'mrb/org-align-all-tags)

Agenda customization

Settings which are just applicable for the org-mode agenda view.

;; Show properties in agenda view
(use-package org-agenda-property
  :custom
  (org-agenda-property-list '("LOCATION" "Responsible")))

;; When done rendering, go to the top
(add-hook 'org-agenda-finalize-hook (λ () (goto-char (point-min))) 100)

A snippet which resized the agenda to its window whenever its rebuilt by orgmode The 'auto value of the org-agenda-tags-column variable right aligns the tags in the agenda view

(setq org-agenda-tags-column 'auto)
(defun mrb/fit-agenda-window (&rest _)
  "Fit the Org Agenda to its buffer."
  (fit-window-to-buffer)
  (goto-char (point-min)))
(advice-add 'org-agenda-redo :after #'mrb/fit-agenda-window)

Calendaring

Calendaring information lives on a Radicale CalDAV server. vdirsyncer syncs it locally every 15 minutes via a systemd timer, producing singlefile .ics files in ~/dat/caldata/.

A Python script ~/bin/caldata-to-org wraps ics-to-org calls from source at ~/dat/src/ics-to-org and converts the ics files to org after each sync via ExecStartPost in the vdirsyncer systemd service. It reads the date ranges directly from the vdirsyncer config and uses them for ics-to-org as well.

The orgmode output files live in ~/dat/caldata/.

org-mem watches ~/dat/caldata/ and includes all .org files there in org-agenda-files automatically. No Emacs configuration is needed for the calendaring pipeline itself.

Babel / Literate programming

Specific settings for babel and literate programming within org-mode

(setq org-babel-load-languages
      '((awk        . t)
        (calc       . t)
        (css        . t)
        (ditaa      . t)
        (emacs-lisp . t)
        (gnuplot    . t)
        (haskell    . t)
        (js         . t)
        (lisp       . t)
        (org        . t)
        (plantuml   . t)
        (python     . t)
        (scheme     . t)
        (shell      . t)
        (sql        . t)))

;; Activate Babel languages
(org-babel-do-load-languages 'org-babel-load-languages org-babel-load-languages)

The elements of org babel are blocks of source code. Some of the modes for sources have some helper programs.

Plantuml transforms diagram specifications with a mini programming language into diagrams. It has support for many types of diagram. Ditaa transforms ascii diagrams into graphics.

(use-package plantuml-mode
  :after org    ; strictly not needed, but i use it mainly from org
  :init
  (setq plantuml-jar-path (mrb/home-file ".local/share/java/plantuml/plantuml.jar"))
  (setq org-plantuml-jar-path plantuml-jar-path)
  (setq plantuml-default-exec-mode 'jar))

(setq org-ditaa-jar-path (mrb/home-file ".local/share/java/ditaa/ditaa-0.11.jar"))

Refiling

A big part of organizing information and task is shuffling things around. The 'thing' to throw around is a heading and 'refiling' is the term org-mode uses for throwing.

When filing, or capturing we want the items at the bottom of what we are filing it into. The main reason for this is that a large part of the sections that contain items are ordered. Should we file the item at the top, in many cases that would mean it is the most imminent thing to do, which is not the case. For some files, we do want the items on top though

(setq org-reverse-note-order '(("workshop.org" . t)
                               (".*" . nil))    ; File for others at the bottom of an entry
      org-refile-allow-creating-parent-nodes 'confirm
      org-refile-use-outline-path 'file
      org-outline-path-complete-in-steps nil
      org-refile-use-cache t)

The base list of files I want to be able to refile to are:

  1. The active file list, i.e. everything in org-agenda-files, regardless of these files are currently opened or not;
  2. All open orgmode based files. Org has no refile-target keyword for those, so a function returns them; org-buffer-list does the listing. Putting open files in the targets this way comes from Yiming Chen's refile workflow.

org-refile-targets only accepts a function by name, so the function cannot be an inline lambda.

(defun mrb/org-opened-buffers ()
  "Return the files of all buffers visiting an org file."
  (mapcar #'buffer-file-name (org-buffer-list 'files)))

(setq org-refile-targets '((org-agenda-files :maxlevel . 10)
                           (mrb/org-opened-buffers :maxlevel . 10)))

The type of headers to refile to is, in the default orgmode config, basically everything. I limit it above to a maximum depth of 10, but then still. By using org-refile-target-verify-function we can fine-tune the decision whether to use a header as target or not. The following conditions have been implemented:

  1. The header must not be a DONE item;
  2. The header needs to have children.

The latter is the most important one and will prevent creating 'gathering' items to tasks themselves.

(defun mrb/has-DONE-keyword()
  "Return t when the heading at point has a `DONE' like keyword"
  (member (nth 2 (org-heading-components)) org-done-keywords))

(defun mrb/verify-refile-target()
  "Decide if a target header can be used as a refile target
Conditions to return t:
- header must not have one of the DONE keywords
- it must be a parent of something already"
  ;; interactive during testing
  (interactive)

  (and
                                        ; exclude done keyword headers
   (not (mrb/has-DONE-keyword))
                                        ; must have a child
   (save-excursion (org-goto-first-child))))


(setq org-refile-target-verify-function 'mrb/verify-refile-target)

Moving a subtree with M-up triggers org-fold's fragility check, which schedules a delayed unfold after the command — causing a folded heading to pop open. Suppressing the fragility check during the move prevents this without needing to manually re-fold.

(defun mrb/org-move-subtree-up-preserve-fold (orig-fn &rest args)
  "Preserve fold state when moving a subtree up."
  (org-fold-core-ignore-fragility-checks
    (apply orig-fn args)))

(advice-add 'org-move-subtree-up :around
            #'mrb/org-move-subtree-up-preserve-fold)

Exporting to other formats

Orgmode can export to a variety of formats, I mainly use LaTeX (PDF) and HTML as destination format.

(use-package htmlize)                   ; for html export source highlighting
;; {% raw %}
(setq org-latex-title-command " "

      org-html-htmlize-output-type 'css

      org-latex-src-block-backend 'engraved

      org-export-copy-to-kill-ring 'if-interactive

      org-latex-classes
      '(
        ("article" "\\documentclass[11pt,a4paper,twoside]{article}"
         ("\\section{%s}" . "\\section*{%s}")
         ("\\subsection{%s}" . "\\subsection*{%s}")
         ("\\subsubsection{%s}" . "\\subsubsection*{%s}")
         ("\\paragraph{%s}" . "\\paragraph*{%s}")
         ("\\subparagraph{%s}" . "\\subparagraph*{%s}"))
        ("report" "\\documentclass[11pt]{report}"
         ("\\part{%s}" . "\\part*{%s}")
         ("\\chapter{%s}" . "\\chapter*{%s}")
         ("\\section{%s}" . "\\section*{%s}")
         ("\\subsection{%s}" . "\\subsection*{%s}")
         ("\\subsubsection{%s}" . "\\subsubsection*{%s}"))
        ("book" "\\documentclass[11pt]{book}"
         ("\\part{%s}" . "\\part*{%s}")
         ("\\chapter{%s}" . "\\chapter*{%s}")
         ("\\section{%s}" . "\\section*{%s}")
         ("\\subsection{%s}" . "\\subsection*{%s}")
         ("\\subsubsection{%s}" . "\\subsubsection*{%s}"))
        ("beamer" "\\documentclass{beamer}" org-beamer-sectioning))

      ;; No tags, todo keywords or time-stamp
      org-export-with-tags nil
      org-export-with-todo-keywords nil
      org-export-time-stamp-file nil
      org-export-backends '(ascii html icalendar latex md odt org texinfo)
      )
;; {% endraw %}

Tables in PDF exports can run from the list bullet to the right text margin, with booktabs rules and a tinted, bold header row. It is opt-in per document with #+LATEX_TABLE_STYLE: fullwidth, normally through a house-style SETUPFILE; without it a table exports exactly as plain org does. Org already provides booktabs and switching off centring, but only globally; what it lacks is a width that depends on the indent, a choice of which column stretches, and header styling. Wrappers around org's own table transcoders supply those, working on org's table structure rather than on the generated LaTeX. A table with an explicit :width, :environment, :align, :mode or a caption is left to org, so any table can opt out through #+ATTR_LATEX.

The style brings its own preamble, so it never depends on the house style for anything but looks: \orgtableindent puts the table's left edge on the list bullet (list indent minus label gap minus bullet width), and orgtableheader is the header tint. It is declared with \providecolor, so a house style that defines it first wins.

(with-eval-after-load 'ox
  (add-to-list 'org-export-options-alist
               '(:mrb-latex-table-style "LATEX_TABLE_STYLE" nil nil t)))

(defconst mrb/latex-table-fullwidth-preamble
  (concat "\\usepackage{tabularx}\n"
          "\\usepackage{booktabs}\n"
          "\\usepackage{colortbl}\n"
          "\\providecolor{orgtableheader}{gray}{0.9}\n"
          "\\newlength{\\orgtableindent}\n"
          "\\AtBeginDocument{\\settowidth{\\orgtableindent}{\\labelitemi}"
          "\\setlength{\\orgtableindent}{\\dimexpr\\leftmargini-\\labelsep-\\orgtableindent\\relax}}")
  "LaTeX preamble the full-width table style needs.")

(defun mrb/latex-table-fullwidth-p (info)
  "Non-nil when the document being exported asks for full-width tables."
  (equal (plist-get info :mrb-latex-table-style) "fullwidth"))

(defun mrb/latex-table-style-options (info backend)
  "Switch on booktabs and add the preamble for full-width table documents."
  (when (and (org-export-derived-backend-p backend 'latex)
             (mrb/latex-table-fullwidth-p info))
    (plist-put info :latex-tables-booktabs t)
    (plist-put info :latex-header-extra
               (string-join (delq nil (list (plist-get info :latex-header-extra)
                                            mrb/latex-table-fullwidth-preamble))
                            "\n")))
  info)

(add-hook 'org-export-filter-options-functions #'mrb/latex-table-style-options)

(defun mrb/latex-table-stretch-align (table info)
  "Column spec for TABLE with the last left-aligned column as X."
  (let* ((cols (mapcar (lambda (cell)
                         (pcase (org-export-table-cell-alignment cell info)
                           ('right "r") ('center "c") (_ "l")))
                       (org-element-map
                           (org-element-map table 'table-row
                             (lambda (row) (and (eq (org-element-property :type row) 'standard) row))
                             info 'first-match)
                           'table-cell #'identity info)))
         (last-l (cl-position "l" cols :test #'string= :from-end t)))
    (when last-l (setf (nth last-l cols) "X"))
    (apply #'concat cols)))

(defun mrb/latex-table-plain-p (table info)
  "Non-nil when TABLE gets the full-width layout in this export."
  (let ((attr (org-export-read-attribute :attr_latex table)))
    (and (mrb/latex-table-fullwidth-p info)
         (eq (org-element-property :type table) 'org)
         (not (or (plist-get attr :width) (plist-get attr :environment)
                  (plist-get attr :align) (plist-get attr :mode)
                  (org-element-property :caption table))))))

(defun mrb/latex-table-full-width (orig table contents info)
  "Export plain TABLE as a tabularx from the bullet indent to the margin."
  (if (not (mrb/latex-table-plain-p table info))
      (funcall orig table contents info)
    (format "\\par\\noindent\\hspace*{\\orgtableindent}%%\n\\begin{tabularx}{\\dimexpr\\linewidth-\\orgtableindent\\relax}{%s}\n%s\\end{tabularx}\\par"
            (mrb/latex-table-stretch-align table info)
            contents)))

(defun mrb/latex-table-header-cell (orig cell contents info)
  "Set CELL bold when it sits in the header of a plain table."
  (funcall orig cell
           (if (and contents
                    (mrb/latex-table-plain-p (org-element-lineage cell 'table) info)
                    (org-export-table-row-in-header-p (org-element-parent cell) info))
               (format "\\textbf{%s}" contents)
             contents)
           info))

(defun mrb/latex-table-header-row (orig row contents info)
  "Tint ROW when it is a header row of a plain table."
  (let ((out (funcall orig row contents info)))
    (if (and (eq (org-element-property :type row) 'standard)
             (mrb/latex-table-plain-p (org-element-lineage row 'table) info)
             (org-export-table-row-in-header-p row info))
        (replace-regexp-in-string (regexp-quote contents)
                                  (concat "\\rowcolor{orgtableheader}" contents)
                                  out t t)
      out)))

;; the internal transcoder, so the caller still appends footnote texts and
;; verbatim, math and tabbing tables never reach this
(advice-add 'org-latex--org-table :around #'mrb/latex-table-full-width)
(advice-add 'org-latex-table-cell :around #'mrb/latex-table-header-cell)
(advice-add 'org-latex-table-row :around #'mrb/latex-table-header-row)

Journaling

While the standard capture method is useful I found myself not using it very much. Not sure why. It turns out that org-journal is a much better fit for my workflow, especially using the carry-over functionality.

Its date cache is the one storage location here with no supported way to move it. org-journal--cache-file is a defconst, so it cannot be pre-set the way a defvar can — loading the package would just overwrite the value — and there is no defcustom to use instead. Upstream still defines it the same way, so a version bump does not help either. It is read only inside org-journal--serialize and org-journal--deserialize, both at runtime, so assigning it from :config after the package has loaded is safe. The cost is relying on an internal name that upstream could rename.

  (use-package org-journal
    :after (org)
    :demand t
    :hook ((org-journal-mode . turn-on-visual-line-mode)
           (org-journal-mode . turn-on-visual-fill-column-mode))
    :bind (("C-c j"   . org-journal-new-entry)
           ("C-s-j"   . mrb/open-current-journal))

    :config
    (setq org-journal--cache-file (mrb/emacs-file ".state/org-journal.cache"))

    (defun mrb/open-current-journal()
      "Open current journal file, applying startup folding on first visit only."
      (interactive)
      (let* ((org-journal-file (org-journal--get-entry-path))
             (buf-exists (find-buffer-visiting org-journal-file)))
        (if buf-exists
            (switch-to-buffer buf-exists)
          ;; New entry: create buffer and apply startup folding after creation
          (org-journal-new-entry 1)
          (org-cycle-set-startup-visibility))))

    (defun mrb/daily-summary-write (iso-date bullets)
      "Append daily-summary BULLETS to the journal for ISO-DATE via org-journal."
      (mrb/daily-section-write iso-date "Accomplished" bullets))

    (defun mrb/daily-whereabouts-write (iso-date bullets)
      "Append whereabouts BULLETS to the journal for ISO-DATE via org-journal."
      (mrb/daily-section-write iso-date "Whereabouts" bullets))

    (defun mrb/daily-section-write (iso-date heading bullets)
      "Write a daily section HEADING with BULLETS to the journal for ISO-DATE.
Creates the journal file (with carryover) if missing; replaces an
existing `* HEADING on ISO-DATE' subtree in place if present, else
appends a new one at end of file."
      (let* ((time (org-time-string-to-time iso-date))
             (path (org-journal--get-entry-path time))
             (was-fresh (not (file-exists-p path))))
        (save-window-excursion
          (org-journal-new-entry t time)
          ;; org-journal only runs carryover when the entry is for "today";
          ;; for backfill (past) dates, fire it explicitly.
          (when (and was-fresh
                     (not (string= path (org-journal--get-entry-path (current-time)))))
            (org-journal--carryover)))
        (with-current-buffer (find-file-noselect path)
          (goto-char (point-min))
          (if (re-search-forward (format "^\\* %s on %s$"
                                         (regexp-quote heading)
                                         (regexp-quote iso-date))
                                 nil t)
              (let ((start (line-beginning-position)))
                (outline-end-of-subtree)
                (delete-region start (point)))
            (goto-char (point-max))
            (unless (bolp) (insert "\n")))
          (insert (format "* %s on %s\n%s\n"
                          heading
                          iso-date
                          (mapconcat (lambda (b) (concat "- " b)) bullets "\n")))
          (save-buffer))
        path))

Carryover inserts the carried text into the new journal but leaves that buffer unsaved, and only then deletes the originals from the previous file. Anything interrupting that window (a wedged daemon, a modal prompt) destroys the items outright rather than duplicating them. Saving the destination first bounds the worst case to a visible duplicate.

(defun mrb/org-journal-carryover-save-destination (orig text entries prev-buffer)
  "Persist the destination journal before ORIG strips items from PREV-BUFFER."
  (let* ((dest (current-buffer))
         (handler org-journal-handle-old-carryover)
         (org-journal-handle-old-carryover
          (lambda (old-entries)
            (with-current-buffer dest (save-buffer))
            (funcall handler old-entries))))
    (funcall orig text entries prev-buffer)))

(advice-add 'org-journal-carryover-items :around
            #'mrb/org-journal-carryover-save-destination)

org-journal--create-journal-dir only creates org-journal-dir itself. When org-journal-file-format nests entries into sub-directories (e.g. %Y/%m/%Y%m%d.org), the first entry of a new month lands in a directory that does not exist. find-file still opens a buffer, but the subsequent save-buffer inside mrb/org-journal-carryover-save-destination errors out — the handler that strips carried items from the previous day never runs, so the entries end up duplicated across both files. A :before advice ensures the parent directory exists before org-journal-new-entry proceeds.

(defun mrb/org-journal-ensure-entry-parent-dir (&rest _)
  "Ensure the parent directory of today's journal entry exists."
  (let ((dir (file-name-directory (org-journal--get-entry-path))))
    (unless (file-directory-p dir)
      (make-directory dir t))))

(advice-add 'org-journal-new-entry :before
            #'mrb/org-journal-ensure-entry-parent-dir)

:custom
(org-journal-dir (mrb/org-file "journal/"))

;; Bring our config to every journal file. Use `~/' form (not
;; `user-emacs-directory', which expands to an absolute path) so
;; journals stay portable across a home-directory move.
(org-journal-file-header "#+SETUPFILE: ~/.config/emacs/org-config.org")

;; Match the journal files (TODO  make this independent of earlier assignments)
(org-agenda-file-regexp "^[0-9]+\\.org")
(org-journal-file-format  "%Y/%m/%Y%m%d.org")

;; Put day on top of file. A closure pins `system-time-locale' to
;; guarantee stable Dutch date titles regardless of the inherited
;; locale (LANG/LC_TIME from the environment, daemon init-order
;; races, or worker daemons that do not load this config).
;;
;; Note: org-journal only concatenates `org-journal-date-prefix'
;; when `org-journal-date-format' is a string. With a function it
;; uses the return value verbatim, so the prefix must be prepended
;; here or the `#+TITLE:' header never lands in new journal files.
;; FIXME there are side-effect, this is the wrong solution, we should probably use an advice
(org-journal-date-format
 (lambda (time)
   (let ((system-time-locale "nl_NL.UTF-8"))
     (concat org-journal-date-prefix
             (format-time-string "%A, %-e-%m-%Y" time)))))

;; Put day on top of file, uses `org-journal-date-format`
(org-journal-date-prefix "#+TITLE: ")
;; New entries go at the bottom, make sure we are at top level
(org-journal-time-format "[%R] ")
(org-journal-time-prefix "* ")

;; Carry over TODO items and items explicitly marked
(org-journal-carryover-items "+carryover|+TODO=\"TODO\"")
;; and for this to work, we need agenda integration
(org-journal-enable-agenda-integration t)
;; Remove empty journals after carryover
(org-journal-carryover-delete-empty-journal 'always)

;; I want to have encryption, but how do TODO items bubble up then in the agenda
(org-journal-enable-encryption nil)
(org-crypt-disable-auto-save t)
(org-journal-enable-cache t)
(org-element-use-cache t))

Particularly in journaling, but also in org-mode in general, I want to be able to quickly insert screenshots. Rather, images in general but 90% of my use-case is really screenshots.

There's a package org-attach-screenshot which matches my use-case 99% so let's use that and worry about extending it to images later on.

(use-package org-attach-screenshot
  :bind (("C-c i" . org-attach-screenshot)))

The 1% I was referring to above is that the original package exclusively supports org-mode. I patched it, which was trivial to support org-mode and all its derived modes. (org-journal in my case)

Publishing

There's plenty ways to publish orgmode content on the web. I use a couple of them sparingly. For most of them I don't need a permanent configuration. For the ones that I do need a config, there's this section.

Writefreely

Writefreely is a Write.as derivative and published as open source. This allows me to just kick out a quick orgmode file with a #+TITLE: and a #+DATE: header and throw it on a blog like site rather quickly.

;; Still needed:
;; - post as draft by default, this prevents automatic posting by accident for federated collections
;; - save augment -> publish / update automatically, probably a custom hook on minor mode?
;; - wf: have autorefresh GET parameter which monitors updates and
;;       refreshes automatically
;; - set local save dir for the files
;; Make sure the variables of THIS file are loaded before THIS file is loaded
(use-package writefreely
  :after (org password-store)
  :custom
  ;; writefreely-instance-url and writefreely-instance-api-endpoint are set in personal.el
  (writefreely-maybe-publish-created-date t)
  (writefreely-auth-token nil)

  :config
  (defun mrb/writefreely-with-auth (orig &rest args)
    "Fetch writefreely access_token from password-store before publishing."
    (let ((writefreely-auth-token
           (password-store-get-field "Website/qua.name" "access_token")))
      (apply orig args)))
  (advice-add 'writefreely-publish-or-update
              :around #'mrb/writefreely-with-auth))

Hugo

I use hugo to sporadically publish some content to my blog. Not sure if this is what I want, I post too little to spend much time on it. But here is a small config for the main posts:

(use-package easy-hugo
  :init
  (setq ;; easy-hugo-basedir and easy-hugo-url are set in personal.el
        easy-hugo-postdir "content/post/main"
        easy-hugo-default-ext ".org"
        easy-hugo-org-header t))

Encrypting information in org-mode

I use the encrypt tag for encrypting sections in org-mode (and sometimes my journal). The sections get encrypted and decrypted automatically on saving and opening. This uses the EasyPG library to get to my GPG key.

(use-package org-crypt
  :custom
  (org-crypt-tag-matcher "encrypt")
  (org-crypt-key user-gpg-key)

  :config
  (org-crypt-use-before-save-magic))

We do not want to inherit this tag automatically, as its behavior is already subsection inclusive. When you encrypt a section, everything below it is considered content of that section and gets encrypted. I also add the value “crypt” as that is the org default, so it won't be inherited by mistake.

(add-to-list 'org-tags-exclude-from-inheritance '"encrypt")
(add-to-list 'org-tags-exclude-from-inheritance '"crypt")
(add-to-list 'org-tags-exclude-from-inheritance '"area")

Dynamic behaviour

Not sure where to put this configuration in general. The problem I'm trying to solve is to keep only relevant files in org-agenda. Too much and the performance collapses, too little and nothing can be found. I'm trying org-mem for this, and perhaps add org-node later on.

org-mem creates a cache of metadata about org-mode files. org-node uses this as a library and is similar to org-roam, which many people tout as a game changer in todo management, so I think it is worth to dedicate some time to it.

First step is to have org-agenda-files managed automatically based on the cache, filtered for relevance. It does not add much to my current setup, but has the advantage to have this cache available for other purposes (finding things much/much faster is the main thing).

(use-package org-mem
  :after org
  :custom
  ;; Basically list all folders which have org files in them.
  (org-mem-watch-dirs `(,org-directory "~/dat/blogs" "~/dat/caldata"))
  (org-mem-do-sync-with-org-id t) ;; not sure what this does exactly
  :config
  ;; Include in agenda-files:
  ;; - non archive named files
  ;; - not my org config file
  ;; - files with active timestamps
  ;; - having entries in /todo/ states
  ;; - having entries scheduled/deadlined
  (defun mrb/agenda-exclude-file-p (file)
    "Return t if FILE should be excluded from agenda."
    (or (string-search "archive" file)
        (string-search "org-config.org" file)))

  (defun mrb/agenda-relevant-entry-p (entry)
    "Return t if ENTRY has properties that make it relevant for the agenda."
    (or (org-mem-entry-active-timestamps entry)
        (org-mem-entry-todo-state entry)
        (org-mem-entry-closed entry)
        (org-mem-entry-scheduled entry)
        (org-mem-entry-deadline entry)))

  (defun mrb/set-agenda-files (&rest _)
    "Update `org-agenda-files' to include only files with relevant entries."
    (setq org-agenda-files
          (cl-loop for file in (org-mem-all-files)
                   unless (mrb/agenda-exclude-file-p file)
                   when (seq-find #'mrb/agenda-relevant-entry-p
                                  (org-mem-entries-in file))
                   collect file)))
  (add-hook 'org-mem-post-full-scan-functions #'mrb/set-agenda-files)
  ;; If org-agenda runs before the async scan completes, force mrb/set-agenda-files
  ;; using whatever org-mem has cached so far, rather than falling back to .agenda_files.
  (advice-add 'org-agenda :before
              (λ (&rest _)
                (when (and (stringp org-agenda-files) (org-mem-all-files))
                  (mrb/set-agenda-files))))
  (org-mem-updater-mode))

(use-package org-ql
  :bind ("C-c q" . org-ql-search)
  :config
  ;; org-ql-view calls org-time-string-to-absolute on scheduled/deadline timestamps
  ;; to compute face colors. Diary sexp timestamps (<%%(...)>) can't be parsed without
  ;; a reference date, causing an error. Advice returns the element unchanged instead,
  ;; so the entry is still shown, just without scheduled/deadline face coloring.
  (defun mrb/org-ql-view-safe-face (orig-fn element)
    "Call ORIG-FN on ELEMENT, skipping face if timestamp parsing fails."
    (condition-case nil
        (funcall orig-fn element)
      (error element)))
  (advice-add 'org-ql-view--add-scheduled-face :around #'mrb/org-ql-view-safe-face)
  (advice-add 'org-ql-view--add-deadline-face  :around #'mrb/org-ql-view-safe-face))

Old configuration

Below is what was contained in the old configuration. I will slowly migrate this into more literal sections

  ;; Bit of a leftover from reorganizing bits, do this later
  (add-to-list 'org-tags-exclude-from-inheritance '"sell")

  (defun mrb/has-todo-subtask-p ()
    "Return t if current heading has at least one TODO subtask."
    (let ((subtree-end (save-excursion (org-end-of-subtree t))))
      (save-excursion
        (forward-line 1)
        (catch 'found
          (while (and (< (point) subtree-end)
                      (re-search-forward "^\*+ " subtree-end t))
            (when (member (org-get-todo-state) org-todo-keywords-1)
              (throw 'found t)))))))

  (defun mrb/is-project-p ()
    "Return t if the current entry is a project.
A project is a heading with a TODO keyword and at least one TODO subtask."
    (and (member (nth 2 (org-heading-components)) org-todo-keywords-1)
         (mrb/has-todo-subtask-p)))

  ;; Generic skip helpers to reduce duplication
  (defun mrb/skip-unless (predicate)
    "Skip subtree unless PREDICATE returns true.
PREDICATE should be a function with no arguments that returns t or nil."
    (let ((subtree-end (save-excursion (org-end-of-subtree t))))
      (unless (funcall predicate) subtree-end)))

  (defun mrb/skip-non-projects ()
    "Skip trees that are not projects."
    (mrb/skip-unless #'mrb/is-project-p))

  (defvar org-agenda-group-by-property nil
    "Set this in org-mode agenda views to group tasks by property")

  (defun mrb/org-group-items-by-property (prop items)
    "Group ITEMS by the value of property PROP.
Returns a sorted alist of (PROP-VALUE . ITEMS) pairs."
    (let ((buckets (make-hash-table :test 'equal)))
      ;; Accumulate items into hash table buckets (O(n) with O(1) lookups)
      (dolist (item items)
        (let* ((marker (get-text-property 0 'org-marker item))
               (pvalue (org-entry-get marker prop t)))
          (puthash pvalue
                   (cons item (gethash pvalue buckets nil))
                   buckets)))
      ;; Convert hash to alist, reverse each bucket, and sort by property value
      (let ((result nil))
        (maphash (λ (key items)
                   (push (cons key (nreverse items)) result))
                 buckets)
        (sort result (λ (i1 i2)
                       (string< (first i1) (first i2)))))))

  (defun mrb/org-group-agenda-finalize (orig-fun list &optional nosort)
    "Prepare bucketed agenda entry lists.
Wraps `org-agenda-finalize-entries' to group entries by property."
    (if org-agenda-group-by-property
        ;; Collect parts in list, then join efficiently (O(n) instead of O(n²))
        (let ((parts nil))
          (dolist (bucket (mrb/org-group-items-by-property
                           org-agenda-group-by-property
                           list))
            (let ((header (concat "Property "
                                  org-agenda-group-by-property
                                  " is "
                                  (or (first bucket) "<nil>") ":\n")))
              (add-text-properties 0 (1- (length header))
                                   (list 'face 'org-agenda-structure)
                                   header)
              (push header parts)
              (push (let ((org-agenda-group-by-property nil))
                      (org-agenda-finalize-entries
                       (rest bucket) nosort))
                    parts)
              (push "\n\n" parts)))
          (mapconcat #'identity (nreverse parts) ""))
      (funcall orig-fun list nosort)))
  (advice-add 'org-agenda-finalize-entries :around #'mrb/org-group-agenda-finalize)

Markdown

Markdown is a sibling authoring format to org here, not a programming language. It shows up almost exclusively as a Claude Code substrate; transcripts, memory files, plan files, and slash-command output. Long-form personal authoring stays in org.

The mode of choice is md-mode, not markdown-mode, because its structural key set (heading fold, promote/demote, subtree move, list continuation, task toggle, table editing) mirrors org's. Working in a .md buffer then feels like a variant of org rather than a separate editor. markdown-ts-mode is not a candidate: it is a font-lock shell with no outline, promotion, or table support, and its own README points at markdown-mode instead.

Native features that replace prior add-ons: source-preserving table alignment (drops valign), live styling and rendered view (C-c C-v), and per-buffer TOC (C-c C-t).

Rendered tables are drawn as TextUI widgets, so that package is a hard requirement for C-c C-v on any document containing a table. It is not on MELPA, hence the explicit recipe.

(use-package textui
  :straight (:type git :host github :repo "yibie/textui"))

Claiming the Markdown file extensions

md-mode has to actively win auto-mode-alist. Both competitors register themselves unconditionally: markdown-mode arrives as a straight dependency (magit, flycheck and treesit-fold all reference it) and markdown-ts-mode ships inside Emacs. Neither can be prevented from loading.

auto-mode-alist is scanned front to back and add-to-list prepends, so the package that loads last owns the extension. That made this silently order-dependent: .md files were opening in markdown-mode while this section claimed otherwise, which is what made the Markdown setup look bare — it was not md-mode doing the rendering.

The fix is to drop the competing entries and re-assert ours, then repeat that after either competitor loads.

Where this runs matters. It cannot go in md-mode's :config, because that block only executes once md-mode has actually loaded — which only happens when a .md file opens, which is precisely what the claim is there to arrange. It therefore runs from :init, which executes when this config is read.

(defun mrb/md-claim-md-files ()
  "Give `md-mode' ownership of the Markdown extensions in `auto-mode-alist'."
  (setq auto-mode-alist
        (cl-remove-if (lambda (entry)
                        (memq (cdr entry)
                              '(markdown-mode gfm-mode
                                markdown-ts-mode markdown-ts-mode-maybe)))
                      auto-mode-alist))
  (dolist (pattern '("\\.mdwn\\'" "\\.mdown\\'" "\\.mkd\\'"
                     "\\.markdown\\'" "\\.md\\'"))
    (add-to-list 'auto-mode-alist (cons pattern #'md-mode))))

Folding and startup visibility

Org folds to two levels on open (org-startup-folded is 'show2levels here) and marks a folded subtree with org-ellipsis. Both carry over to md-mode, but the ellipsis needs explaining, because org's mechanism is not available.

Org draws its ellipsis by setting the org-ellipsis variable, which org's own folding code reads. Outline folding — which md-mode uses — predates that and gets its ellipsis from the display table instead: the selective-display slot holds what Emacs paints in place of hidden text. Nothing reads org-ellipsis there, so the string has to be copied across by hand.

A display table slot is not a string, though. It is a vector of glyph codes, one per character, where a glyph code packs a character together with a face id. Hence the mapcar: each character of org-ellipsis becomes (make-glyph-code c 'org-ellipsis) — the same character, tagged with org's ellipsis face — and vconcat turns that list into the vector the slot wants. Handling each character separately is what lets a multi-character ellipsis work; ours is the single glyph ⋯.

The table is per buffer, so this belongs on the mode hook rather than being set once globally.

(defun mrb/md-visual-setup ()
  "Show folded md sections with org's ellipsis, and fold like org does."
  (setq buffer-display-table (make-display-table))
  (set-display-table-slot
   buffer-display-table 'selective-display
   (vconcat (mapcar (lambda (c) (make-glyph-code c 'org-ellipsis))
                    org-ellipsis)))
  (outline-hide-sublevels 2))

Keeping the rendered view across a revert

md-mode puts md-mode-show-source on before-revert-hook: the buffer has to hold real source before the revert replaces it. Nothing puts the rendered view back afterwards, so every auto-revert of a file being previewed drops it to raw markup. Upstream does this deliberately — its own test asserts the post-revert buffer is unrendered — so this stays local rather than becoming a patch.

The flag has to be captured before md's hook clears it, hence depth -100: buffer-local hook entries run before global ones, and within the local list depth orders them. It is kept outside the buffer because a revert without preserve-modes re-runs md-mode, and kill-all-local-variables would take a buffer-local flag with it; for the same reason the restoring hook is global.

(defvar mrb/md-render-after-revert-buffer nil
  "Buffer whose rendered view is to be restored after the in-flight revert.")

(defun mrb/md-note-render-before-revert ()
  "Remember whether the revert about to run is tearing down a rendered view."
  (setq mrb/md-render-after-revert-buffer
        (and md-mode--rendered-p (current-buffer))))

(defun mrb/md-restore-render-after-revert ()
  "Re-render a buffer whose rendered view the revert discarded."
  (when (eq mrb/md-render-after-revert-buffer (current-buffer))
    (setq mrb/md-render-after-revert-buffer nil)
    (md-mode-render)))

The mode itself

Two settings carry most of the visual weight:

md-mode-use-markdown-mode-faces defaults to t, which buffer-locally remaps every md face onto a markdown-mode face whenever that package happens to be loaded — which it always is here. Those faces are almost entirely unstyled by base16-nord (it themes exactly two of them), so the remap replaced md's own org-derived faces with flat ones. Turning it off is what restores the styling.

md-mode-heading-scaling-values defaults to '(2.0 1.7 1.4 1.1 1.0 1.0). Org distinguishes heading levels by colour, not size, so all six are flattened to 1.0 and the faces inherit org-level-1 through org-level-6.

Rendered tables are measured in columns of the fixed-pitch face, which is pinned to the default family in the theme block above so the widths come out right.

Only one key needs adding for org parity — s-. for the task toggle, matching org-todo — plus C-c v to re-fold, mirroring the inline lambda the org block binds to the same key. Everything else (TAB, S-TAB, M-RET, M-<left>/<right>, M-<up>/<down>, C-c C-c, C-c C-n/p/u/f/b) already lands where org puts it.

(use-package md-mode
  :straight (:type git :host github :repo "yibie/md-mode")
  ;; Org is loaded eagerly well before this point, so :after does not defer
  ;; anything here — it documents that the faces and `org-ellipsis' below
  ;; need org's definitions to exist.
  :after org
  :mode ("\\.md\\'" . md-mode)
  ;; Table rows must stay on one screen line, so undo the visual-line-mode
  ;; that `text-mode-hook' turns on; the `when' keeps it a no-op if run twice.
  :hook ((md-mode . (λ () (when visual-line-mode (visual-line-mode -1))))
         (md-mode . (λ () (add-hook 'before-revert-hook
                                    #'mrb/md-note-render-before-revert
                                    -100 t)))
         (md-mode . mrb/md-visual-setup))

  :bind (:map md-mode-map
              ("s-." . md-mode-toggle-todo)
              ("C-c v" . (λ () (interactive) (outline-hide-sublevels 2))))

  :custom
  ;; Keep md's own org-derived faces; see above.
  (md-mode-use-markdown-mode-faces nil)
  ;; Levels differ by colour, not size, as in org.
  (md-mode-heading-scaling-values '(1.0 1.0 1.0 1.0 1.0 1.0))
  (md-mode-fold-front-matter-on-open t)
  (md-mode-toc-width 36)
  ;; Wrap long rendered lines instead of scrolling them off-window.
  (md-render-wrap-lines t)

  :custom-face
  (md-render-header-1 ((t (:inherit org-level-1 :weight bold :height 1.0))))
  (md-render-header-2 ((t (:inherit org-level-2 :weight bold :height 1.0))))
  (md-render-header-3 ((t (:inherit org-level-3 :weight bold :height 1.0))))
  (md-render-header-4 ((t (:inherit org-level-4 :weight bold :height 1.0))))
  (md-render-header-5 ((t (:inherit org-level-5 :weight bold :height 1.0))))
  (md-render-header-6 ((t (:inherit org-level-6 :weight bold :height 1.0))))
  (md-render-link ((t (:inherit org-link))))
  (md-render-blockquote ((t (:inherit org-quote))))
  (md-render-table-border ((t (:inherit org-table))))
  (md-render-table-header ((t (:inherit org-table :weight bold))))
  (md-render-table-zebra ((t (:background "#3b4252"))))
  (md-render-source-block-language ((t (:inherit (italic org-block-begin-line)))))

  :init
  ;; Must be :init, not :config: `:config' would not run until a .md file
  ;; opens, which is the very thing this arranges.
  (mrb/md-claim-md-files)
  (with-eval-after-load 'markdown-mode (mrb/md-claim-md-files))
  (with-eval-after-load 'markdown-ts-mode (mrb/md-claim-md-files))
  ;; Global: a revert without `preserve-modes' re-runs `md-mode', which
  ;; would kill a buffer-local entry added here.
  (add-hook 'after-revert-hook #'mrb/md-restore-render-after-revert))

Key and mouse bindings

Keyboard bindings are the primary way to interact for me. I have been struggling with consistent keyboard shortcuts and how to integrate them with the other systems on my machine which capture shortcut keys. At this time the following applications capture shortcut keys:

  1. the awesome window manager captures keys;
  2. xbindkeys provides a number of key bindings for application dependent operations;
  3. emacs (and obviously all other applications, but those are largely irrelevant).
  4. the X-windows server has the kbd extension which has some keyboard related things to configure.
  5. The linux kernel provides key mapping, so I have to look at that place too (xmodmap)

Because I am daft, here is the notation for the modifiers:

To help me out with this when writing about key bindings, the lisp function key-description can help out, with a little bit of glue around it:

(defun mrb/insert-key-description ()
  "Insert a pretty printed representation of a key sequence"
  (interactive)
  (insert (key-description (read-key-sequence "Type a key sequence:"))))

I like the explicit notation where the name of the key is spelled out better, and I'll move all configured keybindings to that eventually.

The right alt and the right key should be the same as the left alt and the super key, but I haven't gotten around to configuring that yet.

Furthermore, because I still can't remember keys, after pressing a prefix key like C-c the package which-keys can show me a formatted menu with the combinations of keys that can follow it.

                                        ; which keys shows menu with completions of prefix-key
(use-package which-key
  :diminish
  :custom
  (which-key-idle-delay 1.8)
  (which-key-show-operator-state-maps t)

  :config
  (which-key-mode))

Similarly, the casual package shows menu completions, when summoned, for several modes

(use-package casual)

For binding keys there are many ways it seems, with all different syntaxes and uses. I've tried to do everything with the bind-key package, because that is part of use-package so we get that for free.

Bind-key can define both global keys as map-based key settings and accepts all kinds of key specifications, including strings.

First, unsetting the keys I don't want.

Let's begin with killing some bindings which are in my way, notably the standard right mouse click behavior. This is because I want it to behave in org-mode, which apparently sets this. I should probably find out a better way for this.

(unbind-key "<mouse-3>")

;; Make `C-x C-m' and `C-x RET' be different (since I tend
;; to type the latter by accident sometimes.)
(unbind-key "C-x RET")

Setting keys

The standard open-line-splits the line, which is useful, but not what I want, so define a version which can open a line above or below the current line without changing the cursor position.

(defun mrb/openline (arg)
  "Open a new line above point, or below when ARG is non-nil."
  (interactive "P")
  (save-excursion
    (end-of-line (if arg nil 0))        ; with prefix, open line below, else above
    (open-line 1)))

;; should prefix be for above or below open line?
(bind-key "C-o" 'mrb/openline)

Not sure how to bind it, C-o seems kinda busy.

Key bindings

Global

I am running the emacs daemon and sometime when I quit emacs, I want it to quit too. This sounds a bit counter-intuitive, but as long as my emacs config is moving and I am not proficient enough in making sure I can apply the changed settings reliably from within emacs, restarting emacs is just easier. This saves me from having to kill the emacs daemon from the terminal.

(bind-key "C-x C-q" 'save-buffers-kill-emacs)

Probably the most important key is M-x (as set by default). That key gives access to other commands within emacs, so it better be effective. If I wasn't already used to it, I'd certainly not consider M-x as a first candidate. The main objection I have is that the two keys are close to each-other, making it hard to press in a typing flow.

Set of bindings on which I would like to have cut, copy and paste and friends:

;; this kinda sucks now, because the rest of the OS does not do this
;; SOLUTION: learn to work with standard emacs keybinding and adjust the OS  ?
(bind-keys ("s-z" . undo)
           ("s-x" . clipboard-kill-region)
           ("s-c" . clipboard-kill-ring-save)
           ("s-v" . yank)
           ("s-a" . mark-whole-buffer))

Some operations on buffers:

;; Buffer handling shortcuts
(bind-keys ("s-n" . (lambda () (interactive) (switch-to-buffer (generate-new-buffer "Untitled"))))
           ("s-s" . save-buffer)
           ("s-k" . kill-buffer))

And the rest, for now uncategorized:

;; Open my emacs config; I wanted something with '~'
(bind-key "C-~" 'mrb/open-config)

;; Font scaling, like in firefox
(bind-key "C-+" 'text-scale-increase)
(bind-key "C--" 'text-scale-decrease)

;; Line handling functions
(bind-key "s-`" 'toggle-truncate-lines)
;; Most of the time I want return to be newline and indent
;; Every mode can augment this at will obviously (org-mode does, for example)
(bind-key "RET" 'newline-and-indent)

;; Comment code lines, command reacts based on the major mode.
(bind-key "s-/" 'comment-dwim)

;; Keypad delete
(bind-key [(kp-delete)] 'delete-char)

What should have been in emacs

Sometimes there are logical gaps in emacs' keybinding. I put them here

(bind-keys :map help-map
           ("A" . describe-face))

Special keys

For some special keys I have defined some commands. Special keys are those keys that may not be on every keyboard, within reason. I consider the function keys also as special, although they do not fit the previous definition.

;; Menu key does M-x, if we have it.
;;(bind-key (kbd "<apps>") 'execute-extended-command)
(bind-key "<f1>" 'help-command)
(bind-key "<f2>" 'save-buffer)
(bind-key "<f4>" 'find-file)

Resizing and switching windows and frames

Treading cautiously here as ideally frames sizing is the responsibility of the window manager.

;; Moving back and forth in windows For now, I'm using the Fn Key +
;; Arrows, seems consistent with the other window movements
(bind-key [XF86AudioNext] 'next-multiframe-window)
(bind-key [XF86AudioPrev] 'previous-multiframe-window)

;; Alt-Cmd left-right arrows browse through buffers within the same frame
(bind-key "<M-s-left>"  'previous-buffer)
(bind-key "<M-s-right>" 'next-buffer)

;; These would conflict with awesome bindings, perhaps we should change those bindings
;; (bind-key "<C-S-left>"  'buf-move-left)
;; (bind-key "<C-S-right>" 'buf-move-right)

;; Awesome uses Super+Arrows to move between its 'frames'
;; Emacs   uses Shift-Super+Arrows to move between its windows
(bind-key "<S-s-right>" 'windmove-right)
(bind-key "<S-s-left>"  'windmove-left)
(bind-key "<S-s-up>"    'windmove-up)
(bind-key "<S-s-down>"  'windmove-down)

For active regions

In quite a few situations I would like to use keybindings which are only valid when a selection/region is active.

We can bind keys into the selected-keymap if we want keys to be active on every region. When only needed for a certain major mode, define a keymap named selected-<major-mode>-map and bind keys into that.

Within orgmode I'd like to have the emphasis markers react when a region is active, so let's create the necessary fragment for those bindings. These should in theory also work in org-journal mode, but they don't.

During mail composition, when deleting text, this should be replace by '[…]' to signal that we removed text from the original mail.

(use-package selected
  :diminish selected-minor-mode
  :after (org org-journal mu4e)                        ; all modes we supply binding for
  :demand t                             ; So we can use global mode enable in :config
  :commands (selected-global-mode selected-minor-mode) ; redundant?
  :init
  (setq selected-org-mode-map         (make-sparse-keymap)
        selected-mu4e-compose-mode-map (make-sparse-keymap))

  ;; Signal a deletion in compose
  ;; TODO  do something clever with the quote level?
  (defun mrb/compose-mark-deletion ()
    "Delete selected text and insert '[...]' to indicate elided content."
    (interactive)
    (delete-active-region)
    (insert "[...]"))

  :bind ( :map selected-keymap           ; bindings for all active regions
          ("C-q" . selected-off)
          ("C-u" . upcase-region)
          ("C-d" . downcase-region)
          :map selected-org-mode-map
          ("~" . (λ () (interactive) (org-emphasize ?~)))
          ("_" . (λ () (interactive) (org-emphasize ?_)))
          ("*" . (λ () (interactive) (org-emphasize ?*)))
          ("C-b" . (λ () (interactive) (org-emphasize ?*)))
          ("+" . (λ () (interactive) (org-emphasize ?+)))
          ("=" . (λ () (interactive) (org-emphasize ?=)))
          ("/" . (λ () (interactive) (org-emphasize ?/)))
          :map selected-mu4e-compose-mode-map
          ("." . mrb/compose-mark-deletion))

  :config
  (selected-global-mode))

Emacs has, like for everything else, a peculiar idea on scrolling and moving from screen to screen.

These settings work better for me.

I have my keyboard repeat rate rather quick; this helps by moving the cursor fast. It also means that if I press a key like backspace things disappear quite quickly, so it's important that what happens on the screen is 'real-time'. The effect I want to prevent is that when releasing the backspace key, the cursor keeps on going and deletes way more than needed. I think, by default, this is properly configured, and there used to be a variable for it, which has now been obsoleted.

When scrolling, I don't tend to think in half-screens like emacs does, I just want the text in the window to move up or down without having to guess where it's going to be. Make sure we scroll 1 line and have a small, or none at all, scroll-margin. Having both at a value of 1 is intuitive.

(setq scroll-margin 1  scroll-step 1)

Make sure that the scroll wheel scrolls the window that the mouse pointer is over and that we are scrolling 1 line at a time. I don't use any modifiers with the scroll wheel.

(xterm-mouse-mode)
(setq mouse-wheel-follow-mouse 't)
(setq mouse-wheel-scroll-amount '(1 ((shift) . 1)))
(setq focus-follows-mouse t) ;; i3wm changes focus automatically

Terminals, Character encodings and emulation

My policy is to use Emacs for as many things and use as little other programs as necessary. All this within reason obviously.

This sections describes how terminals and shells are used from within Emacs. In an ideal situation I won't be needing any other terminal emulator program, other than the ones used directly from Emacs.

Terminal

While eshell is the more emacsy solution, it has some limitations which makes me reach for xterm for daily work. However, there's still a good option to benefit from all the emacs configuration in that case and that is the vterm package. This is an interface to the libvterm library creating a real terminal emulation using emacs for display.

The basic install is easy, but does need an emacs compiled with module support.

(use-package vterm
  ;;:straight (:type built-in)
  :if (fboundp'module-load)
  :custom-face
  (vterm-color-black   ((t (:foreground "#2e3440"))))
  (vterm-color-red     ((t (:foreground "#bf616a"))))
  (vterm-color-green   ((t (:foreground "#a3be8c"))))
  (vterm-color-yellow  ((t (:foreground "#ebcb8b"))))
  (vterm-color-blue    ((t (:foreground "#81a1c1"))))
  (vterm-color-cyan    ((t (:foreground "#88c0c0"))))
  (vterm-color-magenta ((t (:foreground "#b48ead"))))
  :custom
  (vterm-timer-delay 0)
  (vterm-min-window-width 0)  ;; Allow vterm to adapt to any window width
  :config
  ;; Note: DFT_BACKGROUND=light produces correct difftastic colors despite dark terminal background
  (setenv "DFT_BACKGROUND" "light"))

Eshell keeps its control files in a directory of its own. It has to be set through :custom so it applies before the package loads: eshell-aliases-file, eshell-history-file-name, eshell-last-dir-ring-file-name, eshell-login-script and eshell-rc-script are all separate defcustoms that compute their defaults from this one at load time, so setting it afterwards would leave those five pointing at the old location.

(use-package eshell
  :straight (:type built-in)
  :defer t
  :custom
  (eshell-directory-name (mrb/emacs-file ".state/eshell/")))

To benefit from some of the most useful features, there is a little bit of shell configuration involved. The package includes a shell script for that which I am sourcing in my .zshrc configuration file.

The vterm brings me some advantages, even close to being able to replace xterm for daily use, for example:

The most important downside is that if emacs does weird things, like hang or crash, this will affect all my terminals too, which is unworkable in some situations, so xterm is not going anywhere soon (but eshell is out I think)

Process handling

Sometimes processes get stuck and i want a way to delete those processes easily.

(defun mrb/delete-process-interactive ()
  "Based on an auto-completed list of process, choose one process to kill"
  (interactive)
  (let ((pname (completing-read "Process Name: "
                                (mapcar 'process-name (process-list)))))
    (delete-process (get-process pname))))

Log files

Although handling log files is still best done in an actual terminal, not emacs, sometimes there is a need for it. The journalctl package is configured for that.

(use-package journalctl-mode
  :bind (("C-x j" . journalctl)))

Completion

There are 2 types of completion:

  1. Input completion in the minibuffer
  2. Inline completion in another buffer

I want completion to work as follows:

  1. completion functions are always bound to a keybinding involving the TAB-key, with as little modifiers as possible;
  2. completion should always produce something, even if emacs has no special semantic knowledge of the current mode, it should produce something which makes sense;
  3. completion should be inline whenever possible.
  4. for each mode, a specialization is ok, if that improves the situation; I expect to have many specializations to improve the auto-complete quality;
  5. if a completion window must be opened, do this at the same place always and do not mess up other windows.
  6. Completion should behave somewhat like real-time systems. An answer must be produced within a certain amount of time. If a completion answer takes longer than the amount of type to type it in full, the system has collapsed, so the time needs to be in the order of one third of the typing time.

The next sections deal with the above requirements

Ad 1. Bind completion always involves TAB-key

The package smart-tab seems to fit this bill, but the thing that I care about can be achieved fine without it (I only found this out after considerable time using smart-tab).

So, tab tries to indent, which is the main expectation, and if it can't it tries to complete stuff.

(setq tab-always-indent 'complete)

There are some situations where tab does completion (which is good), but the type of completion is not what I want (which is bad). Currently this includes only quail-completion of which I don't even know what it is for. This code prevents having a quail completion window when pressing tab inline.

                                        ; Bit of a kludge, but when quail loads, get rid of the completion
(with-eval-after-load 'quail (defun quail-completion ()))

In a standard emacs installation, TAB indents, depending on mode obviously. If indenting would not make sense, a TAB can be inserted or completion could start. The function completion-at-point is used in some situations. Ideally the corfu-complete function could take over in many cases. Here's a simplistic approach to get me started:

  1. if in minibuffer, do completion there like we are used to;
  2. if cursor is at the end of a symbol, try to complete it with corfu;
  3. else, indent according to mode.

This is probably incomplete or wrong even in some cases, but it's a start.

This way, TAB always does completion or indent, unless corfu-mode is not active.

Ad 2. Completion should always produce something

Not sure if there is anything to do here.

Ad 3. Inline completion when possible

With inline completion I mean without opening a whole new *Completions* window if not needed.

Content that I want:

Candidates:

All of these work fine, but corfu gets installed because it fits right in with the rest of the completion packages and does more than enough for what I need. Given the quick popup is nice to complete inline, but when still confused, press M-m to move the completion list to the minibuffer (or whatever vertico has been configured to) to show the list and have more info on the items in the list, while competion still continues.

M-m runs the command corfu's README suggests for this, under an mrb/ name: it hands the current completion to consult-completion-in-region, so the candidates show in the minibuffer UI. It is in corfu-continue-commands so corfu does not quit the completion before the command runs.

(use-package corfu
  :custom
  (corfu-auto t)
  (corfu-auto-delay 0.5)  ; increased from default 0.2s to reduce typing lag
  (corfu-quit-at-boundary 'separator)
  (corfu-cycle t)


  :bind ( :map corfu-map
          ("M-m" . mrb/corfu-move-to-minibuffer))
  :preface
  (defun mrb/corfu-move-to-minibuffer ()
    "Continue the current in-buffer completion in the minibuffer."
    (interactive)
    (pcase completion-in-region--data
      (`(,beg ,end ,table ,pred ,extras)
       (let ((completion-extra-properties extras)
             completion-cycle-threshold completion-cycling)
         (consult-completion-in-region beg end table pred)))))
  :init
  ;; Enable it globally
  (global-corfu-mode 1)

  ;; But not for some
  (setq global-corfu-modes
        '(message-mode
          (not text-mode)
          t))
  :config
  (add-to-list 'corfu-continue-commands #'mrb/corfu-move-to-minibuffer))

Ad 4. Mode specialization

There's always exceptions to the rule; with Emacs doubly so. Here's the relevant exceptions for my configuration.

Ad 5. Open completion window predictably

The configuration of the completion is handled by vertico now, so nothing needs to be done here.

For most of the completion that is needed which is not inline (for which I am using the corfu package above, helm seems to be the most powerful solution, but feels isolated. For every integration something extra seems to be needed. Vertico seems the completing framework that fits the integration requirement best; it clearly states so in its goal to only depending on the Emacs API and not create a new one. Integrating it with others looks a lot more orthogonal than Helm.

(use-package vertico
  :demand t
  :straight (vertico :files (:defaults "extensions/*")
                     :includes (vertico-reverse vertico-multiform))

  :custom-face
  (vertico-current ((t (:background "#5e81ac"))))
  :config
  (vertico-mode))

As M-x or execute-extended-command is probably the most used command in Emacs, I want to give it a bit more prominent place on the scree than just the tiney minibuffer at the bottom. The vertico-posframe package turns the minibuffer into a floating popup sort of screen by using posframe. This puts the minibuffer more in my face and gives some better options to present the command information.

(use-package vertico-posframe
  ;; Customize border face to same color as cursor
  :custom-face
  (vertico-posframe-border ((t (:background "DarkOrange" :inherit default))))
  :config
  (setq vertico-posframe-parameters
        `((left-fringe . 18)
          (right-fringe . 18)
          (border-width . 4)
          (border-color . ,mrb/cursor-color)
          (child-frame-border-width . 4)))

  (setq vertico-multiform-commands
        '((consult-line posframe (vertico-posframe-fallback-mode . vertico-buffer-mode))
          (t posframe)))

  (vertico-multiform-mode 1))

The first thing to complement vertico is the orderless package. My main use is to have the matching for completing items match 'first second' as well as 'second first' regardless of their order. Note that this works for every completion, also within corfu which is especially useful.

(use-package orderless
  :custom-face
  (orderless-match-face-0 ((t (:foreground "dark orange"))))
  :custom
  (completion-styles '(orderless basic)))

The default list of candidates can be nicely augmented with marginalia which shows extra info about the matching items in the right aligned margin.

(use-package marginalia
  :custom
  (marginalia-align 'right)

  :init
  (marginalia-mode))

The consult package exposes a number of consult- commands which work with the completing framework and typically put an original command on steroids. Some of the commands support live previewing while browsing the selections.

(use-package consult
  ;; Replace some bindings with a consult command, so we get things on steroids
  :bind (("C-x b" . consult-buffer)))

Embark is the last of the set, and adds a new level of interaction. The basic principle normally in Emacs is that you specify a command and then tell what to act on. Like opening a file is the `find-file` command and then specifying what file to find.

Embark is the other way around. Something is active, depending on context and `embark-act` give a list of things you can do with it.


(use-package embark
  :straight (embark :files (:defaults "embark-org.el"))

  :bind
  (("C-." . embark-act)         ;; pick some comfortable binding
   ("C-;" . embark-dwim)        ;; good alternative: M-.
   ("C-h B" . embark-bindings)) ;; alternative for `describe-bindings'

  :init
  ;; Optionally replace the key help with a completing-read interface
  (setq prefix-help-command #'embark-prefix-help-command)

  :config
  ;; Hide the mode line of the Embark live/completions buffers
  (add-to-list 'display-buffer-alist
               '("\\`\\*Embark Collect \\(Live\\|Completions\\)\\*"
                 nil
                 (window-parameters (mode-line-format . none)))))

;; Consult users will also want the embark-consult package.
(use-package embark-consult
  :ensure t ; only need to install it, embark loads it after consult if found
  :hook
  (embark-collect-mode . consult-preview-at-point-mode))

Ad 6. Guaranteed response-time

I haven't found a solution for this yet, but also have not found it to be a problem that needs solving in practice so far.

Editing control

Editing control are generic features which make editting easier, faster, more productive etcetera

Techniques in use by me:

Multiple cursors is a package which turns 1 cursor into many, depending on the active region, and enters insertions at all cursor locations.

(use-package multiple-cursors
  :custom
  (mc/list-file (mrb/emacs-file ".state/mc-lists.el"))
  :bind (("C-c e" . 'mc/edit-lines)))

Tempel is a templating system, with templates being lisp data, which hooks into modes and autocompletion system. The folder ~`emacs-user-dir`/templates holds *.eld files which specify templates which get autoloaded based on the major mode of the current buffer.

I set it up for relevant modes as a CAPF handler (for programming and orgmode for now)

(use-package tempel
  :custom
  (tempel-path (list (mrb/emacs-file "templates/*.eld"))) ; tempel-path must be list

  :bind (("M-<insert>" . tempel-complete))

  :after (org)

  :init

  (setq org-tab-before-tab-emulation-hook nil)
  (defun mrb/tempel-setup-capf()
    ;; Remove org structured templates, which are just tempo templates as well
    ;; TODO make this smarter? (there might be other things in there which I *do* need)
    (setq org-tab-before-tab-emulation-hook nil)
    (setq-local completion-at-point-functions
                (cons #'tempel-complete ; use tempel-expand if only exact matches wanted
                      completion-at-point-functions)))

  ;; Do template name completion in programming, textmodes and orgmode
  ;; TODO why not global?
  :hook ((prog-mode text-mode org-mode) . mrb/tempel-setup-capf))

Formatting a file properly is trusted by indent rules, this one indents the whole buffer at once, making formatting completely proper 🫠

(defun mrb/indent-whole-buffer ()
  "Indent the entire buffer without affecting point or mark."
  (interactive)
  (save-excursion
    (save-restriction
      (indent-region (point-min) (point-max)))))

(global-set-key (kbd "C-c TAB") 'mrb/indent-whole-buffer)

move-text moves the current line or active region up and down. The indent advice re-indents after each move according to the current mode.

(use-package move-text
  :config
  (move-text-default-bindings)
  (defun mrb/move-text-indent-advice (&rest ignored)
    "Re-indent the moved line or region according to the current mode."
    (let ((deactivate deactivate-mark))
      (if (region-active-p)
          (indent-region (region-beginning) (region-end))
        (indent-region (line-beginning-position) (line-end-position)))
      (setq deactivate-mark deactivate)))
  (advice-add 'move-text-up :after #'mrb/move-text-indent-advice)
  (advice-add 'move-text-down :after #'mrb/move-text-indent-advice))

Navigating pieces of text effectively is probably the best optimization to make in the emacs configuration.

In my browser I use vimium to jump to links with the keyboard. In emacs avy does something similar. On pressing the hotkey, all matches of the first char typed are highlighted (frame wide) by a single character. By pressing that character the cursor is place there directly. This makes within-frame navigation a 3-key operation, which is considerably faster than anything else.

;; Bind super-j globally to jump quickly to anything in view
(use-package avy
  :bind (("s-j" . 'avy-goto-char)))

In a more general sense, evil-mode, the VI emulation layer on top of emacs, is the ultimate navigational package. I have tried this for about 6 months, but it's not for me (yet).

Searching

Incremental search (isearch) is a core navigation tool for finding text in buffers. The default faces don't provide enough visual distinction between the current match and other matches. With better color contrast, it's immediately clear which match is selected and where the other matches are located.

;; Improve search highlighting to clearly distinguish current match from others
(use-package isearch
  :straight (:type built-in)
  :custom-face
  ;; Current match: bright yellow text on dark background for maximum visibility
  ;; Other matches: bright purple text on dark background for clear distinction
  (isearch                        ((t (:foreground "#f0d77d" :background "#2e3440"))))
  (lazy-highlight                 ((t (:foreground "#b988dd" :background "#2e3440")))))

Outlining

Outline mode provides a way to structure text hierarchically. The outline heading levels use distinct colors to visually separate different depth levels, making it easy to navigate document structure at a glance.

Provide base faces for the outline levels, packages supporting some form of outlining should inherit from these.

(use-package outline
  :straight (:type built-in)
  :custom-face
  (outline-1 ((t (:foreground "#5e81ac"))))
  (outline-2 ((t (:foreground "#a3be8c"))))
  (outline-3 ((t (:foreground "#b48ead"))))
  (outline-4 ((t (:foreground "#81a1c1"))))
  (outline-5 ((t (:foreground "#ebcb8b"))))
  (outline-6 ((t (:foreground "#8fbcbb"))))
  (outline-7 ((t (:foreground "#d08770"))))
  (outline-8 ((t (:foreground "#bf616a")))))

Remote editing

As my default shell is zsh tramp does not work out of the box for me. It gets confused by my prompt, so on top of my .zshrc file I have the following line:

# Immediately bail out if we are coming in with a dumb terminal
# which, in my case, mostly means TRAMP is asking for something.
[[ $TERM == "dumb" ]] && unsetopt zle && PS1='$ ' && return

Once that is in place files can be opened with /ssh:/host:/path/to/file syntax. Surprisingly, the syntax I got used to in the past (i.e. /host:/path/to/file) does not work for me anymore.

The tramp settings themselves (sshx as default method, a dumb terminal, the proxy for root@ha, LC_ALL=C on connections) are in the tramp block under 6.1, because docker loads tramp at startup and its cache file has to be set before that.

Browser integration

My default browser, as set in the OS, should be started automatically on opening http: like stuff and html files. The only link to the outside world is specifying that xdg-open should figure out what program to use.

(setq browse-url-browser-function 'browse-url-generic)
(setq browse-url-generic-program "xdg-open")

Not exactly browser integration, but 'openstreetmap browsing' is close enough. I would like to use this for planning trips, so something like a 'set of view settings' for this package would be nice. This would allow me to gather maps views, augment them with a gpx route and bookmarks. Not sure if gpx waypoints/POI sets would work.

The package as is, is already useful for searching locations and storing links to locations.

(use-package osm
  :straight (:host github :repo "minad/osm")
  :commands (osm osm-mode)
  :bind ( :map osm-mode-map
          ("q" . (λ() (interactive) (quit-window t))))
  :init
  ;; Load Org link support
  (with-eval-after-load 'org
    (require 'osm-ol))

  :custom
  ;; set proper home location, TODO  read this from private store
  (osm-home '(51.6441759 4.4377029 17))
  (osm-copyright nil)
  (osm-tile-directory (mrb/emacs-file ".cache/osm/"))

  :config
  ;; Tile-server URLs need API keys from password-store. Resolve them
  ;; lazily at first `M-x osm' so the daemon doesn't block on a
  ;; YubiKey/pinentry prompt during startup (osm itself only
  ;; autoloads on demand via :commands).
  (let* ((map5-base (concat "https://s.map5.nl/map/"
                            (password-store-get "API/map5.nl")
                            "/tiles/")))
    (setq osm-outdoor-url
          (concat "https://tile.thunderforest.com/outdoors/{%z}/{%x}/{%y}.png?apikey="
                  (password-store-get "API/tile.thunderforest.com"))
          osm-map5-opentopo-url
          (concat map5-base "opentopo/EPSG900913/{%z}/{%x}/{%y}.jpeg")
          osm-map5-opensimpletopo-url
          (concat map5-base "opensimpletopo/EPSG900913/{%z}/{%x}/{%y}.jpeg")
          osm-map5-openlufo-url
          (concat map5-base "openlufo/EPSG900913/{%z}/{%x}/{%y}.jpeg")
          osm-server-list
          `((default
             :name "Mapnik"
             :description "Standard Mapnik map provided by OpenStreetMap"
             :url "https://%s.tile.openstreetmap.org/%z/%x/%y.png"
             :group "Standard")
            (outdoor :name "Outdoor"
                     :description "Outdoor focussed maps"
                     :url ,osm-outdoor-url
                     :group "Personal")
            (opentopo :name "OpenTopo NL"
                      :description "Map5.nl OpenTopo"
                      :url ,osm-map5-opentopo-url
                      :group "Personal")
            (opensimpletopo  :name "OpenSimpleTopo NL"
                             :description "Map5.nl OpenSimpleTopo"
                             :url ,osm-map5-opensimpletopo-url
                             :group "Personal")
            (openlufo  :name "OpenLufo NL"
                       :description "Map5.nl OpenLufo"
                       :url ,osm-map5-openlufo-url
                       :group "Personal")))))

Messaging and chatting

Mail

Mail setup, converged on mu4e for reading/composing over the built-in message layer, with format=flowed hardening applied on both encode and decode axes. Kept in this order: composition helpers first, then transport (smtpmail, mml-sec), then the generic message settings that any MUA inherits, then the flowed-hardening block, then the mu4e use-package itself.

Composing mail

Composing mail is often an out of band activity, like creating a tweet or a capture, so I would like to have roughly the same behavior. This is by default provided by compose-mail-other-frame, which in turn calls the right mua to do the job. Oddly enough, it is still required to have mu4e-compose-in-new-frame set to actually have another frame, so I'm writing it with mu4e-compose directly.

:bind (("C-x m" . 'mrb/compose-mail)
       ( :map mu4e-compose-mode-map
         ("C-;" . mu4e-compose-context-switch)))

:custom
(mu4e-compose-format-flowed t)
(mu4e-compose-complete-only-after nil) ; no limit on contact completion for now

:config
(defun mrb/compose-mail (&optional mailto-url)
  "Run mail-compose, use mailto URI if it is given."
  (interactive)

  ;; If we have a mailto argument, parse and use it
  (if (and (stringp mailto-url)
           (string-match "\\`mailto:" mailto-url))
      (browse-url-mail mailto-url)
    ;; No mailto, argument, just run the mua
    (mu4e-compose-new)))

(defun mrb/mailfile()
  "Compose mail message from current buffer file, typically a pdf is viewed for my use-case"
  (interactive)
  (if-let ((file buffer-file-name))
      (let ((mimetype (mm-default-file-type file)))
        (mu4e-compose-new)
        (mml-attach-file file mimetype (concat mimetype " attachment") "attachment"))
    (user-error "Current buffer is not visiting a file")))

;; Extend attaching files with extra info insertion
<<extend-attach>>

To be able to use the mrb/compose-mail function as a mailto handler we need to be able to call it from outside of emacs. Let's define a small shell script that does exactly this. The SRC attributes tangle it into my bin directory where is will be in the path. In the OS, this script will need to be set as the default mail handler.

# Emacs mailto URI handler

# Make sure mailto: is always prepended to $1
mailto="mailto:${1#mailto:}"
mailto=$(printf '%s\n' "$mailto" | sed -e 's/[\"]/\\&/g')

# Call the elisp function handling our mailto URI
elisp_expr="(mrb/compose-mail \"$mailto\")"

# Do not create new frame, because mu4e does that already
# which is a simpler solution in this case
edit-noframe --eval "$elisp_expr"

On top of that, I want a manual capture script which is basically the same as the mailto handler.

edit -e '(mrb/compose-mail)'

Attachments

When attaching files, it is sometimes useful to extract some info from the attached file and include it in the message. My main usecase is extracting PDF annotations (mostly for people who cannot process the annotations in their PDF viewer directly.

The way I have implemented this is by advicing the function mml-insert-empty-tag which is used by the mml-attach-file function. At that point in the code the file and point in the active buffer are known, and we just have to process the extra actions. The advice function extracts the annotations from the pdf file (unconditionally at the moment) and inserts a rendering of them just before inserting the attachment itself.

  :config
  (defun mrb/pdf-extract-info (file)
    "Extract and render the pdf annotations in FILE"
    (mapc
     (λ (annot) ;; traverse all annotations
       (progn
         (let ((page (cdr (assoc 'page annot)))
               (highlighted-text
                (if (pdf-annot-get annot 'markup-edges)
                    (let ((highlighted-text
                           (pdf-info-gettext (pdf-annot-get annot 'page)
                                             (pdf-tools-org-edges-to-region
                                              (pdf-annot-get annot 'markup-edges))
                                             t
                                             file)))
                      (replace-regexp-in-string "\n" " " highlighted-text))
                  nil))
               (note (pdf-annot-get annot 'contents))
               (type (pdf-annot-get annot 'type))) ; underline, strike-through etc.
           ;; Stuff gathered, start rendering
           (when (or highlighted-text (> (length note) 0))
             (insert (format "\n- page %s" page))

             (when highlighted-text
               (insert (format ": “%s”\n" highlighted-text)))

             (if (> (length note) 0)
                 (insert (format "\n  %s\n" note))
               (insert "\n" ))))))
     ;; list to traverse, second arg of mapc
     (cl-remove-if                          ; don't process links?
      (λ (annot) (member (pdf-annot-get-type annot) (list 'link)))
      ;; file is typically already visited
      (pdf-annot-getannots nil '() (find-file-noselect file)))
     ))


  (defun mrb/extended-attach-file (name &rest plist)
    "Advice `:before' `mml-insert-empty-tag' to inline PDF annotations.
Soft-loads `pdf-tools' on demand — a machine without pdf-tools installed
attaches the file as usual and skips the annotation render."
    (let* ((file (plist-get plist 'filename))
           (mimetype (if file (mm-default-file-type file) nil)))
      (when (and (string= mimetype "application/pdf")
                 (require 'pdf-tools nil t))
        (mrb/pdf-extract-info file))))

  (advice-add
   'mml-insert-empty-tag                  ; a lot easier because we already know the file
   :before
   'mrb/extended-attach-file
   '((name . "extended-attach")))

Sending mail

Sending mail through smtp, using the smtpmail package

(use-package smtpmail
  :straight (:type built-in)
  :custom
  (smtpmail-default-smtp-server "localhost")
  (smtpmail-smtp-service 25)
  (smtpmail-local-domain user-domain)
  (smtpmail-sendto-domain user-domain)
  ;; User agent style is message mode by default, specific mail packages may override this
  (mail-user-agent 'message-user-agent)
  (send-mail-function 'smtpmail-send-it) ;This is for mail-mode
  (message-send-mail-function 'message-smtpmail-send-it) ; This is for message-mode

  (password-cache t)            ; default is true, so no need to set this actually
  (password-cache-expiry 28800)) ; default is 16 seconds, which is ridiculously low

At times we want to send signed and/or encrypted mail

(use-package mml-sec
  :straight (:type built-in)
  :config
  ;; Use my key to sign messages and make it safe if other keys exist
  (add-to-list 'mml-secure-openpgp-signers user-gpg-key)
  (setq mml-secure-key-preferences
        '((OpenPGP
           (sign)
           (encrypt
            (user-mail-address user-gpg-key)))
          (CMS
           (sign)
           (encrypt))))

  ;; Add my bcc address to list of safe addresses in bcc for secure message
  (add-to-list 'mml-secure-safe-bcc-list mrb/bcc-address)

  ;; Always encrypt to self, so I can read my own messages
  (setq mml-secure-openpgp-encrypt-to-self `(,user-gpg-key)))

Generic mail message settings

Cross-MUA settings that live in message.el — citation, signatures, alternative addresses, hidden headers. Applied via a hook so mu4e (and any other message-derived MUA) picks them up.

(use-package message
  :demand t ; mu4e won't load otherwise
  :straight (:type built-in)

  :init
  (defun mrb/message-mode-hook ()
    "Configure a `message-mode' compose buffer."
    ;; No reflowing is possible without hard newlines
    (use-hard-newlines t 'always)

    ;; Visual line-wrap at word boundaries; matches encode-side wrap.
    (visual-line-mode 1)
    (auto-fill-mode 0)                          ; makes no sense in visual mode
    (setq-local adaptive-fill-mode nil)         ; compose-side only; encoder/decoder are guarded separately (see RFC 3676 hardening)

    (setq-local visual-fill-column-width nil)   ; make sure `fill-column' is used
    (setq-local fill-column 80)                 ; compose-side; encode wraps at `fill-flowed-encode-column'
    (visual-fill-column-mode 1))

  :hook (message-mode . mrb/message-mode-hook)

  :custom
  (message-dont-reply-to-names
   (regexp-opt (cons mrb/default-email-address mrb/private-addresses))
   "Don't keep any of my addresses on wide reply.")
  ;; Encoder runs in a temp buffer; these must be global.
  (fill-flowed-encode-column 66)
  (mml-enable-flowed t)

  (message-signature-directory mrb/maildir)
  (message-signature-file mrb/default-signature-file)
  (message-alternative-emails
   (regexp-opt mrb/private-addresses)
   "Steers From header when replying; overlaps with mu4e contexts.")
  (message-cite-function 'message-cite-original-without-signature)
  (message-yank-prefix "> ")
  (message-yank-cited-prefix ">")
  (message-yank-empty-prefix "")
  ;; mu4e sets message-hidden-headers locally from mu4e-compose-hidden-headers
  (message-hidden-headers '(not "To:" "From:" "Cc:" "Subject:"))
  (message-citation-line-format "[%N]:")
  (message-citation-line-function 'message-insert-formatted-citation-line)
  (message-kill-buffer-on-exit t)

  :config
  ;; message-openpgp-header is set in personal.el
  (add-hook 'message-header-setup-hook 'message-add-openpgp-header))

RFC 3676 hardening

Three fixes for format=flowed edge cases that upstream message / flow-fill mishandle in specific combinations: adaptive-fill inventing hanging indents on encode and decode, per-line unstuffing missed by stock fill-flowed, and stray trailing whitespace in the compose buffer being encoded as unintended soft breaks. Each guard has companion tests under tests/.

The two flow-fill guards below are fragments of this one block:

(use-package flow-fill
  :straight (:type built-in)
  :demand t
  :config
  <<flow-fill-no-adaptive>>
  <<flow-fill-pre-unstuff>>)

Adaptive-fill guard (encode + decode)

fill-flowed-encode is invoked by mml-generate-mime-1 inside a with-temp-buffer that copies the compose body via insert-buffer-substring. Text properties (including hard newline markers) survive that transfer; buffer-local variables — such as our mrb/message-mode-hook's adaptive-fill-mode nil — do not. The temp buffer therefore runs with the default adaptive-fill-mode t, and fill-region inside fill-flowed-fill-buffer infers a hanging-indent fill-prefix from patterns like =– = at the start of a paragraph, inventing continuation-line indentation the user never typed. Strict-spec receivers join across soft breaks and preserve those spaces as content, producing spurious multi-space runs mid-sentence.

The mirror bug exists on the decode axis: after joining soft-broken lines into a paragraph, fill-flowed calls fill-region to re-wrap for display. Even with an explicit empty fill-prefix, fill-region still consults adaptive-fill-mode and infers the same hanging-indent prefix from a =– = marker at the paragraph start — so wrapped continuation lines acquire a leading space that wasn't in the sender's content.

Same fix for both: an :around advice binding adaptive-fill-mode to nil for the duration of the call. Companion tests: tests/test-flowed-encoder.el and tests/test-flowed-decoder.el.

(defun mrb/fill-flowed--no-adaptive (orig &rest args)
  "Bind `adaptive-fill-mode' to nil around ORIG (a flow-fill entry point)."
  (let ((adaptive-fill-mode nil))
    (apply orig args)))

(advice-add 'fill-flowed-encode :around #'mrb/fill-flowed--no-adaptive)
(advice-add 'fill-flowed        :around #'mrb/fill-flowed--no-adaptive)

Per-line unstuff (RFC 3676 §4.4)

RFC 3676 §4.4 requires the receiver to strip one leading SP from every body line beginning with SP (“space-unstuffing”). Stock fill-flowed only performs this on paragraph-initial lines via its outer-loop unstuff; continuation lines joined inside the inner flow-join loop keep their stuffed SP as content, producing one spurious SP at every soft-join.

A :before advice unstuffs continuation lines only — identified by the previous line ending with SP — and leaves paragraph-initial handling to stock so we don't double-strip. Companion test: tests/test-flowed-decoder.el (per-line space-unstuff spec).

(defun mrb/fill-flowed--pre-unstuff (&rest _)
  "Space-unstuff continuation lines before `fill-flowed' runs (RFC 3676 §4.4)."
  (save-excursion
    (goto-char (point-min))
    (forward-line 1)
    (while (not (eobp))
      (when (and (save-excursion
                   (forward-line -1)
                   (end-of-line)
                   (eq (char-before) ?\s))
                 (eq (following-char) ?\s))
        (delete-char 1))
      (forward-line 1))))

(advice-add 'fill-flowed :before #'mrb/fill-flowed--pre-unstuff)

Trailing-whitespace guard (compose → send)

Stray trailing whitespace typed into a compose buffer is invisible on screen but becomes a format=flowed soft break at encode time, causing the receiver to join the next line into the same paragraph — bulleted items collapse into one flowed sentence. Strip trailing whitespace from the body just before send, preserving the RFC 3676 §4.3 sig separator -- = (whose trailing space is mandatory) and MML tag lines (=<#part …>, <#secure …>) whose exact contents matter to the MIME builder.

Safe w.r.t. format=flowed: fill-flowed-encode re-adds trailing spaces at its own wrap points during encode, so stripping first is idempotent from the wire's perspective.

Companion test: tests/test-message-strip-ws.el.

(with-eval-after-load 'message
  (defun mrb/message-strip-trailing-whitespace ()
    "Strip trailing whitespace from message body before send."
    (save-excursion
      (message-goto-body)
      (while (not (eobp))
        ;; message mode specific exceptions
        (unless (or (looking-at-p "-- $")
                    (looking-at-p "<#"))
          (end-of-line)
          (let ((eol (point)))
            (skip-chars-backward " \t")
            (when (< (point) eol)
              (delete-region (point) eol))))
        (forward-line 1))))

  (add-hook 'message-send-hook #'mrb/message-strip-trailing-whitespace))

Attachment rendering

gnus's default MIME-button label — [N. mime/type; filename]... — is dense and hard to scan. Rewrite it to <icon> <filename>, where the icon is a real SVG from the system's elementary icon theme (per-MIME, colored, 24px inline). Click behaviour, keyboard navigation, keymap and face all preserved.

Approach: :around advice on gnus-insert-mime-button. Run the built-in insertion first (so keymap, face, help-echo and gnus-data text properties get installed), then re-scan the inserted label and replace it with a display-image + filename, carrying the text properties across (except face and display, which would clobber the icon).

Icon lookup follows the freedesktop.org convention: application/pdf → application-pdf.svg; family fallback image/* → image-x-generic.svg; ultimate fallback text-x-generic.svg.

  (with-eval-after-load 'gnus-art
    ;; Only do icon/file attachment in the body
    (setq gnus-mime-display-attachment-buttons-in-header nil)

    (defun mrb/mime-icon-svg-path (mime-type)
      "Return the SVG file path for MIME-TYPE in the elementary icon theme, or nil."
      (cl-loop for name in (list (concat (replace-regexp-in-string "/" "-" mime-type) ".svg")
                                 (concat (car (split-string mime-type "/")) "-x-generic.svg")
                                 "text-x-generic.svg")
               for path = (expand-file-name name "/usr/share/icons/elementary/mimes/48")
               when (file-exists-p path)
               return path))

    (defun mrb/mime-icon-image (handle)
      "Return a `create-image' object for the MIME icon of HANDLE."
      (let* ((mime (or (mm-handle-media-type handle) "application/octet-stream"))
             (path (mrb/mime-icon-svg-path mime)))
        (and path (create-image path 'svg nil :ascent 'center :height 48))))

    ;; --- MIME metadata from freedesktop.org shared-mime-info ---

    (defvar mrb/mime-description-cache (make-hash-table :test #'equal))

    (defun mrb/mime-description (mime-type)
      "Return the English human-readable description for MIME-TYPE, cached.
Reads the sole untagged `<comment>' element from the freedesktop
shared-mime-info XML at `/usr/share/mime/MIME-TYPE.xml' (localized
siblings all carry an `xml:lang' attribute, so a naive regex suffices)."
      (or (gethash mime-type mrb/mime-description-cache)
          (puthash mime-type
                   (let ((path (format "/usr/share/mime/%s.xml" mime-type)))
                     (when (file-readable-p path)
                       (with-temp-buffer
                         (insert-file-contents path)
                         (goto-char (point-min))
                         (and (re-search-forward "<comment>\\([^<]+\\)</comment>" nil t)
                              (match-string 1)))))
                   mrb/mime-description-cache)))

    (defun mrb/gnus-mime-button--icon-and-filename (orig handle id &optional displayed)
      "Rewrite the default `[N. type; filename]...' button to `<icon> <filename>'."
      (let ((start (point)))
        (funcall orig handle id displayed)
        (let ((filename (mm-handle-filename handle)))
          (when filename
            (save-excursion
              (goto-char start)
              (when (re-search-forward "\\[[0-9]+\\. [^]]+\\]\\(\\.\\.\\.\\)?" nil t)
                (let* ((mstart (match-beginning 0))
                       (mend   (match-end 0))
                       (all-props (text-properties-at mstart))
                       (btn-face  (plist-get all-props 'face))
                       (image     (mrb/mime-icon-image handle))
                       (icon-str  (if image
                                      (propertize " " 'display image)
                                    (propertize "📎" 'face btn-face)))
                       (nonclobber-props
                        (append (cl-loop for (k v) on all-props by #'cddr
                                         unless (memq k '(face font-lock-face display))
                                         collect k and collect v)
                                (list 'mrb-mime-type
                                      (mm-handle-media-type handle)))))
                  (delete-region mstart mend)
                  (let ((rstart (point)))
                    (insert icon-str)
                    (insert (propertize (concat " " filename) 'face btn-face))
                    (add-text-properties rstart (point) nonclobber-props)))))))))

    (advice-add 'gnus-insert-mime-button :around
                #'mrb/gnus-mime-button--icon-and-filename))

Attachment summary in header

Restore an always-visible top-of-message summary — dropped when we disabled the noisy default header buttons — in a compact per-MIME form, clickable to jump to the first matching body-inline button:

Attached: <pdf-icon> 2 PDF documents · <cal-icon> 1 iCalendar file · <jpg-icon> 5 JPEG images

Labels come from freedesktop.org /usr/share/mime/<type>.xml <comment> (cached). Rendered via mu4e-view-rendered-hook; jump uses the mrb-mime-type text property that the body-inline advice tags on each button.

(with-eval-after-load 'mu4e
  (defvar mrb/mu4e-attach-summary-map
    (define-keymap
      "<mouse-1>" #'mrb/mu4e-attach-jump-at-point
      "RET"       #'mrb/mu4e-attach-jump-at-point))

  (defun mrb/mu4e-attach-jump-at-point ()
    "Jump to the first body-inline attachment whose MIME matches the summary item at point."
    (interactive)
    (when-let* ((want (get-text-property (point) 'mrb-jump-mime)))
      (let ((pos (cl-loop with p = (point-min)
                          while (setq p (text-property-not-all
                                         p (point-max) 'mrb-mime-type nil))
                          when (equal (get-text-property p 'mrb-mime-type) want)
                            return p
                          do (setq p (next-single-property-change
                                      p 'mrb-mime-type)))))
        (if pos (goto-char pos)
          (message "No matching attachment in body")))))

  (defun mrb/mu4e--attach-summary-string ()
    "Return an `Attached: …' summary string for the current view, or nil if empty."
    (let (rows)
      (dolist (entry gnus-article-mime-handle-alist)
        (let* ((h (cdr entry))
               (fname (and (listp h) (mm-handle-filename h))))
          (when fname
            (let* ((mime (mm-handle-media-type h))
                   (cell (assoc mime rows)))
              (if cell (cl-incf (nth 1 cell))
                (push (list mime 1 (or (mrb/mime-description mime) mime))
                      rows))))))
      (setq rows (nreverse rows))
      (when rows
        (concat
         (propertize "Attached:" 'face 'mu4e-header-key-face) "  "
         (mapconcat
          (pcase-lambda (`(,mime ,count ,label))
            (let* ((path (mrb/mime-icon-svg-path mime))
                   (img  (and path (create-image path 'svg nil
                                                  :ascent 'center :height 16)))
                   (icell (if img (propertize " " 'display img) "•"))
                   (plural (if (= count 1) label (concat label "s"))))
              (propertize (concat icell " " (number-to-string count) " " plural)
                          'keymap mrb/mu4e-attach-summary-map
                          'mrb-jump-mime mime
                          'mouse-face 'highlight
                          'help-echo (format "Jump to first %s" label))))
          rows
          " · ")))))

  (defun mrb/mu4e-attach-summary-render ()
    "Insert `Attached: …' at the header/body boundary of the mu4e view (idempotent)."
    (when-let* ((str (mrb/mu4e--attach-summary-string)))
      (let ((inhibit-read-only t))
        (save-excursion
          (goto-char (point-min))
          (unless (re-search-forward "^Attached:" nil t)
            (goto-char (point-min))
            (when (re-search-forward "^$" nil t)
              (beginning-of-line)
              (insert str "\n")))))))

  (add-hook 'mu4e-view-rendered-hook #'mrb/mu4e-attach-summary-render))

Mu4e specific settings

After a journey with many different MUAs I seem to be settling on mu4e.

  ;; TODO  check for system packages meson, g++, pkg-config,
  ;;        cmake, libglib2.0-dev, libgmime3.0-dev, libxapian-dev, texinfo
  ;; (names are debian package names)
  (use-package mu4e
    ;; Straight needs some tweaking
    :straight ( :type git :host github :repo "djcb/mu"
                :pre-build (
                            ("./autogen.sh")
                            ("ninja" "-C" "build"))
                :files (:defaults "build/mu4e/*.el")
                :includes (mu4e-icalendar))

  :custom-face
  (mu4e-header-highlight-face ((t (:inherit hl-line :underline nil
                                            :weight normal :foreground unspecified))))
  (mu4e-unread-face           ((t (:inherit nil :underline nil :weight bold))))
  (mu4e-context-face          ((t (:inherit mu4e-link-face))))
  (mu4e-flagged-face          ((t (:foreground "#ebcb8b" ))))
  (mu4e-title-face            ((t (:foreground "#5e81ac"))))

  :custom
  (mu4e-mu-binary (mrb/emacs-file "straight/repos/mu/build/mu/mu"))

  :after (message)
  :commands (mu4e mrb/mu4e mrb/compose-mail mrb/mailfile)
  :hook ((mu4e-view-mode . mrb/mu4e-view-mode-hook)
         (mu4e-headers-mode . (λ () (eldoc-mode -1))))

  :config
  ;; Pure move-to-Trash on `d', no T flag. Paired with isyncrc's
  ;; `Expunge Both': mbsync would unlink any T-flagged file within one
  ;; sync cycle, which would destroy the Trash review window if `d'
  ;; set T. `mu4e-trash-without-flag' is a defvar, not defcustom — so
  ;; setq here rather than :custom.
  (setq mu4e-trash-without-flag t)

  (defun mrb/mu4e-view-mode-hook ()
    "Enable visual-line wrapping and flowed-text rendering for mu4e views."
    (turn-on-visual-line-mode)
    (setq-local mm-fill-flowed t))

  (defun mrb/mu4e ()
    "Entry point for mu4e — kept as a stable target for external bindings."
    (interactive)
    (mu4e))

  (defun mrb/mu4e-execute-all-noconfirm ()
    "Execute all pending mu4e marks without confirmation.
Works from both the headers and view buffers via
`mu4e--in-headers-context' (no-op when already in headers)."
    (interactive)
    (mu4e--in-headers-context (mu4e-mark-execute-all t)))

  :bind ( :map mu4e-search-minor-mode-map
          ("/" . mu4e-search)
          ("s" . nil)
          ("e" . mu4e-search-edit))

  ;; Configuration sections
  <<mu4e-composeconfig>>
  <<mu4e-main-config>>
  <<mu4e-headers-config>>
  <<mu4e-view-config>>
  <<mu4e-packages>>

  (defun mrb/setsigfile (ctxname)
    (expand-file-name  (concat ".signature-" (downcase ctxname)) mrb/maildir))

  (defcustom mrb/mu4e-junk-folder "/junk"
    "Maildir for junk/spam mail, relative to `mu4e-root-maildir'."
    :type 'string
    :group 'mu4e)

  (defcustom mrb/mu4e-archive-folder "/archives"
    "Maildir for archived mail; consumed by `mu4e-refile-folder' for the
year-organized archive layout."
    :type 'string
    :group 'mu4e)

  (setq
   ;; Override the default mail user agent with the mu4e specific one
   mail-user-agent 'mu4e-user-agent
   mu4e-sent-folder        "/Sent"
   mu4e-drafts-folder      "/Drafts"
   mu4e-trash-folder       "/Trash"
   mrb/mu4e-junk-folder    "/Junk"
   mrb/mu4e-archive-folder "/Archives"
   ;; mu4e-attachment-dir is set in personal.el

   ;; Refiling to /Archives/YYYY
   mu4e-refile-folder (λ (msg)
                        "Refile message to archive organized by year."
                        (let* ((archive-base (concat mrb/mu4e-archive-folder "/"))
                               (msg-date (mu4e-message-field msg :date))
                               (year (when msg-date (format-time-string "%Y" msg-date))))
                          (if year
                              (concat archive-base year)
                            archive-base)))


   mu4e-get-mail-command "mbsync quick" ; Just get inbox and important stuff, not everything
   mu4e-update-interval 120             ; we just index, no retrieve, so 2mins is fine

   ;; Set completing function to standard, so completing frameworks can use it
   mu4e-read-option-use-builtin nil
   mu4e-completing-read-function 'completing-read

   ;; Use same hidden setting as message mode
   mu4e-compose-hidden-headers message-hidden-headers

   ;; Context definitions
   ;; Goal 1. use global config, unless private address is used
   ;; TODO  Can we use the one that matched?
   mu4e-context-policy 'pick-first      ; When nothing matches only!
   mu4e-compose-context-policy 'nil     ; for compose, use current if nothing matches

   mu4e-search-results-limit -1         ; until we run into performance problems
   mu4e-search-include-related nil      ; do not included related by default


   mu4e-contexts
   `(,(let* ((ctxname "Default")
             (sigfile (mrb/setsigfile ctxname)))
        (make-mu4e-context
         :name ctxname
         :match-func (λ (msg)
                       (when msg
                         (mu4e-message-contact-field-matches
                          msg `(:to :cc :from) mrb/default-email-address)))
         ;; Use global config, but restore what was changed in other contexts
         :vars `((user-mail-address      . ,mrb/default-email-address)
                 (message-signature-file    . ,sigfile)
                 )))
     ,(let* ((ctxname "Private")
             (sigfile (mrb/setsigfile ctxname)))
        (make-mu4e-context
         :name "Private"
         :match-func (λ (msg)
                       (when msg
                         (mu4e-message-contact-field-matches
                          msg `(:to :cc :from) mrb/private-addresses)))
         :vars `((user-mail-address     . ,mrb/default-private-address)
                 (message-signature-file   . ,sigfile)
                 )))
     )
   )

  ;; Rendering of html mail is done with shr, but I prefer not to show it at all
  (with-eval-after-load "mm-decode"
    (add-to-list 'mm-discouraged-alternatives "text/html")
    (add-to-list 'mm-discouraged-alternatives "text/richtext"))

  )

Main screen

When mu4e start, it always opens in its main screen, which contains the main operations, visible bookmarks.

:bind ( :map mu4e-main-mode-map
        ("q" . mrb/mu4e-bury)
        ("Q" . mu4e-quit)
        (";" . nil)           ; this was too easy to press by mistake
        ("G" . (λ () (interactive) (mu4e-update-mail-and-index 1)))
        ("C-;" . mu4e-context-switch))
:init
(defun mrb/mu4e-bury ()
  "Bury the mu4e main buffer without ending the mu4e session."
  (interactive)
  (bury-buffer))

:custom
(mu4e-main-hide-personal-addresses t "No need to see my own addresses")
(mu4e-confirm-quit nil)
(mu4e-bookmarks
 (list '( :name "Unread messages"
          :query "flag:unread and not %trash"
          :key ?u)
       '( :name "INBOX"
          :query "%inbox"
          :favorite t
          :key ?i)
       '( :name "TODO list"
          :query "flag:flagged and not %trash"
          :key ?+)
       '( :name "Today's messages"
          :query "date:today..now"
          :key ?d)
       '( :name "Trash"
          :query "%trash"
          :key ?t)
       '( :name "Junk"
          :query "%junk"
          :key ?j)))

Header view

The first view after searching is a list of mail headers. Set config vars for this view and define the visualisation of the marks. I need an extra action to mark mail as junk which, on processing, moves the marked messages to the junk folder

:bind ( :map mu4e-headers-mode-map
        ("s" . mu4e-headers-mark-for-junk)
        ("r" . mu4e-compose-reply)
        ("R" . mu4e-compose-wide-reply)
        ("A" . mu4e-headers-mark-for-refile)
        ("SPC" . mu4e-headers-mark-for-something)
        ("X" . mrb/mu4e-execute-all-noconfirm))

:custom
(mu4e-headers-date-format "%F" "ISO format yyyy-mm-dd")
(mu4e-headers-visible-lines 20)  ; Default of 10 is a bit small
(mu4e-headers-auto-update nil)   ; Prevent confusion

;; Make header configuration explicit
(mu4e-headers-fields '((:flags . 6)
                       (:human-date . 12)
                       (:mailing-list . 10)
                       (:from . 22)
                       (:subject . nil)))
(mu4e-use-fancy-chars t)


;; Define marks explicitly. These are defvars, not defcustoms,
;; so they go in :config via setq rather than :custom.
:config
(setq mu4e-headers-attach-mark    '("a" . "📎 "))
(setq mu4e-headers-calendar-mark  '("c" . "📅"))
(setq mu4e-headers-draft-mark     '("D" . "🚧 "))
(setq mu4e-headers-encrypted-mark '("x" . "🔑 "))
(setq mu4e-headers-flagged-mark   '("F" . "🚩 "))
(setq mu4e-headers-list-mark      '("s" . "🔈"))
(setq mu4e-headers-new-mark       '("N" . "✨ "))
(setq mu4e-headers-passed-mark    '("P" . "↪ "))
(setq mu4e-headers-personal-mark  '("p" . "👨"))
(setq mu4e-headers-replied-mark   '("R" . "↩ "))
(setq mu4e-headers-seen-mark      '("S" . " "))
(setq mu4e-headers-signed-mark    '("s" . "🖊 "))
(setq mu4e-headers-trashed-mark   '("T" . "🗑️"))
(setq mu4e-headers-unread-mark    '("u" . "📩 "))

;; Add one specifically for marking as junk
(add-to-list 'mu4e-marks
             `(junk
               :char ("J" . "💀") :prompt "Mark as junk"
               :show-target ,(λ (dyn-target) "Junk")
               :ask-target ,(λ () mrb/mu4e-junk-folder)
               :action ,(λ (docid msg target)
                          (mu4e--server-move
                           docid
                           (mu4e--mark-check-target target) "+S-N"))))

;; Let mu4e define functions for it
(mu4e~headers-defun-mark-for junk)

;; Trashing also marks as read and clears the Flagged flag: always
;; +S-N-F, add +T only when `mu4e-trash-without-flag' is nil (Maildir
;; T = pending expunge).
(plist-put (cdr (assq 'trash mu4e-marks))
           :action
           (λ (docid msg target)
             (mu4e--server-move
              docid
              (mu4e--mark-check-target target)
              (concat (unless mu4e-trash-without-flag "+T") "+S-N-F"))))

;; Refile (archive on `A') also marks as read and clears Flagged.
(plist-put (cdr (assq 'refile mu4e-marks))
           :action
           (λ (docid msg target)
             (mu4e--server-move docid
                                (mu4e--mark-check-target target)
                                "+S-N-F")))

Message view

After selecting a message in the header view, a message view will open.

:bind ( :map mu4e-view-mode-map
        ("s" . mu4e-headers-mark-for-junk)
        ("r" . mu4e-compose-reply)
        ("A" . mu4e-headers-mark-for-refile)
        ("X" . mrb/mu4e-execute-all-noconfirm))

:custom
(mu4e-view-actions '(("c - capture message" . mu4e-action-capture-message)
                     ("v - view in browser" . mu4e-action-view-in-browser)
                     ("s - save attachments" . mu4e-view-save-attachments)
                     ("t - show this thread" . mu4e-action-show-thread)))

Addon packages

There are quite a few mu4e packages out there. Let's load them in one group for now, so it will be easy to disable plugins if we need to.

:config
;; Show an alert when new mails come in, this relies on mu4e being active though
(use-package mu4e-alert
  :hook (after-init . mu4e-alert-enable-notifications)
  :custom
  (mu4e-alert-email-notification-types '(subjects) "Just show subjects, not counts")
  :config
  (mu4e-alert-set-default-style 'libnotify))

;; Support icalendar Yes/No/Maybe reactions to calendar invites.
(use-package mu4e-icalendar
  :config
  (mu4e-icalendar-setup))

;; Reusable query fragments. The package advises `mu4e--server-find' for
;; interactive searches, but bookmark counts on the main screen go through
;; `mu4e--server-queries' which bypasses that advice. Wire fragment
;; expansion into `mu4e-query-rewrite-function' so bookmark counts get
;; the same substitution. Expansion is idempotent (no `%' markers remain
;; after the first pass), so the double pass on the search path is harmless.
(use-package mu4e-query-fragments
  :custom
  (mu4e-query-fragments-list
   '(("%trash"  . "flag:trashed or maildir:/Trash")
     ("%junk"   . "maildir:/Junk")
     ("%hidden" . "%trash or %junk")
     ("%inbox"  . "(flag:unread or flag:new or maildir:/INBOX) and not (flag:draft  or %trash)")))
  (mu4e-query-fragments-append "and not %hidden")
  (mu4e-query-rewrite-function #'mu4e-query-fragments-expand))

;; Patch highlighting inside messages
(use-package message-view-patch
  :after (magit)
  :hook (gnus-part-display . message-view-patch-highlight))

(use-package org-mime
  :custom
  (org-mime-use-property-inheritance t))

(use-package autocrypt
  ;; autocrypt-accounts is set in personal.el
  )

;; org-mu4e was folded into mu4e as mu4e-org.el; requiring it registers
;; the `mu4e:' org-link type so org-store-link / org-capture from a
;; mu4e view produces a valid message link.
(use-package mu4e-org
  :straight (:type built-in)
  :after (mu4e org))

Sieve

Sieve is used on the server to filter our mail and this is accessed by the sieve-manage package. For some reason I can't get the 'M' characters out of the process, so here's a hack for that.

(use-package sieve
  :straight (:type built-in)
  :init
  (defun dos2unix ()
    "Replace DOS eolns CR LF with Unix eolns CR"
    (interactive)
    (goto-char (point-min))
    (while (search-forward "\r" nil t) (replace-match "")))
  :commands sieve-manage
  :hook (sieve-mode . dos2unix)
  :custom
  (sieve-manage-authenticators '(plain digest-md5 cram-md5 scram-md5 ntlm login)))

which basically goes over the whole sieve script and removes the 'M' characters from the buffer

Elfeed

Elfeed handles my RSS feeds, which I specify using an orgmode file feeds.org

(use-package notifications
  :straight (:type built-in))

(use-package elfeed
  :after notifications
  :commands elfeed
  :bind (("C-c f" . 'elfeed)
         :map elfeed-show-mode-map
         ("w" . 'mrb/elfeed-show-toggle-watchlater)
         ("v" . 'mrb/elfeed-play-with-mpv)
         :map elfeed-search-mode-map
         ("v" . 'mrb/elfeed-play-with-mpv)
         ("w" . 'mrb/elfeed-search-toggle-watchlater))
  :init
  (setf url-queue-timeout 30)
  ;; elfeed-db-directory is set in personal.el

  :custom-face
  (elfeed-search-tag-face  ((t (:foreground "#a3be8c"))))
  (elfeed-search-feed-face ((t (:foreground "#ebcb8b"))))
  (elfeed-search-date-face ((t (:foreground "#88c0d0"))))
  (elfeed-show-title-face  ((t (:foreground "#eceff4" :weight bold))))
  (elfeed-show-author-face ((t (:foreground "#88c0d0" :weight bold))))
  (elfeed-show-feed-face   ((t (:foreground "#ebcb8b"))))
  (elfeed-show-date-face   ((t (:foreground "#88c0d0"))))
  (elfeed-show-tags-face   ((t (:foreground "#a3be8c"))))
  :config
  (defun mrb/elfeed-search-toggle-tag(tag)
    (let ((entries (elfeed-search-selected)))
      (cl-loop for entry in entries do
               (if (elfeed-tagged-p tag entry)
                   (elfeed-untag entry tag)
                 (elfeed-tag entry tag)))
      (mapc #'elfeed-search-update-entry entries)
      (unless (use-region-p) (forward-line))))

  (defun mrb/elfeed-search-toggle-watchlater()
    (interactive)
    (mrb/elfeed-search-toggle-tag 'watchlater))

  (defun mrb/elfeed-show-toggle-tag(tag)
    (interactive)
    (if (elfeed-tagged-p tag elfeed-show-entry)
        (elfeed-show-untag tag)
      (elfeed-show-tag tag)))

  (defun mrb/elfeed-show-toggle-watchlater()
    (interactive)
    (mrb/elfeed-show-toggle-tag 'watchlater))

  ;; umpv maintains a playlist, so adding more videos wil automatically queue
  (defun mrb/elfeed-play-with-mpv ()
    "Play elfeed link in mpv"
    (interactive)
    (notifications-notify
     :title "Elfeed action"
     :body "Playing video with MPV"
     :app-name "Elfeed")

    (start-process "elfeed-mpv" nil
                   "~/bin/umpv"
                   (elfeed-entry-link (elfeed-search-selected t))))


  ;; New entry hook allows meta information manipulation
  ;; without directly having to change elfeed-feeds
  (add-hook 'elfeed-new-entry-hook
            (elfeed-make-tagger :feed-url "youtube\\.com"
                                :add '(video youtube)))
  (add-hook 'elfeed-new-entry-hook
            (elfeed-make-tagger :feed-url "vimeo\\.com"
                                :add '(video vimeo))))

Elfeed allows to interactively subscribe to a feed (defaulting to what is in the clipboard. Managing elfeed-feeds as a variable is kinda clumsy, although very flexible. There is a middle ground which fits me even better. At the cost of the bare-bone flexibility of having the feeds directly in lisp, I'm using an orgmode file to record the feeds i want.

(use-package elfeed-org
  :after (elfeed org)
  :demand t
  :init
  (setq rmh-elfeed-org-files (list (concat elfeed-db-directory "feeds.org")))
  :config
  (elfeed-org))

Quite a few of my feeds are youtube channels, the elfeed-tube package has some extra features for reading those feeds

(use-package elfeed-tube
  :after elfeed
  :config
  (elfeed-tube-setup)

  :bind (:map elfeed-show-mode-map
              ("F" . elfeed-tube-fetch)
              ([remap save-buffer] . elfeed-tube-save)
              :map elfeed-search-mode-map
              ("F" . elfeed-tube-fetch)
              ([remap save-buffer] . elfeed-tube-save)))

IRC

I used to run a weechat relay on a vps for IRC. The main reason for this was that the relay allowed multiple clients to be active at the same time an have at least an attempt at saving the state betwee multiple devices.

The major downside is that a special weechat client is needed to make this work, which is not the same as a standard IRC client. This made the choice for clients very limited, but luckily there was an emacs client.

After using it some time and maintaining some local patches; the main program is largely unmaintained, it was time to look for an alternative.

While having the weechat relay is very nice, I also want to have a “standard” IRC configuration where everything is happening in the client, possibly augmented by having an IRC bouncer to maintain persistent connections. ZNC might almost have the featureset to do what weechat does, or very closely to it.

ERC seems to be the most used IRC client for emacs and included with it, so it would make sense to use it. I like the simplicity of circe though, so I opted to create a config for that.

(use-package circe
  :commands (circe)
  :init
  (defun mrb/chat ()
    "Connect to libera and oftc IRC networks via Circe."
    (interactive)
    (circe "libera")
    (circe "oftc"))
  ;; circe-default-nick and circe-default-user are set in personal.el
  ;; :pass as lambda defers password-store-get to connect time, avoiding
  ;; pinentry failures at startup when gpg-agent isn't ready yet
  (setq circe-network-options
        '(("libera"
           :host "chat.hsdev.com" :port 1066
           :pass (lambda (_) (concat "mrb@emacs/libera:"
                                     (password-store-get "chat.hsdev.com")))
           :channels ("#emacs"
                      "#talos-workstation"
                      "#vikings"))
          ("oftc"
           :host "chat.hsdev.com" :port 1066
           :pass (lambda (_) (concat "mrb@emacs/oftc:"
                                     (password-store-get "chat.hsdev.com")))
           :channels ("#osm"))))
  :custom

  ;; Simplify and improve formatting, remove some details
  (circe-format-say "{nick:10s}: {body}" "right align nicks at 10th pos")
  (circe-format-self-say "{nick:10s}: {body}")
  (circe-format-server-part "*** Part: {nick} left {channel}")
  (circe-format-server-quit-channel "*** Quit: {nick} left {channel}")
  (circe-format-server-quit "*** Quit: {nick}")
  (circe-format-server-join "*** Join: {nick}")
  (circe-format-server-rejoin "*** Re-joined: {nick}")
  (lui-time-stamp-position 'right-margin)
  (lui-fill-type nil)
  (circe-reduce-lurker-spam t "dont show notices for non talkers")
  (circe-default-part-message "" "no parting reason")
  (circe-default-quit-message "" "no quitting reason")

  :custom-face
  (circe-prompt-face ((t (:foreground ,mrb/cursor-color :height 1.3))))
  (circe-originator-face ((t (:height 0.8))))
  (lui-time-stamp-face ((t (:height 0.8))))
  (lui-track-bar ((t (:inherit default :extend t :background "LawnGreen" :height 0.1))))

  :config
  (enable-lui-track)                   ; show indicator to where has been read
  (enable-circe-display-images)        ; turn links to images into images
  (enable-circe-color-nicks)           ; color nicks

  ;; Gather buffer local and line UI settings
  (defconst mrb/lui-nick-column-width 12
    "Width allocated for IRC nicknames, matching `circe-format-say'.")

  (defconst mrb/lui-timestamp-width 7
    "Width of timestamp margin, sized for [hh:mm].")

  (defun mrb/lui-setup ()
    "Configure buffer-local display settings for lui IRC buffers."
    (setq fringes-outside-margins t
          right-margin-width mrb/lui-timestamp-width
          word-wrap t
          wrap-prefix (make-string mrb/lui-nick-column-width ?\s))
    (setf (cdr (assoc 'continuation fringe-indicator-alist)) nil))
  (add-hook 'lui-mode-hook 'mrb/lui-setup)

  ;; Set the prompt properly on entering the chat mode
  (defun mrb/set-circe-prompt ()
    (lui-set-prompt (propertize (concat "[" circe-chat-target "]➜ ")
                                'face 'circe-prompt-face)))
  (add-hook 'circe-chat-mode-hook 'mrb/set-circe-prompt))

Mastodon

Not messaging or chatting perse, but a micro blog social network. The 'capture a toot' is hot-keyed through xbindkeys like other capture commands.

;; Mastodon in emacs
(use-package mastodon
  :straight (mastodon :host codeberg :repo "martianh/mastodon.el"
                      :branch "develop")
  ;; mastodon-instance-url and mastodon-active-user are set in personal.el
  :custom
  (mastodon-client--token-file (mrb/emacs-file ".state/mastodon.plstore"))
  :config
  ;; Convenience function
  (defun mrb/capture-toot()
    (interactive)
    (mastodon-toot)))
edit-noframe --eval '(mrb/capture-toot)'

Development settings

Some settings which aid in development tasks.

Generic

Let's start with some generic development related settings and packages

(use-package request
  :custom
  (request-log-level 'verbose)
  (request-storage-directory (mrb/emacs-file ".cache/request/")))

(use-package yaml-mode)                 ; used by ansible, magit(?)

For most of the languages I use (Bash, python, C, Go, HTML Haskell, Lua), the Language Server Protocol seems to be supported by eglot, which is now a built-in package, so I'm setting this up generally here and enable it for each language I'm using if it is supported.

(use-package eglot
  :straight (:type built-in))

Diff and merge tools for version control:

(use-package diff-mode
  :straight (:type built-in)
  :mode "\\.patch\\'"
  :custom-face
  ;; Unified diff faces — diff-mode is the single source of truth,
  ;; magit, ediff and smerge inherit from it.
  (diff-added                     ((t (:foreground "#a3be8c" :background "#2e4a2e"))))
  (diff-removed                   ((t (:foreground "#bf616a" :background "#4a2e2e"))))
  (diff-refine-added              ((t (:foreground "#eceff4" :background "#5a7a52"))))
  (diff-refine-removed            ((t (:foreground "#eceff4" :background "#943b44"))))
  (diff-header                    ((t (:foreground "#e5e9f0" :background "#3b4252"))))
  (diff-hunk-header               ((t (:foreground "#e5e9f0" :background "#434c5e"))))
  (diff-context                   ((t (:foreground "#d8dee9"))))
  )

(use-package ediff
  :straight (:type built-in)
  :custom-face
  ;; ediff — A = removed/old, B = added/new
  (ediff-current-diff-A           ((t (:inherit diff-removed))))
  (ediff-current-diff-B           ((t (:inherit diff-added))))
  (ediff-fine-diff-A              ((t (:inherit diff-refine-removed))))
  (ediff-fine-diff-B              ((t (:inherit diff-refine-added))))
  (ediff-even-diff-A              ((t (:background "#3b4252"))))
  (ediff-even-diff-B              ((t (:background "#3b4252"))))
  (ediff-odd-diff-A               ((t (:background "#434c5e"))))
  (ediff-odd-diff-B               ((t (:background "#434c5e")))))

(use-package smerge-mode
  :straight (:type built-in)
  :custom-face
  (smerge-upper                   ((t (:inherit diff-removed))))
  (smerge-lower                   ((t (:inherit diff-added))))
  (smerge-refined-added           ((t (:inherit diff-refine-added))))
  (smerge-refined-removed         ((t (:inherit diff-refine-removed)))))

Make our source look a bit more attractive using font-native ligatures via ligature.el.

The :noweb no header is required: the global header-args use :noweb no-export, which expands noweb references during tangle. Ligature strings like -<<, <<-, <<, and >>= contain << and >> sequences that org-mode would consume as noweb targets, mangling the list.

(use-package ligature
  :config
  ;; Enabled for all modes; tested in prog-mode, org-mode, vterm — not thoroughly tested elsewhere
  (ligature-set-ligatures
   t
   '(;; Arrows
     "-|" "-~" "---" "-<<" "-<" "--" "->" "->>" "-->"
     "<-" "<<-" "<--" "<-<" "<<<" "<->" "->>"
     "=>" "=>>" "<=" "<=>" "==>" ">>=>"
     "~>" "~~>" "-~>"
     ;; Equality / comparison
     "==" "===" "!=" "!=="
     "<<=" ">>="
     ;; Logic / bitwise
     "&&" "||" "||>" "|||>" "|>"
     ;; Colons / dots
     "::" ":::" ".." "..." "...."
     ;; Comments / slashes
     "//" "///" "/*" "*/"
     ;; Misc
     "##" "###" "####"
     "--" "---"
     "~~" "~~>"
     "+>" "<|" "|>" "<||" "<|||" "<|>"
     "[|" "|]" "{|" "|}"
     ))
  (global-ligature-mode t))

(use-package prog-mode
  :straight (:type built-in))

In general, the syntax highlighting of emacs is sufficient, but is based on defining regular expression to match language contents to color them. There is a better way to do this; based on generating a semantic tree representation of the language in question. There is a tool called tree-sitter which does this.

The main advantages of the method that tree-sitter uses are:

As of emacs 29 the treesit package is built-in, so let's use that in any case for highlighting where it is possible. I think over time the mapping of the modes below will just disappear and everything will be treesitter based.

;; Use the built-in treesit and load all language grammars
(use-package treesit
  :straight (:type built-in)
  :init
  (setq treesit-extra-load-path
        `(,(mrb/emacs-file ".cache/tree-sitter/langs")))
  :config
  ;; Replace relevant modes with the treesitter variant
  (dolist (mode
           '((bash-mode       . bash-ts-mode)
             (c-mode          . c-ts-mode)
             (c++-mode        . c++-ts-mode)
             (css-mode        . css-ts-mode)
             (dockerfile-mode . dockerfile-ts-mode)
             (go-mode         . go-ts-mode)
             (javascript-mode . js-ts-mode)
             (js-json-mode    . json-ts-mode)
             (python-mode     . python-ts-mode)
             (typescript-mode . typescript-ts-mode)
             (yaml-mode       . yaml-ts-mode)))
    (add-to-list 'major-mode-remap-alist mode)))

Tree-sitter based major modes need to be declared after the treesit block above to ensure treesit-extra-load-path is set before they activate. Built-in ts-modes (like cmake-ts-mode) replace their legacy counterparts.

(use-package cmake-ts-mode
  :straight (:type built-in))

Code folding via tree-sitter. Provides semantic-aware folding (on functions, classes, blocks) across all tree-sitter modes.

(use-package treesit-fold
  :ensure t
  :demand t
  :custom-face
  ;; Treesit-fold replacement text (ellipsis when code is folded)
  (treesit-fold-replacement-face  ((t (:foreground "white" :weight bold))))

  ;; Treesit-fold fringe indicators (the triangles in the fringe)
  ;; Use subtle grey to reduce visual noise from range indicators
  (treesit-fold-fringe-face ((t (:foreground "#81a1c1"))))

  :config
  ;; Define C-c f as a prefix for folding commands
  (define-prefix-command 'treesit-fold-prefix-command)
  (global-set-key (kbd "C-c t") 'treesit-fold-prefix-command)
  (define-key treesit-fold-prefix-command (kbd "t") 'treesit-fold-toggle)
  (define-key treesit-fold-prefix-command (kbd "c") 'treesit-fold-close-all)
  (define-key treesit-fold-prefix-command (kbd "o") 'treesit-fold-open-all)
  (define-key treesit-fold-prefix-command (kbd "r") 'treesit-fold-open-recursively)

  (global-treesit-fold-mode 1)
  (global-treesit-fold-indicators-mode 1)

  ;; Use full rendering with filtering to remove range indicators
  (setq treesit-fold-indicators-render-method 'full)

  ;; Fix YAML folding issue where block_sequence_item can return ranges with nil values
  ;; Override the YAML parser to handle missing key/value nodes gracefully
  ;; See: https://github.com/emacs-tree-sitter/treesit-fold/issues/48

  (require 'treesit-fold-parsers)
  (let ((yaml-parsers (treesit-fold-parsers-yaml)))
    ;; Wrap the block_sequence_item handler to validate beg/end values
    (setf (alist-get 'block_sequence_item yaml-parsers)
          (λ (node offset)
            (let* ((key (treesit-search-subtree
                         node
                         (λ (n)
                           (string= "key" (treesit-node-field-name n)))))
                   (value (treesit-search-subtree
                           node
                           (λ (n)
                             (string= "value" (treesit-node-field-name n)))))
                   ;; Only compute beg if we have a valid key or value
                   (beg (when (or key value)
                          (treesit-node-end
                           (if (and value (string= "block_node" (treesit-node-type value)))
                               key
                             value))))
                   (end (treesit-node-end node)))
              ;; Only return a range if both beg and end are valid numbers
              (when (and (numberp beg) (numberp end) beg end)
                (treesit-fold--cons-add (cons beg end) offset)))))
    ;; Update the alist for YAML
    (setf (alist-get 'yaml-ts-mode treesit-fold-range-alist) yaml-parsers))

  ;; Define custom fringe bitmaps for fold indicators
  ;; Right-pointing triangle (▶) for folded code, down-pointing (▼) for open code
  ;; Right-pointing triangle (▶) - 16x16 fully filled
  (define-fringe-bitmap 'treesit-fold-indicators-fr-plus
    (vector #b1000000000000000
            #b1100000000000000
            #b1111000000000000
            #b1111110000000000
            #b1111111100000000
            #b1111111111000000
            #b1111111111110000
            #b1111111111111100
            #b1111111111111100
            #b1111111111110000
            #b1111111111000000
            #b1111111100000000
            #b1111110000000000
            #b1111000000000000
            #b1100000000000000
            #b1000000000000000)
    16 16)
  ;; Down-pointing triangle (▼) - 16x16
  (define-fringe-bitmap 'treesit-fold-indicators-fr-minus-tail
    (vector #b1111111111111111
            #b1111111111111111
            #b0111111111111110
            #b0011111111111100
            #b0001111111111000
            #b0000111111110000
            #b0000011111100000
            #b0000001111000000
            #b0000000110000000
            #b0000000100000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000)
    16 16)

  ;; Define empty bitmaps for range indicators to hide vertical lines
  (define-fringe-bitmap 'treesit-fold-indicators-fr-center
    (vector #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000)
    16 16)

  (define-fringe-bitmap 'treesit-fold-indicators-fr-end-left
    (vector #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000)
    16 16)

  (define-fringe-bitmap 'treesit-fold-indicators-fr-end-right
    (vector #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000
            #b0000000000000000)
    16 16)

)

To have some control over the environment for specific projects I use direnv, so let's install emacs support for it as well.

(use-package envrc
  :custom
  (envrc-on-lighter    '(" " (:propertize ".E" face envrc-mode-line-on-face)))
  (envrc-error-lighter '(" " (:propertize ".E" face envrc-mode-line-error-face)))
  (envrc-none-lighter  nil)
  :config
  ;; Keep *envrc* buffer but don't pop it up on error; the mode-line .E indicator suffices.
  (add-to-list 'display-buffer-alist '("\\*envrc\\*" display-buffer-no-window))
  (envrc-global-mode))

Reference & documentation

While emacs itself has a very nicely documentation system, I still don't understand why this is not interpolated into every other mode and or language.

Example: C-h-f in lisp-mode describes the function to me. If I am in a python file, I want it to do exactly the same, but for the python function. There's a page on emacs wiki dealing with this a bit: https://www.emacswiki.org/emacs/Context_sensitive_help

There's a package ghelp (generic help I guess) which promises to do what I want, but I can't get it installed properly with use-package. That package can use the helpful package as back-end which is also helpful on it own, so I'm installing that anyways and replace the internal help commands from emacs with them.

(use-package helpful
  :bind (("C-h f" . #'helpful-callable)
         ("C-h h" . #'helpful-at-point) ; I don't care about the emacs 'hello' file
         ("C-h v" . #'helpful-variable)
         ("C-h k" . #'helpful-key)))

Anyways, here's what I have configured related to reference information and documentation

Context sensitive

Regardless of (programming) language there's certain information that's needed on the spot. These are the function signatures, the specific syntax when typing Math symbols, orgmode oddities etc.

I want that type of information directly at hand. One part of the solution is ElDoc. This shows documentation in the echo area for a language. ElDoc is built into Emacs so that's a good start. Let's enable it for the modes that have support for it.

(dolist (mode-hook '(emacs-lisp-mode-hook
                    lisp-interaction-mode-hook
                    ielm-mode-hook
                    python-mode))
  (add-hook mode-hook 'eldoc-mode))
(diminish 'eldoc-mode)

The location of the eldoc information in the message area is a bit far away from the editing point. I have looked at eldoc-overlay and others to resolve that, but haven't found a satisfying solution yet.

Reference information

If the inline documentation presented by ElDoc is not sufficient, I want a way to spawn reference documentation based on the context.

The closest thing I found is zeal or dash which allow docsets to be searched. There is a GUI and helm-dash is an interface to the docsets for emacs. Typically I install docsets through the GUI or manually (the folder names are sometimes not the same when I use helm-dash to install docsets). This solution gives me at least access to reference information for most of the programming languages I use. The direct access to the sources is missing, or I don't know how to do this yet.

;; Unify reference documentation with dash
(use-package helm-dash
  :after helm
  :commands (helm-dash helm-dash-at-point)
  :custom
  (dash-docs-enable-debugging nil)
  (helm-dash-browser-func 'eww "Within dash, keep browser links within emacs")
  :config
  ;; Make sure docset directory exists
  (make-directory helm-dash-docsets-path t)
  (setq helm-dash-common-docsets (helm-dash-installed-docsets)))

Dash show documentation inside emacs, which is my preferred method. There are times however I need the desktop GUI Zeal. For one, it's a lot easier to see which docsets are installed and manage them.

;; Global keybinding ot open documentation
;; This should probably be somewhere else
(use-package zeal-at-point
  :bind
  ("s-d" . zeal-at-point))

RFC document reader

I often need to consult RFC documents, so not having to leave emacs for that and be able to use all tools on RFC documents is very helpful.

;; Special mode to read rfc documents locally
(use-package rfc-mode)                ; rfc-mode-directory is set in personal.el

Man pages

There's a builtin package to read man pages, let's make it explicit.

(use-package man
  :straight (:type built-in)
  :custom-face
  ;; Man pages inside emacs
  (Man-overstrike ((t (:foreground "#bf616a" :weight bold))))
  (Man-underline ((t (:foreground "#a3be8c" :weight bold)))))

Not really man pages as such, but meant as their advanced cousin for GNU programs is the info format.

(use-package info
  :straight (:type built-in)
  :bind (:map Info-mode-map ("C-o" . 'casual-info-tmenu)))

Epub documents

Not often, but increasingly so, reference books are in epub format. So, for that we need a reader package. nov seems to be the best option

(use-package nov
  :mode ("\\.epub" . nov-mode)
  :custom
  (nov-save-place-file (mrb/emacs-file ".state/nov-places")))

Coding styles

Different projects use different coding styles. The ones I need I'll gather here for now.

My personal style will be called mrb and basing it on the linux style.


;; Basing it on k&r, no real reason, does it really matter?
(c-add-style "mrb"
             '("k&r"
               (indent-tabs-mode . nil)
               (c-basic-offset . 2)
               (c-cleanup-list . (scope-operator
                                  space-before-funcall))))

;; Make my style the default
(setq c-default-style
      '((java-mode . "java")
        (awk-mode  . "awk")
        (other     . "mrb")))

;; EditorConfig support, not used much by the looks of it
(use-package editorconfig
  :disabled t
  :diminish
  :config
  (editorconfig-mode 1))

Language support

Python

Just enabling eglot for now. Hook and keys go on python-base-mode, the parent of both python-mode and python-ts-mode: the tree-sitter remap opens .py files in python-ts-mode, which runs neither python-mode-hook nor python-mode-map.

(use-package python
  :straight (:type built-in)
  :hook (python-base-mode . eglot-ensure)
  :bind ( :map python-base-mode-map
          ("C-c C-e" . python-shell-send-defun)  ; canonical eval-defun alias (overrides send-statement)
          ("C-c C-n" . python-shell-send-buffer) ; eval buffer (unbound by default)
          ("C-c C-k" . python-shell-send-file))) ; load file alias (default is C-c C-l)

Haskell

I am just starting out with haskell, but the two things that are probably needed in any case are a mode to use when editing Haskell source (*.hs files) and the ghc-mod package to help with completion and showing syntax errors.

(use-package haskell-mode
  :hook (;(haskell-mode . interactive-haskell) ;; Produces elc load error
         (haskell-mode . turn-on-haskell-doc)
         (haskell-mode . haskell-indentation-mode)
         (haskell-mode . eglot-ensure))

  :mode "\‌\.hs\\'"
  :custom
  (haskell-font-lock-symbols t)
  (haskell-interactive-popup-errors nil)
  (haskell-process-type 'stack-ghci)
  :config
  ;; Replace ⇒ with ⇉
  (delete '("=>" . "⇒") haskell-font-lock-symbols-alist)
  (add-to-list 'haskell-font-lock-symbols-alist '("=>" . "⇉")))

The language server is handled by eglot via the eglot-ensure hook above, in line with every other language here. No extra package is needed: eglot-server-programs already maps haskell-mode to haskell-language-server-wrapper --lsp, and haskell-literate-mode derives from haskell-mode, so literate buffers are covered by the same hook. This does require haskell-language-server to be on PATH.

Because the haskell environment can be different for each project, we need a way to adjust to that for certain things. One such thing is flycheck which will give false-negatives when checking haskell files if the specific build environment is not taken into account. The package flycheck-haskell automatically adjusts flycheck.

(use-package flycheck-haskell
  :after flycheck
  :hook (flycheck-mode . flycheck-haskell-setup))

For creating web apps I use yesod, which is supported by a number of packages

;; Yesod's html like files
(use-package hamlet-mode)

Go

Go uses eglot like the other languages, with gopls as the server. Arch POWER has no gopls package, so it comes from go install golang.org/x/tools/gopls@latest; .zshenv puts ~/go/bin on the login-shell PATH that exec-path-from-shell reads.

On save the server organises imports and formats the buffer. Both steps are skipped when no server manages the buffer, because eglot-format-buffer errors without one. Organising an already tidy file also errors (“no code actions”), which is the normal case and is ignored.

The hooks go on go-ts-mode: the tree-sitter remap in the treesit section opens .go files in it instead of go-mode, and go-ts-mode does not run go-mode-hook. The functions are in :preface for the same reason, because go-mode itself may never load.

(use-package go-mode
  :hook ((go-ts-mode . eglot-ensure)
         (go-ts-mode . mrb/go-format-on-save))
  :preface
  (defun mrb/go-before-save ()
    "Organise imports and format the buffer through the language server."
    (when (and (fboundp 'eglot-managed-p) (eglot-managed-p))
      (ignore-errors (eglot-code-action-organize-imports (point-min) (point-max)))
      (eglot-format-buffer)))

  (defun mrb/go-format-on-save ()
    "Organise imports and format through the server before saving this buffer."
    (add-hook 'before-save-hook #'mrb/go-before-save nil t)))

Rust

(use-package rust-mode)

Lisp-like

There are a number of languages which are lisp-like, not in the least Emacs lisp itself. It makes sense to group the configuration for these languages together. In the first part there will be configuration for all lisp-like languages (like the puni configuration for example). For each specific implementation there may be augmentations to the config, or even a completely separate.

For all of those languages, I want to use puni which makes editing, longer term, a lot more productive. For autopairing, which puni does not do itself unlike paredit, I use the built-in electric-pair-mode Both of these support multiple modes, so are really generic packages, but lisp-like modes are the modes typically benefitting the most from them.

(use-package puni
  :init
  (puni-global-mode)                    ; perhaps too much, we'll see

  :bind ( :map puni-mode-map
          ("C-<right>" . puni-slurp-forward) ; foo (bar|) baz  ->  foo (bar| baz)
          ("C-<left>"  . puni-barf-forward)  ; foo (bar| baz)  ->  foo (bar|) baz
          )

  :hook ((org-mode . puni-disable-puni-mode)
         (prog-mode . electric-pair-local-mode)))

Evaluating an expression with the cursor after it is often used, I'd like being able to do the same when the cursor is in front of such an expression

;; The reciprocate of C-x C-e
;; As in:  _cursor_(...eval what is in here...)
(defun mrb/eval-next-sexp ()
  "Evaluate the sexp that follows point without moving point."
  (interactive)
  (save-excursion
    (forward-sexp)
    (eval-last-sexp nil)))

Not sure how to bind this though. I'd like it close to C-x C-e obviously

When evaluating expressions, the result is way down in the status area. I would like to have it inline in the buffer in an overlay. The package cider does this, but I have no need for the complete clojure environment. There is a specific package for emacs-lisp though, called eros

(use-package eros
  :config
  (eros-mode 1))

Emacs Lisp

(defun mrb/undefine-function (symbol)
  "Remove the function binding of SYMBOL interactively."
  (interactive (list (intern (completing-read "Undefine function: "
                                              obarray #'fboundp t))))
  (fmakunbound symbol))

(use-package emacs-lisp-mode
  :straight (:type built-in)
  :bind ( :map emacs-lisp-mode-map
          ("C-c C-z" . ielm)
          ("C-c C-e" . eros-eval-defun)
          ("C-c C-r" . eval-region)
          ("C-c C-n" . eval-buffer)
          ("C-c C-k" . emacs-lisp-byte-compile-and-load)
          ("C-c C-d" . helpful-at-point)
          ("C-c C-m" . emacs-lisp-macroexpand)
          ("C-c M-m" . macroexpand-all)
          ("C-c C-p" . pp-eval-last-sexp)
          ("C-c C-u" . mrb/undefine-function)
          ("C-c C-t" . trace-function)
          ("C-c C-a" . apropos-command)
          ("C-c C-c" . emacs-lisp-byte-compile)
         :map lisp-interaction-mode-map
          ("C-c C-z" . ielm)
          ("C-c C-e" . eros-eval-defun)
          ("C-c C-r" . eval-region)
          ("C-c C-n" . eval-buffer)
          ("C-c C-d" . helpful-at-point)
          ("C-c M-m" . macroexpand-all)
          ("C-c C-p" . pp-eval-last-sexp)))

Scheme

The generic scheme support is the builtin scheme package, let's tell it that I use guile as my default scheme program.

(use-package scheme
  :straight (:type built-in)
  :custom
  (scheme-program-name "/usr/bin/guile"))

Geiser is, among other things, a repl for schemes. I try to use one, mostly for educational purposes. I chose guile because it also claims to support emacs-lisp and might be the future vm on which emacs-lisp will run.

The bindings hang off geiser-mode, not geiser: the map lives in geiser-mode.el, which loading geiser does not pull in, so binding it on geiser fails with a void geiser-mode-map whenever something (ob-scheme, for one) requires geiser first.

(use-package geiser)

(use-package geiser-mode
  :straight nil
  :after geiser
  :bind ( :map geiser-mode-map
          ("C-c C-z" . geiser-mode-switch-to-repl)
          ("C-c C-e" . geiser-eval-definition)
          ("C-c C-r" . geiser-eval-region)
          ("C-c C-n" . geiser-eval-buffer)
          ("C-c C-k" . geiser-compile-current-buffer)
          ("C-c C-d" . geiser-doc-symbol-at-point)
          ("C-c C-m" . geiser-expand-last-sexp)
          ("C-c C-b" . geiser-eval-interrupt)
          ("C-c C-c" . geiser-eval-definition)
          ("C-c C-a" . apropos-command)))
;; Specific implementations depend on geiser
(use-package geiser-guile
  :straight (:host gitlab :repo "emacs-geiser/guile")
  :defer t
  :commands (run-guile)
  :custom
  (geiser-guile-binary "/usr/bin/guile")
  (geiser-active-implementations '(guile))
  (geiser-default-implementation 'guile)

  (geiser-repl-history-filename (expand-file-name "~/.local/state/geiser/history"))

  ;; Do NOT evaluate on return when inside an expression
  (geiser-repl-send-on-return-p nil)

  ;; start repl and eval results inline in buffer
  ;; TODO I do want this eval result to be transient, not inserted!
  (geiser-mode-start-repl-p t)
  (geiser-mode-eval-last-sexp-to-buffer t)
  (geiser-mode-eval-to-buffer-prefix " ;;=> ")

  )

Similar to using eros for emacs-lisp, I use geiser-eros for languages that geiser supports.

(use-package geiser-eros
  :after (eros geiser)
  :straight '(:type git :host sourcehut :repo "sokolov/geiser-eros")
  :config
  ;; Make sure geiser does not insert eval into buffer
  (setq geiser-mode-eval-last-sexp-to-buffer nil)
  (geiser-eros-mode 1))

While we're in the scheme section, let's configure guix and friends, which use scheme heavily

(use-package guix
  :config
  ;; geiser-guile-load-path is set in personal.el

  ;; Use tempel templates
  (with-eval-after-load 'tempel
    ;; Ensure tempel-path is a list -- it may also be a string.
    (add-to-list 'tempel-path "~/dat/src/guix/etc/snippets/tempel/*"))

  ;; guix uses debbugs, but this should probably be closer to mu4e and gnus config
  (use-package debbugs))

Common lisp

The defacto standard for a development environment in Emacs for common-lisp is either the slime or sly package. The latter seems a bit more modern and easier to configure, so trying that one

(use-package sly
  :hook ((sly-mrepl-mode . sly-mrepl-font-lock-setup))

  :bind ( :map sly-mode-map
          ("C-c C-z" . mrb/sly-switch-to-repl)
          ("C-c C-e" . sly-eval-defun)
          ("C-c C-r" . sly-eval-region)
          ("C-c C-n" . sly-eval-buffer)
          ("C-c C-k" . sly-compile-and-load-file)
          ("C-c C-d" . sly-describe-symbol)
          ("C-c C-c" . sly-compile-defun)
          ("C-c C-b" . sly-interrupt)
          ("C-c C-t" . sly-toggle-trace-fdefinition)
          ("C-c C-a" . sly-apropos)
          ("C-c I"   . sly-inspect))

  :init

  ;; Copy the keywords from Common Lisp mode (not lisp-el which is Emacs Lisp)
  (defvar sly-mrepl-font-lock-keywords lisp-cl-font-lock-keywords-2)

  ;; Set them up
  (setq sly-mrepl-font-lock-keywords
        (cons '(sly-mrepl-font-lock-find-prompt
                . 'sly-mrepl-prompt-face)
              sly-mrepl-font-lock-keywords))
  (defun sly-mrepl-font-lock-setup ()
    (setq font-lock-defaults
          '(sly-mrepl-font-lock-keywords
            ;; From lisp-mode.el
            nil nil (("+-*/.<>=!?$%_&~^:@" . "w")) nil
            (font-lock-syntactic-face-function
             . lisp-font-lock-syntactic-face-function))))

  :custom
  ;; Prevent usage of linedit when we are inside emacs, it's just for the terminal
  ;; Q: How about vterm?
  (inferior-lisp-program "sbcl --noinform --no-linedit")
  (sly-mrepl-history-file-name (expand-file-name "~/.local/state/sly/mrepl-history"))

  (sly-lisp-implementations
   '((sbcl ("sbcl" "--core" (mrb/home-file ".sbcl/sbcl.core-for-sly")))
     (qlot ("qlot" "exec" "sbcl"))))
  (sly-net-coding-system 'utf-8-unix)

  :config
  (defun mrb/sly-switch-to-repl ()
    "Switch to SLY REPL, starting SLY if not connected."
    (interactive)
    (if (sly-connected-p)
        (sly-mrepl)
      (sly)))

  ;; Correct the REPL prompt
  (defun sly-mrepl-font-lock-find-prompt (limit)
    ;; Rough: (re-search-forward "^\\w*>" limit t)
    (let (beg end)
      (when (setq beg (text-property-any
                       (point) limit 'sly-mrepl-prompt-face t))
        (setq end (or (text-property-any
                       beg limit 'sly-mrepl-prompt-face nil)
                      limit))
        (goto-char beg)
        (set-match-data (list beg end))
        t))))

Gcode

I'm not in the habit of editing gcode, but every now and then I need to look at it and I want it reasonably decent. My use-case is just sending gcode to my 3D-printer which runs Marlin firmware.

Using define-generic-mode is more than enough to define something useful for me.

(use-package generic-x
  :straight (:type built-in))

(define-generic-mode marlin-gcode-mode
  '((";"))   ;; ; starts a comment
  (apply 'append
         (mapcar (λ (s) (list (upcase s) (downcase s) (capitalize s)))
                 '("keywords?" "exist?")))
  '(("\\([GM]\\)[0-9]+" (1 font-lock-function-name-face))    ; code letters
    ("\\([ABCDEFHIJKLNOPQRSTUVWXYZ]\\)[-]?[0-9]+" (1 font-lock-string-face)) ; parameter letters
    ("\\([\-+]?[0-9]*\\.[0-9]+\\)" (1 font-lock-constant-face)) ; 9.9 type numbers
    ("\\([\-+]?[0-9]+\\)" (1 font-lock-constant-face)))         ; 999 type numbers
  '("\\.gcode\\'")
  nil
  "Mode for marlin g-code files.")

Openscad

Openscad is a script based 3d parametric drawing program. Obviously I'm editing those scripts in emacs.

(use-package scad-mode
  :custom
  (scad-keywords '("return" "true" "false" "include")))

SQL

SQL has a number of different dialects I use, depending on the database software in use.

(use-package sql
  :straight (:type built-in)

  :bind ( :map sql-mode-map
          ("C-c C-e" . sql-send-paragraph)) ; canonical eval-statement alias (C-c C-c default)

  :custom
  ;; sql-server is set in personal.el
  (sql-postgres-options '("-P" "pager=off" "-p 5434")))

GIT integration

A common factor in all my development is the use of git. For emacs this automatically means Magit. I've used eshell in the past but that config didn't work out.

Magit

For most, if not all development work (and some other work too) I use git as the revision control system. In emacs that translates to using magit, so let's begin with bringing that in.

Magit forge is a sub-module for magit which helps in managing repositories in forges (notably github and gitlab). This looks very promising, albeit slow.

Features I'm interested in: issue commenting, pull requests

Magit drives its popups through transient, which persists history, levels and values. Those land in the config root by default, so they are pointed at .state/ here — before magit loads, since magit is what pulls transient in.

(use-package transient
  :custom
  (transient-history-file (mrb/emacs-file ".state/transient/history.el"))
  (transient-levels-file (mrb/emacs-file ".state/transient/levels.el"))
  (transient-values-file (mrb/emacs-file ".state/transient/values.el")))

Magit's stock display function places buffers against whatever windows happen to exist, so the same command lands differently from one invocation to the next. mrb/magit-display-buffer fixes the layout instead: a status buffer takes the whole frame, and anything magit opens off it — diff, log, revision, process — normalises the frame to two side-by-side windows and takes the right one.

The commit message buffer never reaches this function: with-editor displays it with switch-to-buffer, in the selected window. That still lands predictably, because magit shows the pending diff first (from with-editor-filter-visit-hook) and splitting leaves the left window selected, so the message ends up left of its diff.

magit-restore-window-configuration puts back whatever was on screen before magit claimed the frame, for every magit buffer rather than the status buffer alone.

(defun mrb/display-buffer-right-half (buffer alist)
  "Display BUFFER in the right half of a frame split into two windows."
  (let ((window (split-window (progn (delete-other-windows) (selected-window))
                              nil 'right)))
    (window--display-buffer buffer window 'window alist)))

(defun mrb/magit-display-buffer (buffer)
  "Display BUFFER full-frame if it is a status buffer, else in the right half."
  (display-buffer
   buffer
   (if (eq (buffer-local-value 'major-mode buffer) 'magit-status-mode)
       '(mrb/display-buffer-fullframe)
     '(mrb/display-buffer-right-half))))

(use-package magit
  :straight (:host github :repo "magit/magit")

  :demand t
  :after (org)
  :commands magit-status
  :bind
  ("C-c m" . magit-status)

  :custom
  (magit-diff-refine-hunk 'all)
  (magit-display-buffer-function #'mrb/magit-display-buffer)
  (magit-bury-buffer-function #'magit-restore-window-configuration)

  :custom-face
  ;; magit — inherit base, highlight variants get brighter bg
  (magit-diff-added               ((t (:inherit diff-added))))
  (magit-diff-removed             ((t (:inherit diff-removed))))
  (magit-diff-added-highlight     ((t (:inherit diff-added   :background "#3b5c3b"))))
  (magit-diff-removed-highlight   ((t (:inherit diff-removed :background "#5c3b3b"))))
  (magit-diff-context             ((t (:inherit diff-context))))
  (magit-diff-context-highlight   ((t (:inherit diff-context :background "#3b4252"))))
  (magit-diff-hunk-heading        ((t (:inherit diff-hunk-header))))
  (magit-diff-hunk-heading-highlight ((t (:inherit diff-hunk-header :background "#5e81ac"))))


  :config
  ;; with-editor must NOT use emacsclient here: anvil advises `server-execute'
  ;; (anvil-eval--server-execute-cleanup-advice) to guarantee emacsclient
  ;; teardown for its MCP stdio bridge, and that cleanup tears down the
  ;; still-open connection a with-editor client legitimately holds while the
  ;; user edits. The commit then aborts with "anvil: server-execute aborted by
  ;; non-local exit" and no commit is created. Setting this to nil selects
  ;; with-editor's signal-based "sleeping editor", which never goes through
  ;; `server-execute' at all.
  ;; Re-verified 2026-09-19 on magit 4.7.1 / with-editor 3.5.4: removing this
  ;; reproduces the failure; removing the anvil advice instead fixes it.
  ;; The frame-creation rationale previously given here was wrong — with-editor
  ;; never passes -window-system, and `with-editor-server-window-alist' already
  ;; maps COMMIT_EDITMSG to `switch-to-buffer'. No new frame appears either way.
  (setq with-editor-emacsclient-executable nil)

  ;; Manage pre-commit from inside magit
  (use-package magit-pre-commit
    :straight (:host github :repo "DamianB-BitFlipper/magit-pre-commit.el"))

  ;; Enable links to magit fromm org
  (use-package orgit))

Agent Integration – Commit Helper

Helper function for an AI agent to start a magit commit with pre-filled message.

Git's -m MSG -e prefills the editor buffer exactly as -F FILE -e does, so no temporary file is needed; the earlier make-temp-file version leaked one 64K file per commit into /tmp.

  (defun mrb/agent-magit-commit (message &optional directory)
    "Prepare and start a magit commit with pre-filled MESSAGE.
Called via emacsclient by an AI agent (Claude Code, Hermes) when committing.
DIRECTORY is the git repository path (optional; uses current directory if nil)."
    (let ((default-directory (file-name-as-directory
                              (expand-file-name (or directory default-directory)))))
      (magit-commit-create (list "-m" message "-e"))))

Committing automatically

I have lost changes in the past from reverts and mistakes. git-auto-commit-mode acts as a recovery safety net: git captures every save, while org-mode's own LOGBOOK, CLOSED, and archive files carry the semantic history.

Scope:

Commit messages: instead of the bare filename default, mrb/gac-message produces <relpath>: <heading> (+N -M) — the heading comes from the diff's hunk-context (typically the org heading the change falls under), shortstat shows change size. When the change is outside any heading the format degrades to <relpath> (+N -M).

  (use-package git-auto-commit-mode
    :defer t
    :hook ((org-journal-mode . git-auto-commit-mode)) ; journals are always autocommitted

    :custom
    (gac-default-message #'mrb/gac-message)

    :init
    (defun mrb/git-auto-commit-mode ()
      "Enable git-auto-commit-mode unless editing the config file."
      (interactive)
      (unless (and (buffer-file-name)
                   (string= (buffer-file-name) config-file))
        (git-auto-commit-mode)))

    (defun mrb/gac-message (filename)
      "Compose a git-auto-commit commit message for FILENAME.
Format: \"<relpath>: <heading> (+N -M)\" — heading from the diff's
hunk context, shortstat from git diff. Heading or stat are omitted
when unavailable (e.g. new files, pre-heading edits)."
      (let* ((default-directory (file-name-directory filename))
             (relname (gac-relative-file-name filename))
             (diff (with-output-to-string
                     (call-process "git" nil standard-output nil
                                   "diff" "--unified=0" "--" filename)))
             (heading (when (string-match "^@@[^@]*@@ ?\\(.+\\)$" diff)
                        (let ((h (string-trim (match-string 1 diff))))
                          (if (> (length h) 60)
                              (concat (substring h 0 57) "...")
                            h))))
             (stat (with-output-to-string
                     (call-process "git" nil standard-output nil
                                   "diff" "--shortstat" "--" filename)))
             (ins (when (string-match "\\([0-9]+\\) insertion" stat)
                    (match-string 1 stat)))
             (del (when (string-match "\\([0-9]+\\) deletion" stat)
                    (match-string 1 stat)))
             (stat-str (when (or ins del)
                         (format "+%s -%s" (or ins "0") (or del "0")))))
        (cond
         ((and heading stat-str) (format "%s: %s (%s)" relname heading stat-str))
         (heading                (format "%s: %s" relname heading))
         (stat-str               (format "%s (%s)" relname stat-str))
         (t relname)))))

Miscellaneous

A collection of loosely development related things.

Online pastebin services

I use github gists sometimes, but rather have more open options as well. These are mostly use to communicate longer pieces of text in chat programs, notably IRC

(use-package webpaste
  :custom
  (webpaste-provider-priority '("paste.rs"
                                 "dpaste.com"
                                 "paste.ubuntu.com"
                                 "bpa.st"
                                 "gist.github.com"))
  (webpaste-paste-confirmation t)
  (webpaste-open-in-browser t))

Grepping files

Searching for terms in files is a hard problem, but the vast majority of my use-cases can just be solved by searching for terms in the current project root, almost always defined by having a .git directory in a parent directory. So, let's start there and see what else I'll need.

I've chosen ag as the engine for grepping the files because it's very fast. I would like to have a better UI within Emacs though (consult maybe?)

(use-package ag)

AItools

Tools to use “AI” in Emacs.

Anvil suite

anvil.el is a token-efficient MCP server that exposes Emacs primitives (file, org-mode, elisp, git, shell) over MCP, so Claude Code operates on regions/headings rather than reading/writing whole files. The stdio bridge at ~/.config/emacs/straight/repos/anvil.el/anvil-stdio.sh connects Claude Code to the running Emacs daemon; two MCP entries are needed (--server-id=anvil and --server-id=emacs-eval).

Records that cannot be rebuilt go in .state/, indexes and logs in .cache/. Modules not currently listed in anvil-optional-modules are pointed somewhere sensible too, so enabling one later adds no clutter. The paths are :custom so they are in place before anvil-enable opens anything.

The org index skips /.claude/, which holds orchestrator worktrees — 81k org files that would otherwise all be indexed — and Syncthing conflict copies, which index as real files and can surface a stale DONE headline in anything aggregating CLOSED items by date. anvil-org-index-watch keeps the index live; without it it is a one-off snapshot.

The recipe tracks mrb/local, not develop. mrb/local is the set of patches I actually run: origin/develop with my local fixes merged on top. It exists only for that and is never the base for a PR. Each fix lives on its own branch cut from develop, holding only that change; the PR is opened from that branch, and the branch is merged into mrb/local to run it.

Naming the branch in the recipe matters: with :branch "develop" the recipe described a branch the checkout was not on, so a straight-pull-package or a rebuild that reconciled the recipe would have silently moved HEAD to develop, discarding the local patches. The lockfile in straight/versions/default.el is not the safeguard here; it only applies on straight-thaw-versions, which is not part of any workflow, and its anvil.el pin is a commit that is not even an ancestor of mrb/local.

When a fix is accepted upstream, rebase mrb/local onto the new develop and drop the now-redundant merge; the recipe stays pointed at mrb/local either way.

(use-package anvil
  :straight (:type git :host github :repo "zawatton/anvil.el"
             :branch "mrb/local"
             :post-build ("chmod" "+x" "anvil-stdio.sh"))
  :custom
  (anvil-state-db-path (mrb/emacs-file ".state/anvil/anvil-state.db"))
  (anvil-memory-obs-db-path (mrb/emacs-file ".state/anvil/anvil-memory-obs.db"))
  (anvil-memory-bridge-token-file (mrb/emacs-file ".state/anvil/memory-bridge.token"))
  (anvil-fusion-traj-db-path (mrb/emacs-file ".state/anvil/anvil-fusion-traj.db"))
  (anvil-memory-db-path (mrb/emacs-file ".cache/anvil/anvil-memory-index.db"))
  (anvil-orchestrator-work-dir (mrb/emacs-file ".cache/anvil/orchestrator"))
  (anvil-worker-lifecycle-log (mrb/emacs-file ".cache/anvil/anvil-worker.log"))
  (anvil-defs-index-db-path (mrb/emacs-file ".cache/anvil/anvil-defs-index.db"))
  (anvil-org-index-db-path (mrb/emacs-file ".cache/anvil/anvil-org-index.db"))
  (anvil-org-index-async-refresh-log (mrb/emacs-file ".cache/anvil/anvil-org-index-refresh.log"))
  (anvil-org-index-paths '("~/dat/org/"))
  (anvil-org-index-exclude-patterns '("/\\.git/" "/archive/" "/\\.cache/" "/\\.claude/" "sync-conflict"))
  (anvil-worklog-db-path (mrb/emacs-file ".cache/anvil/anvil-worklog-index.db"))
  (anvil-semantic-db-path (mrb/emacs-file ".cache/anvil/anvil-semantic/index.db"))

  :config
  (setq anvil-org-allowed-files-enabled nil)
  (setq anvil-optional-modules '(state session memory memory-obs sexp org-index))
  (setq anvil-session-hooks-enabled t)
  (setq anvil-memory-obs-enabled t)
  (setq anvil-memory-obs-compress-on-session-end t)
  (setq anvil-memory-obs-use-ai-compression t)
  (setq anvil-session-hook-script
        (expand-file-name "scripts/anvil-hook" (straight--repos-dir "anvil.el")))

  (anvil-enable)
  (unless anvil-server--running
    (anvil-server-start))
  (anvil-org-index-watch))

Memory provider paths

Auto-detection globs ~/.claude/projects/*/memory, so the canonical cross-client memory directory never matched and was invisible to memory-search. These are added rather than set: anvil-memory-roots would override auto-detection and drop every per-project directory, and replacing the provider alist would freeze upstream's client list into this config.

The glob in --expand-provider-pattern matches subdirectories ONLY, so a tree needs one entry per depth: the kb root plus kb/<subject>/, and three for the journal's mixed layout (one file at the root, 226 under year/, 213 under year/month/). The journal is a memory source in its own right — 440 dated entries of what happened, what was decided, what was tried.

(with-eval-after-load 'anvil-memory
  (add-to-list 'anvil-memory-provider-paths
               '(shared . "~/.config/ai/agent/memory"))
  (add-to-list 'anvil-memory-provider-paths
               '(kb . "~/dat/org/kb"))
  (add-to-list 'anvil-memory-provider-paths
               '(kb-subjects . "~/dat/org/kb/*"))
  (add-to-list 'anvil-memory-provider-paths
               '(journal . "~/dat/org/journal"))
  (add-to-list 'anvil-memory-provider-paths
               '(journal-year . "~/dat/org/journal/*"))
  (add-to-list 'anvil-memory-provider-paths
               '(journal-month . "~/dat/org/journal/*/*")))

Indexing org files

anvil-memory-scan globs \\.md\\', so an .org knowledge base is invisible to memory-search. Two sites need widening: the scan glob, and --parse-frontmatter below. The rest is already format-agnostic — --read-body-utf8 is a plain UTF-8 insert, and --infer-type keys off the basename prefix, so .org files index as memo like any unprefixed .md. The remaining .md sites are export (DB → file), basename resolution and promote, none of which a federated read path uses. anvil-memory-prune has its own .md glob and is deliberately not advised: it deletes rows for files it cannot see, and nothing prunes the kb tree. The upstream fix would be a defcustom for the glob.

  (defvar mrb/anvil-memory--scanning-path nil
    "Full path of the file `anvil-memory-scan' is currently registering.
`anvil-memory--infer-type' receives only the basename, so a type that
depends on WHERE a file lives cannot be inferred without this.")

  (defun mrb/anvil-memory-scan-org-too (orig &rest args)
    "Let `anvil-memory-scan' index .org files alongside .md.
Also records each path in `mrb/anvil-memory--scanning-path' so
`mrb/anvil-memory-infer-finding' can type journal entries, whose names are
dates and carry no type prefix."
    (cl-letf* ((real (symbol-function 'directory-files))
               ((symbol-function 'directory-files)
                (lambda (dir &optional full match nosort count)
                  (let ((files (funcall real dir full
                                        (if (equal match "\\.md\\'")
                                            "\\.\\(?:md\\|org\\)\\'"
                                          match)
                                        nosort count)))
                    (setq mrb/anvil-memory--scanning-path dir)
                    files))))
      (unwind-protect (apply orig args)
        (setq mrb/anvil-memory--scanning-path nil))))

  (with-eval-after-load 'anvil-memory
    (advice-add 'anvil-memory-scan :around #'mrb/anvil-memory-scan-org-too))

Titles from org keywords

Indexing alone is not enough: anvil-memory--parse-frontmatter only reads a leading --- YAML block, so an org page falls through to --fallback-display-name and shows up as a filename-derived title with no description — in memory-get and in the generated MEMORY.md index. Since id: links between kb pages are sparse by nature, that index is the only full-coverage view of the tree, and a title like “memo ab probe” makes it useless for deciding what to open. Advising the parser fixes both consumers at once.

Keywords are read only from the preamble, before the first headline, so a #+TITLE: inside an example block cannot hijack the title.

(defun mrb/anvil-memory-parse-org-keywords (orig body)
  "Fall back to org `#+TITLE:' / `#+DESCRIPTION:' when BODY has no YAML."
  (or (funcall orig body)
      (when (stringp body)
        (let* ((case-fold-search t)
               (preamble (substring body 0 (or (string-match "^\\*+[ \t]" body)
                                               (length body))))
               name desc)
          (save-match-data
            (when (string-match "^#\\+TITLE:[ \t]*\\(.+\\)$" preamble)
              (setq name (string-trim (match-string 1 preamble))))
            (when (string-match "^#\\+DESCRIPTION:[ \t]*\\(.+\\)$" preamble)
              (setq desc (string-trim (match-string 1 preamble)))))
          (when (or name desc)
            (list :name name :description desc))))))

(with-eval-after-load 'anvil-memory
  (advice-add 'anvil-memory--parse-frontmatter :around
              #'mrb/anvil-memory-parse-org-keywords))

Memory types anvil does not know

Three types are added. A finding is a discovered fact that corrects an assumption, recorded so it need not be rediscovered (“config lives in ~/.claude.json, NOT the file you would assume”). Its defining feature is surprise, which distinguishes it from reference (facts about a thing) and feedback (a correction to agent behaviour); it is memcp's largest category. A decision is a choice made, why, and what was rejected — memcp's second-largest at 108 nodes. A journal entry is a dated day from ~/dat/org/journal, typed by path rather than prefix because those files are named by date.

Both halves are required for each: --infer-type assigns the type at scan time, while anvil-memory-add validates against anvil-memory-ttl-policies and errors on a type missing from it. Registering only the first would let scan create rows that memory-add then rejects.

  (defun mrb/anvil-memory-infer-finding (orig filename)
    "Recognise prefixes and paths upstream does not know.
`finding_' and `decision_' are filename prefixes; `journal' is decided by the
directory being scanned, because journal entries are named by date and carry
no prefix."
    (cond
     ((and mrb/anvil-memory--scanning-path
           (string-match-p "/dat/org/journal\\(/\\|\\'\\)"
                           mrb/anvil-memory--scanning-path))
      'journal)
     ((string-prefix-p "finding_"  filename) 'finding)
     ((string-prefix-p "decision_" filename) 'decision)
     (t (funcall orig filename))))

  (with-eval-after-load 'anvil-memory
    (add-to-list 'anvil-memory-ttl-policies '(finding . (:hard nil :soft nil)))
    (add-to-list 'anvil-memory-ttl-policies '(journal . (:hard nil :soft nil)))
    (add-to-list 'anvil-memory-ttl-policies '(decision . (:hard nil :soft nil)))
    (advice-add 'anvil-memory--infer-type :around
                #'mrb/anvil-memory-infer-finding))

No TTL expiry

Every type is set to no-expiry, overriding upstream's defaults (project 90d/60d, reference 30d/21d, feedback soft 180d). That policy was never chosen here, it was inherited, and it points the wrong way: reference expired fastest of everything while memo, the fallback for anything with no type prefix, never expired at all — a verified reference page would be flagged stale within a month while an unclassified note lived forever. 52 rows were already flagged before this change. Compounding it, :created comes from file mtime for scanned rows, so a bulk import or a reformat re-dates the corpus and the flags say nothing about whether the content is stale.

anvil-memory-audit only reports a flag — nothing deletes, hides or demotes — so no-expiry loses no data. It loses the review prompt, which is the point: reinstate per-type TTLs once it is settled what should act on them.

(with-eval-after-load 'anvil-memory
  (setq anvil-memory-ttl-policies
        (mapcar (lambda (entry) (cons (car entry) '(:hard nil :soft nil)))
                anvil-memory-ttl-policies)))

Keeping the memory index live

anvil-memory-scan only runs when something calls it, so a page written by an agent stays invisible to memory-search until someone remembers to rescan — and “remember to run it” is exactly the standing discipline that made the previous memory store unreliable.

anvil-memory-prune runs alongside the scan because scan only adds rows: without it a deleted page keeps answering searches. Both take a roots list, and prune walks DB rows rather than globbing, so neither needs the .org advice above.

The refresh returns immediately and hands the work to an idle timer, so the emacsclient call driving it never blocks: a systemd timer fires and exits in milliseconds while the daemon does the scan when it is next idle. Scanning inside the daemon rather than an emacs --batch subprocess also avoids loading this entire config a second time — 4.9s against 15.5s — and the batch process would need the advice above re-established anyway.

A filesystem watcher was tried first and rejected: 52 lines, file-notify events that arrive only when the daemon next runs its event loop, and no events at all under --batch so it could not be tested the usual way. A timer is coarser and much simpler, and the staleness window is bounded by the interval rather than by whether anyone remembered.

  (defun mrb/anvil-memory-refresh ()
    "Re-index every memory root and drop rows whose file is gone.
Returns at once; the work runs on the next idle moment so a caller
driving this over `emacsclient' never blocks."
    (interactive)
    (run-with-idle-timer
     1 nil
     (lambda ()
       (condition-case err
           (message "anvil-memory: indexed %d file(s), pruned %d"
                    (anvil-memory-scan) (anvil-memory-prune))
         (error (message "anvil-memory refresh failed: %S" err)))))
    :queued)

Fast mode and visited buffers

Anvil's fast mode suppresses font-lock and defers mode hooks for performance. It must not apply to buffers the user already has open, only to the temp buffers anvil creates internally.

(defun mrb/anvil-skip-fast-mode-for-visited-buffers (orig-fn &rest args)
  "Skip anvil fast org-mode for already-visited file buffers."
  (if (and (buffer-file-name)
           (eq (current-buffer) (get-file-buffer (buffer-file-name))))
      (let ((anvil-eval--in-org-fast-mode nil))
        (apply orig-fn args))
    (apply orig-fn args)))
(advice-add 'anvil-eval--org-mode-fast-advice
            :around #'mrb/anvil-skip-fast-mode-for-visited-buffers)

Deferred session-end summarization

The session-end hook normally runs AI summarization inline, blocking the daemon for 20-30s. Emacsclient calls queue in the daemon, so that stalls the next session's startup. Deferring to an idle timer lets session-end return immediately.

(defun mrb/anvil-memory-obs-session-end-async (orig-fn session-id)
  "Skip inline AI compression in session-end; defer it to an idle timer."
  (let ((anvil-memory-obs-compress-on-session-end nil))
    (prog1 (funcall orig-fn session-id)
      (when (and (bound-and-true-p anvil-memory-obs-enabled)
                 (stringp session-id)
                 (fboundp 'anvil-memory-obs-summarize-session))
        (run-with-idle-timer
         1 nil
         (lambda ()
           (ignore-errors
             (anvil-memory-obs-summarize-session session-id))))))))
(advice-add 'anvil-memory-obs-record-session-end
            :around #'mrb/anvil-memory-obs-session-end-async)

Ansible

Not really a development setting, but closely related to it, as eventually the programs written need to be deployed. Most of the time, while deploying, ansible plays a role in this and it's now worth having a dedicated configuration for editing the YAML files for it.

For now, just the ansible package, and some access to its documentation will suffice

Both modes hook yaml-ts-mode as well as yaml-mode: the tree-sitter remap opens YAML files in yaml-ts-mode, which does not derive from yaml-mode and so never runs its hook. The ansible keyword fontification still applies on top of tree-sitter.

(use-package ansible
  :after yaml-mode
  :init
  (use-package ansible-doc
    :hook ((yaml-mode yaml-ts-mode) . ansible-doc-mode))
  :hook ((yaml-mode yaml-ts-mode) . (λ () (ansible 1))))

Finale

When we are all done with this, provide it.

(provide 'mrb)
;;; mrb ends here