Forgemacs' Literate Emacs Configuration

Table of Contents

1. Why a literate file?

1.1. Background

I've always been a fan of code that has in-line documentation, pretty much like a good Physics or Mathematics text book. Though I did not know of this in the computing paradigm until I was introduced to Donald Knuth's Literate Programming1.

Org-mode's implementation of this method allows for documenting each decision, providing examples and then generating not just the necessary code, but additional viewing formats such as HTML, PDF, etc. Yes, but why choose this format for an Emacs init file? Well, Emacs, is as extensible as can be2, 3, 4 and things break often5, 6, and there is some joy in ‘yak-shaving’7, 8. Over the last year or so (between 2023 and 2024), I have been using Emacs again and I have been using it continuously. While the first foray was in 2003, and I have always enjoyed it for the short duration that I used it, it never stuck, for a variety of reasons ranging from the fact that my work-flow constantly changed (based on who I was working with and what I was working on)9.

1.2. Emacs literate file

There are different ways one can use an Org mode file to create Emacs init files,

  • Use org-babel-tangle in order to generate the necessary file from the Org file.
  • The other possibility is to use initialise with org-babel using org-babel-load-file

A preliminary search on your favourite site(s) will generate a number of detailed documentation on why and how people use a literate file (along with the drawbacks of doing so) which will perhaps give you a good idea.

1.3. My Set-up

Emacs typically searches for the configuration file in a few bog standard places.

For the purposes of this particular file, I have chosen the location to be ~/.config/emacs having used ~/.emacs.el, ~/.emacs, or ~/.emacs.d/init.el in the past. It is advisable to use a symlink to ~/.emacs.d in case your machine does not have XDG location setup. I have a symbolic link for /.config/emacs from /.emacs.d.

I will move all the comments out of the source blocks and put them in the Org file instead, the aim is to ensure that the generated init file is small (not that the size of it matters), but importantly, to ensure that I can read the logic without having to necessarily look at the code. Please use C-h v and C-h f generously in order to help with understanding each action10.

The configuration will be divided into the following parts,

  1. An initial shell-script that creates the required folder structure
  2. A bog-standard early-init.el with some custom configuration
  3. An init.el file with the configuration
  4. I have included the generation of a HTML file with the theme from ReadTheOrg, I have made a minor few edits to the theme for my use case. In order to have some level of automation, a function bmp/export-tangle has been added in 4.6.7.

The output of M-x emacs-version details are below,

GNU Emacs 30.2 (build 1, x86_64-redhat-linux-gnu, GTK+ Version 3.24.52, cairo version 1.18.4) of 2026-04-22

The system details are as follows,

Linux aurora 6.19.12-200.fc43.x86_64 #1 SMP PREEMPT_DYNAMIC Sun Apr 12 15:26:33 UTC 2026 x86_64 GNU/Linux

This configuration was reviewed with the help of LLM tools11.

1.4. Preamble for git forges

This section generates a markdown for code forges as they do not yet interpret Org files well.

# Introduction

This is my literate Emacs configuration repository, this *README* is auto-generated as code forges do not always render [Org Mode](https://orgmode.org/) files well yet. This configuration is released under [The Unlicense](https://unlicense.org/).

- Website: https://forgemacs.bharathpalavalli.com
- Repository: https://codeberg.org/bmp/forgemacs

# Instructions

1. All details are available in the [Forgemacs Org Mode](forgemacs.org) file. The file is self-contained, if necessary, you can view the [https://forgemacs.bharathpalavalli.com/](HTML) version  in your browser.
2. Upon tangling the contents of the Org file, it generates two files, `init.el` and `early-init.el` in the configured places[^1].
3. The [Forgemacs Org Mode](forgemacs.org) file, has a section to create the necessary folder structures in the appropriate place, they are as follows,

  ```
  ~/.config/emacs/
  ├── early-init.el
  ├── init.el
  ├── gnu_color.webp
  ├── snippets/
  ├── abbrevs/
  └── secrets/

  ```
4. This folder structure is created by running `C-c C-c` within the source block in the Org file for the first time or when new folders are created.

5. The note templates used for `org-roam` are part of the Org file and are tangled into `~/Notes/Templates/`[^2].

6. I have generously borrowed from the following publicly available init files, I have added notes to other links as and when possible in the file if they aren't listed below (in no particular order):

- https://github.com/daviwil/dotfiles
- https://github.com/sachac/.emacs.d/
- https://github.com/renzmann/.emacs.d
- https://github.com/Panadestein/emacsd
- https://gitlab.com/dwt1/dotfiles/-/tree/master/.emacs.d.gnu
- https://github.com/justinbarclay/.emacs.d
- https://gitlab.com/jdm204/dotfiles
- https://github.com/munen/emacs.d/
- https://taingram.org/init.html
- https://github.com/zzamboni/dot-emacs
- https://github.com/lccambiaghi/vanilla-emacs
- https://github.com/frap/emacs-literate
- https://github.com/hrs/dotfiles
- https://github.com/daedreth/UncleDavesEmacs
- https://github.com/angrybacon/dotemacs.git
- https://github.com/jamiecollinson/dotfiles
- https://github.com/tecosaur/emacs-config
- https://justin.abrah.ms/dotfiles/emacs.html
- https://protesilaos.com/emacs/dotemacs


*Footnotes*
[^1]: They are stored in ~/.config/emacs, and this `README.md` file is generated.
[^2]: The simpler templates are plain Org files filled in by `org-roam`; the ones that need logic (such as the tea notes) are Emacs Lisp functions that ask for each detail.

1.5. TODO Copy necessary files and create folder structure

This only needs to be executed the first time, though there are checks in place that make it safe. This structure creates the folder structure mentioned in the preceding section.

# Create ~/.config/emacs/ folder and copy necessary files
if [ -d ~/.config/emacs ]
then
    echo "Emacs folder exists at ~/.config/emacs/"
else
    echo "Emacs folder does not exist in ~/.config"
    mkdir -p ~/.config/emacs/secrets
    mkdir -p ~/.config/emacs/abbrevs
    mkdir -p ~/.config/emacs/config/yasnippet/snippets/org-mode
    cp ~/source/forgemacs/gnu_color.webp ~/.config/emacs/
    cp ~/source/forgemacs/.authinfo.gpg ~/.config/emacs/secrets/
fi
# Create ~/.emacs.d as a symlink to ~/.config/emacs/
if [ -L ~/.emacs.d ] && [ -d ~/.emacs.d ]
then
    echo "~/.emacs.d is already a symlink"
else
    echo "Creating symlink"
    if [ -d ~/.emacs.d ]
    then
        echo "~/.emacs.d exists, removing it"
        rm -rf ~/.emacs.d/
    fi
    ln -s ~/.config/emacs/ ~/.emacs.d
fi

1.6. Prerequisite for this configuration

Here is a list of different software, tools and fonts required to successfully use this configuration,

  • Fonts
    • EB Garamond
    • FiraCode Nerd Font Mono
    • Noto Emoji
    • Symbola
    • FontAwesome
  • Package(s)
    • fortune
    • ripgrep
    • gnupg
    • typst
    • latex
    • mpv
    • pdf2svg
    • poppler
    • ffmpegthumbnailer
  • Tools

    • Zotero
    • Calibre
    • Recoll12
    • LanguageTool13
    • fastText14

    Since I have moved to Aurora Linux, here is the libraries that are needed,

    brew install gcc pkg-config patchelf glib poppler cairo libpng enchant fortune
    # Language servers and the Python linter used through eglot and flymake
    brew install ruff basedpyright gopls rust-analyzer
    # Prose style checking (flymake-vale) and the Typst language server
    brew install vale tinymist
    # Grammar checking (langtool.el); Emacs starts the server on demand,
    # so turn off its autostart: systemctl --user disable --now homebrew.languagetool
    brew install languagetool
    

A local LibreTranslate server, used by bmp/translate in 4.7.10, runs as a Podman container. It only listens on this machine, so nothing I translate leaves it. This Quadlet file is tangled into ~/.config/containers/systemd/, where systemd turns it into a user service. It does not start with my session: Emacs starts it when I translate and stops it after ten idle minutes (and when Emacs exits), which frees its memory (about 1.9 GB with five languages). The language models stay in the named volume, so later starts do not download them again.

The languages to load (LT_LOAD_ONLY) are read from ~/.config/libretranslate/languages.env. Emacs creates that file from bmp/translate-default-languages the first time, and M-x bmp/translate-add-language adds to it. It is not tangled, so languages added from Emacs are kept. Each language adds about 0.25 GB of disk and memory (models to and from English). After tangling:

podman rm -f libretranslate     # remove a container started by hand, if any
systemctl --user daemon-reload
[Unit]
Description=LibreTranslate (local translation for Emacs)

[Container]
Image=docker.io/libretranslate/libretranslate:latest
ContainerName=libretranslate
PublishPort=127.0.0.1:5000:5000
EnvironmentFile=%h/.config/libretranslate/languages.env
Volume=libretranslate-models:/home/libretranslate/.local:Z
# A small init passes the stop signal on; otherwise LibreTranslate (as the
# container's PID 1) ignores it and is killed only after a 10 s timeout
RunInit=yes

[Service]
Restart=on-failure
# A start that downloads new language models can take several minutes
TimeoutStartSec=900

Vale checks prose style through flymake (see 4.15.2). Two configurations are tangled into ~/.config/vale/, and Emacs picks one per buffer:

  • .vale.ini (notes, the default): vale's own rules, proselint and write-good (the same checks as writegood-mode, written as vale rules).
  • publish.ini (writing to be published): the same, plus the Readability scores. Used for blog posts (Org files with a #+hugo_base_dir: line) and for any buffer switched with M-x bmp/vale-toggle-config.

I write in Org, so the rules are set for Org files; Markdown and text files share them, except that Typography stays on there. Rules that duplicate other checks or do not suit Org are turned off:

Rule Setting Why
Vale.Spelling off jinx checks spelling
write-good.Illusions off same as Vale.Repetition (repeated words)
write-good.Cliches off same as proselint.Cliches
write-good.E-Prime off flags almost every sentence with "is" or "are"
write-good.Passive suggestion technical notes legitimately use the passive voice
proselint.Annotations off TODO/DONE at the start of a heading is a task state, not a stray note
proselint.Typography (Org only) off Org turns -- and ... into proper dashes and ellipses on export
Readability (publish.ini only) on reading-difficulty scores, meaningful for published writing
Readability.Polysyllables off flags every long word; the scores already account for them

After tangling, download the three styles once:

vale sync
StylesPath = ~/.local/share/vale/styles
MinAlertLevel = suggestion
Packages = proselint, write-good, Readability

[*.{org,md,txt}]
BasedOnStyles = Vale, proselint, write-good
Vale.Spelling = NO
write-good.Illusions = NO
write-good.Cliches = NO
write-good.E-Prime = NO
write-good.Passive = suggestion
proselint.Annotations = NO

[*.org]
proselint.Typography = NO
StylesPath = ~/.local/share/vale/styles
MinAlertLevel = suggestion
Packages = proselint, write-good, Readability

[*.{org,md,txt}]
BasedOnStyles = Vale, proselint, write-good, Readability
Readability.Polysyllables = NO
Vale.Spelling = NO
write-good.Illusions = NO
write-good.Cliches = NO
write-good.E-Prime = NO
write-good.Passive = suggestion
proselint.Annotations = NO

[*.org]
proselint.Typography = NO

Instructions for jinx,

  • Define paths for Enchant (Update version if brew info changes)
  • Compile the object file as Position Independent Code (-fPIC)
  • Link as a shared library (.so)
  • Move to the builds folder
ENCHANT_DIR=$(brew --prefix enchant)

gcc -I. -O3 -fPIC \
    $(pkg-config --cflags enchant-2) \
    -c jinx-mod.c -o jinx-mod.o

gcc -shared -o jinx-mod.so jinx-mod.o \
    $(pkg-config --libs enchant-2) \
    -Wl,-rpath,${ENCHANT_DIR}/lib


mkdir -p ~/.config/emacs/elpaca/builds/jinx/
cp jinx-mod.so ~/.config/emacs/elpaca/builds/jinx/

1.7. Emacs daemon life-cycle and systemd integration

This configuration intentionally uses a systemd-managed Emacs daemon.

Emacs is started as a user service via:

emacs --fg-daemon

and is expected to remain running for long periods (days or weeks).

All GUI frames are created using:

emacsclient -c

KDE launchers invoke emacsclient only; they do not start Emacs directly.

1.7.1. Design rationale

  • systemd is the single authority for the Emacs daemon lifecycle
  • Emacs is long-lived, matching daily laptop usage
  • Clean shutdown on logout is handled by systemd (SIGTERM, bounded timeout)
  • No fallback daemons are started by emacsclient
  • The mental model is simple: “systemd owns Emacs”

1.7.2. systemd service notes

The user service includes:

  • StopWhenUnneeded=yes
  • KillSignal=SIGTERM
  • TimeoutStopSec=30

These ensure clean shutdown without forcing Emacs to quit abruptly.

The service intentionally uses the default Type=simple.

When started with emacs --fg-daemon, systemd reports the unit as activating (start) indefinitely. This is expected and harmless:

  • Emacs does not emit a systemd readiness notification in this mode
  • The daemon is fully usable as soon as emacsclient can connect
  • The authoritative health check is emacsclient -c

Do not attempt to "fix" this with Type=notify or wrapper scripts.

1.7.3. Socket activation

systemd socket activation was evaluated and deliberately rejected.

While socket activation works technically, it interacts poorly with KDE launchers and emacsclient --alternate-editor, which can bypass systemd control and spawn unmanaged daemons.

Given that Emacs is expected to run continuously, an always-on systemd-managed daemon is simpler, more predictable, and easier to debug.

2. Creating early-init.el

I use ~/.config/emacs/ in the src block definition to tangle the file in the correct location. This file is loaded before the package system or GUI of Emacs is loaded, and is ideally code that does not depend on any packages or on the size of the frame.

  • Inhibit packages at start-up with (setq package-enable-at-startup nil) as I will be using Elpaca
  • Increase garbage collection threshold, this speeds up launch of Emacs and while I set it up to be large to speed up launch of Emacs, we can return it back to normalcy later on. The reason it is good to reset is that when the threshold is hit, Emacs clears it the garbage, but it is a blocking process, so if I have a high number, then Emacs will hold up (hang/pause/wait) until the process is completed. By resetting it to a saner number after start-up, we can ensure that such hold-ups to not happen.
  • file-name-handler-alist is used an a list that is used to decide how to handle various file types. Similarly, if we use the default values, it will slow down Emacs substantively, instead we set it to nil at start-up and then restore it after start-up using the start-up hook.
  • I have --with-native-compilation in my build configuration for Emacs, but since I do depend on external packages, when I load Emacs and it spews a bunch of warnings, which often I can do nothing about. I had turned JIT compilation off to avoid this, but Elpaca only byte-compiles, so nothing was ever native-compiled. JIT compilation is now on: packages are compiled in the background the first time they load (and after updates). The compiler's warnings are almost always about optional dependencies of a package that I do not use, so they are kept out of *Warnings* (via warning-suppress-log-types) and only recorded in *Async-native-compile-log*. That way *Warnings* only shows problems with my own configuration.
  • Avoid initial white screen/flash of light and start in full-screen mode.
  • Ensure GTK never sees an unspecified background (pgtk daemon fix)
;; -*- lexical-binding: t; -*-
;; WARNING: This file is generated by the org file, please do not edit this file directly

(setq package-enable-at-startup nil)

(setq gc-cons-threshold most-positive-fixnum
      gc-cons-percentage 0.6)

(defvar default--file-name-handler-alist file-name-handler-alist)
(setq file-name-handler-alist nil)

(add-hook 'after-init-hook
          (lambda ()
            ;; Reasonable defaults during active use
            (setq gc-cons-threshold (* 32 1024 1024)
                  gc-cons-percentage 0.1
                  file-name-handler-alist default--file-name-handler-alist)))


;; Native compilation: compile packages in the background, quietly
(setq native-comp-jit-compilation t
      native-comp-async-report-warnings-errors 'silent)
;; Keep them out of *Warnings* entirely; they stay in *Async-native-compile-log*
(setq warning-suppress-log-types '((native-compiler)))

(add-to-list 'default-frame-alist '(background-color . "#292c2f"))

(when (daemonp)
  (let ((dummy-frame (car (frame-list))))
    (set-face-background 'default "black" dummy-frame)
    (set-face-foreground 'default "white" dummy-frame)))

(setq frame-resize-pixelwise t
      frame-inhibit-implied-resize t
      frame-title-format '("%b")
      ring-bell-function 'ignore
      use-dialog-box t
      use-file-dialog nil
      use-short-answers t
      inhibit-splash-screen t
      inhibit-startup-screen t
      inhibit-x-resources t
      inhibit-startup-echo-area-message user-login-name
      inhibit-startup-buffer-menu t)

(menu-bar-mode -1)
(scroll-bar-mode -1)
(tool-bar-mode -1)

(setq scroll-margin 0
      scroll-conservatively 100000
      scroll-preserve-screen-position 1)

3. Initial configuration

A bunch of initial configuration for init.el to ensure smooth start-up.

3.1. Lexical binding

It is an interesting feature that was introduced a while ago in Emacs15, using this in the init file will help ensure that

;; -*- lexical-binding: t; -*-
;; WARNING: This file is generated by an org file, please do not edit this file directly

3.2. Using Elpaca.el

Emacs package manager has come a long way and is fairly wonderful, but having tried straight.el and Elpaca, I prefer the simplicity and flexibility that Elpaca provides (not to mention the UX).

  • The default portion is used from https://github.com/progfolio/elpaca to ensure that Elpaca is loaded.
  • Elpaca can hang sometimes as it asynchronously tries to fetch a bunch of packages. Set a queue limit to ensure that this happens in a saner manner.
  • Having migrated from straight.el to Elpaca, one of the things that made it smooth for migration was the coupling with use-package, in order to continue this, Elpaca is also integrated with use-package.
  • Elpaca provides support for ensure, it is a good way to add specific repository details, etc. Therefore using (elpaca-wait) is the suggested way to achieve this.
  • Added https://github.com/progfolio/elpaca/wiki/Reloading-a-package%E2%80%99s-features-after-updating-a-package to be able to reload a package.
  • Avoid loading of built-in eldoc, see https://github.com/progfolio/elpaca/issues/236#issuecomment-1879838229
  • Updating compat, jsonrpc and transient from the default packages to avoid issues with Emacs older versions.
  • consult is declared here, before anything that depends on it. If a package is first queued as a dependency (consult is needed by consult-dir, which comes earlier in this file) and then declared again, Elpaca warns "previously queued as dependency", and in the daemon that warning aborts the rest of init.el. Its use-package form in 4.8.6 therefore uses :ensure nil.
(defvar elpaca-installer-version 0.12)
(defvar elpaca-directory (expand-file-name "elpaca/" user-emacs-directory))
(defvar elpaca-builds-directory (expand-file-name "builds/" elpaca-directory))
(defvar elpaca-sources-directory (expand-file-name "sources/" elpaca-directory))
(defvar elpaca-order '(elpaca :repo "https://github.com/progfolio/elpaca.git"
                              :ref nil :depth 1 :inherit ignore
                              :files (:defaults "elpaca-test.el" (:exclude "extensions"))
                              :build (:not elpaca-activate)))
(let* ((repo  (expand-file-name "elpaca/" elpaca-sources-directory))
       (build (expand-file-name "elpaca/" elpaca-builds-directory))
       (order (cdr elpaca-order))
       (default-directory repo))
  (add-to-list 'load-path (if (file-exists-p build) build repo))
  (unless (file-exists-p repo)
    (make-directory repo t)
    (when (<= emacs-major-version 28) (require 'subr-x))
    (condition-case-unless-debug err
        (if-let* ((buffer (pop-to-buffer-same-window "*elpaca-bootstrap*"))
                  ((zerop (apply #'call-process `("git" nil ,buffer t "clone"
                                                  ,@(when-let* ((depth (plist-get order :depth)))
                                                      (list (format "--depth=%d" depth) "--no-single-branch"))
                                                  ,(plist-get order :repo) ,repo))))
                  ((zerop (call-process "git" nil buffer t "checkout"
                                        (or (plist-get order :ref) "--"))))
                  (emacs (concat invocation-directory invocation-name))
                  ((zerop (call-process emacs nil buffer nil "-Q" "-L" "." "--batch"
                                        "--eval" "(byte-recompile-directory \".\" 0 'force)")))
                  ((require 'elpaca))
                  ((elpaca-generate-autoloads "elpaca" repo)))
            (progn (message "%s" (buffer-string)) (kill-buffer buffer))
          (error "%s" (with-current-buffer buffer (buffer-string))))
      ((error) (warn "%s" err) (delete-directory repo 'recursive))))
  (unless (require 'elpaca-autoloads nil t)
    (require 'elpaca)
    (elpaca-generate-autoloads "elpaca" repo)
    (let ((load-source-file-function nil)) (load "./elpaca-autoloads"))))
(add-hook 'after-init-hook #'elpaca-process-queues)
(elpaca `(,@elpaca-order))
(setq elpaca-queue-limit 30)

(elpaca elpaca-use-package
  (elpaca-use-package-mode))

;; CRITICAL FIX: Force Elpaca to fully activate Org 10.0-pre and project
;; before ANY other package can trigger the built-in versions.
(elpaca project)
;; Elpaca's default Org recipe does not copy etc/org-babel/, which holds
;; the ob_python_utils.py helper that Org 10 needs for Python blocks
;; with `:results value' (the default).
(elpaca (org :files (:defaults ("etc/styles/" "etc/styles/*" "doc/*.texi")
                               ("etc/org-babel/" "etc/org-babel/*.py"))))
(elpaca-wait)

(use-package eldoc
  :ensure nil
  :demand t
  :init
  (unload-feature 'eldoc t)
  (setq custom-delayed-init-variables '())
  (defvar global-eldoc-mode nil)
  :config
  (global-eldoc-mode 1)
  :custom
  (eldoc-echo-area-use-multiline-p nil))

(use-package compat
  :ensure t)
(use-package jsonrpc
  :ensure t)
(use-package transient
  :ensure t)
;; Declared here so it is not first pulled in as a dependency of
;; consult-dir; configured in the Consult section
(elpaca consult)

(elpaca-wait)

(defun bmp/elpaca-reload-package (package &optional allp)
  "Reload PACKAGE's features.
             If ALLP is non-nil (interactively, with prefix), load all of its
             features; otherwise only load ones that were already loaded.

             This is useful to reload a package after upgrading it.  Since a
             package may provide multiple features, to reload it properly
             would require either restarting Emacs or manually unloading and
             reloading each loaded feature.  This automates that process.

             Note that this unloads all of the package's symbols before
             reloading.  Any data stored in those symbols will be lost, so if
             the package would normally save that data, e.g. when a mode is
             deactivated or when Emacs exits, the user should do so before
             using this command."
  (interactive
   (list (let ((elpaca-overriding-prompt "Reload package: "))
           (elpaca--read-queued))
         current-prefix-arg))
  ;; This finds features in the currently installed version of PACKAGE, so if
  ;; it provided other features in an older version, those are not unloaded.
  (when (yes-or-no-p (format "Unload all of %s's symbols and reload its features? " package))
    (let* ((package-name (symbol-name package))
           (package-dir (file-name-directory
                         (locate-file package-name load-path (get-load-suffixes))))
           (package-files (directory-files package-dir 'full (rx ".el" eos)))
           (package-features
            (cl-loop for file in package-files
                     when (with-temp-buffer
                            (insert-file-contents file)
                            (when (re-search-forward (rx bol "(provide" (1+ space)) nil t)
                              (goto-char (match-beginning 0))
                              (cadadr (read (current-buffer)))))
                     collect it)))
      (unless allp
        (setf package-features (seq-intersection package-features features)))
      (dolist (feature package-features)
        (ignore-errors
          ;; Ignore error in case it's not loaded.
          (unload-feature feature 'force)))
      (dolist (feature package-features)
        (require feature))
      (when package-features
        (message "Reloaded: %s" (mapconcat #'symbol-name package-features " "))))))

3.2.1. Elpaca native compilation

Elpaca only byte-compiles packages; native compilation is done by Emacs itself in the background (see 2). I used to set elpaca-queue-limit to 1 here and an elpaca-wait-for-compilation variable, believing this made Elpaca native-compile on install. That variable does not exist, and a limit of 1 only meant every package was built one at a time. The limit of 30 set above is the one in use.

3.3. Personal and local settings

This file is public, so anything that identifies me or my work lives in two private files next to my secrets, never in this repository:

  • secrets/local.el holds my folders (notes, Calibre library, project folders). It is not encrypted (it only names folders) and is loaded while Emacs starts, because org-roam needs the notes folder straight away.
  • secrets/personal.el.gpg holds everything else: my Org tags and work tags, bibliography files, IRC channels, Mastodon and Wallabag accounts and my blog's folder. It is encrypted to my GPG key, so a copy shared by mistake cannot be read. Decrypting it may need my passphrase, so it is loaded when the first frame appears (or right after start-up when Emacs is not a daemon), not while the daemon starts without a frame.
  • secrets/elfeed.score.gpg holds my feed-scoring rules (my interests), also encrypted; see 4.18.7.

Below, every setting is declared with a neutral default (empty, or a general folder) and a description, so this file still documents what each one is. After the private files are loaded, bmp/personal-check lists any setting that is still empty in *Warnings*. Sections that use these values set them in bmp/personal-loaded-hook, so nothing breaks while a value is missing.

To create or change the encrypted file, edit a plain copy and encrypt it to my key (its first line tells Emacs which key to use when I save it from Emacs later):

gpg -e -r mail@bharathpalavalli.com -o ~/.config/emacs/secrets/personal.el.gpg personal.el
shred -u personal.el     # remove the plain copy
;; Folders, from secrets/local.el (loaded now)
(defvar bmp/notes-directory "~/Notes/"
  "Root of my notes (org-roam).")
(defvar bmp/calibre-directory "~/Calibre/"
  "My Calibre library.")
(defvar bmp/project-search-paths '("~/source/")
  "Folders whose Git repositories project.el should know (one level deep).")

(load (expand-file-name "secrets/local.el" user-emacs-directory) t t)
;; Older local.el files used the projectile name
(when (boundp 'bmp/projectile-search-paths)
  (setq bmp/project-search-paths (symbol-value 'bmp/projectile-search-paths)))

;; Everything else, from secrets/personal.el.gpg (loaded with the first frame)
(defvar bmp/personal-file
  (expand-file-name "secrets/personal.el.gpg" user-emacs-directory)
  "Encrypted file with my personal settings.")
(defvar bmp/org-tags nil
  "Tags offered when tagging Org headings, in `org-tag-alist' format.")
(defvar bmp/work-tags nil
  "Tags of my work projects, grouped together in the agenda.")
(defvar bmp/bibliography nil
  "Bibliography (.bib) files exported automatically from Zotero.")
(defvar bmp/irc-networks nil
  "IRC networks and their servers, in `erc-networks-alist' format.")
(defvar bmp/irc-channels nil
  "Channels to join per network, in `erc-autojoin-channels-alist' format.")
(defvar bmp/irc-password-networks nil
  "IRC networks that need a password (looked up in authinfo).")
(defvar bmp/mastodon-instance-url nil
  "Address of my Mastodon instance.")
(defvar bmp/mastodon-user nil
  "My Mastodon username.")
(defvar bmp/wallabag-user nil
  "My Wallabag username.")
(defvar bmp/hugo-base-dir nil
  "Folder of my Hugo blog, used by the ox-hugo snippet.")
(defvar bmp/elfeed-score-file
  (expand-file-name "secrets/elfeed.score.gpg" user-emacs-directory)
  "Encrypted file with my elfeed scoring rules.")

(defvar bmp/personal-loaded-hook nil
  "Run after the personal settings are loaded, to apply them.")

(defvar bmp/personal-variables
  '(bmp/org-tags bmp/work-tags bmp/bibliography bmp/irc-networks
    bmp/irc-channels bmp/irc-password-networks bmp/mastodon-instance-url
    bmp/mastodon-user bmp/wallabag-user bmp/hugo-base-dir)
  "Personal settings expected in `bmp/personal-file'.")

(defun bmp/personal-check ()
  "Warn about personal settings or files that are still missing."
  (let ((missing (seq-remove #'symbol-value bmp/personal-variables)))
    (unless (file-exists-p bmp/personal-file)
      (display-warning
       'personal (format "%s not found; personal settings are empty"
                         bmp/personal-file)))
    (when missing
      (display-warning
       'personal
       (format "Personal settings missing: %s.  Add them to %s"
               (mapconcat #'symbol-name missing ", ") bmp/personal-file)))
    (unless (file-exists-p bmp/elfeed-score-file)
      (display-warning
       'personal (format "%s not found; elfeed scoring is off"
                         bmp/elfeed-score-file)))))

(defvar bmp/personal-loaded nil
  "Non-nil once `bmp/personal-load' has run.")

(defun bmp/personal-load (&rest _)
  "Load the encrypted personal settings once, apply them and check them."
  (unless bmp/personal-loaded
    (setq bmp/personal-loaded t)
    (when (file-exists-p bmp/personal-file)
      (condition-case err
          (load bmp/personal-file nil t t)
        (error (display-warning
                'personal (format "Could not load %s: %s" bmp/personal-file
                                  (error-message-string err))))))
    (run-hooks 'bmp/personal-loaded-hook)
    (bmp/personal-check)))

(if (daemonp)
    (add-hook 'server-after-make-frame-hook #'bmp/personal-load)
  ;; After start-up, once file handlers (which decrypt .gpg files) are back
  (add-hook 'after-init-hook #'bmp/personal-load 90))

3.4. DONE Setting up user-defined variables

Based on the packages I am using, I plan to define variables in this section.

  • Create directory for backing up files
  • Add user name
  • Set file for custom settings
  • Create auto-save list
  • Set variables for fortune command for use in dashboard
  • Let project.el know the projects under my folders
  • Set up file for abbreviations
(setq custom-file "~/.config/emacs/emacs-custom-settings.el"
      user-full-name "Bharath M. Palavalli"
      backup-directory-alist
      `(("." . ,(expand-file-name  "~/.config/emacs/backups")))
      auto-save-file-name-transforms
      `((".*" "~/.config/emacs/auto-save-list") t))

;; Redirect fortune to Homebrew's data files
(setq fortune-dir "/home/linuxbrew/.linuxbrew/share/games/fortunes"
      fortune-file "/home/linuxbrew/.linuxbrew/share/games/fortunes/fortunes")

(defvar org-roam-directory (file-truename bmp/notes-directory)
  "Define value for `org-roam-directory' to ensure it is available for helper functions.")

(defvar org-roam-db-location (expand-file-name "org-roam.db" org-roam-directory)
  "Define value for `org-roam-db-directory' to ensure it is available for helper functions.")

;; Let project.el know the projects under my folders (see [[*Project handling]])
(defun bmp/project-remember-search-paths ()
  "Remember the projects directly inside `bmp/project-search-paths'."
  (require 'project)
  (dolist (dir bmp/project-search-paths)
    (when (file-directory-p dir)
      (project-remember-projects-under (expand-file-name dir))))
  ;; ~/Notes is synced, not a Git repository; a .project file marks it
  (when (file-exists-p (expand-file-name ".project" bmp/notes-directory))
    (project-remember-project (project-current nil bmp/notes-directory))))

(run-with-idle-timer 5 nil #'bmp/project-remember-search-paths)

(setq abbrev-file-name "~/.config/emacs/abbrevs/custom-abbrevs")
  1. Use System Default Browser via Systemd (Flatpak/Host Friendly)

    Since this my move to Aurora Linux, which relies heavily on a systemd-managed user daemon, launching external links can fail due to missing environment variables (like DISPLAY or WAYLAND_DISPLAY). This hand off tells the systemd user session manager to invoke xdg-open directly, ensuring links clicked inside Emacs seamlessly open in your host system's default browser (e.g., Flatpak Firefox) without hard-coding paths.

    (defun bmp/browse-url-default-browser (url &optional _new-window)
      "Hand off URL directly to Flatpak Firefox via systemd user manager."
      (interactive (browse-url-interactive-arg "URL: "))
      (let ((default-directory "/"))
        (start-process "default-browser-open" nil
                       "systemd-run" "--user" "flatpak" "run" "org.mozilla.firefox" url)))
    
    (setq browse-url-browser-function 'bmp/browse-url-default-browser)
    

3.4.1. Setting up environment variables

Add variables to account for Aurora and libraries installed through homebrew

(let ((brew-bin "/home/linuxbrew/.linuxbrew/bin"))
  (when (file-directory-p brew-bin)
    ;; 1. Handle system binary paths (Your original code)
    (setenv "PATH" (concat brew-bin ":" (getenv "PATH")))
    (add-to-list 'exec-path brew-bin)

    ;; 2. Add package config mapping (Crucial for building R packages like showtext/sysfonts)
    (setenv "PKG_CONFIG_PATH" "/home/linuxbrew/.linuxbrew/lib/pkgconfig:/home/linuxbrew/.linuxbrew/share/pkgconfig")

    ;; 3. Route ESS interactive sessions (M-x R)
    (setq inferior-R-program-name "/home/linuxbrew/.linuxbrew/opt/r/bin/R")

    ;; 4. Route Org Babel background evaluation (C-c C-c)
    (setq org-babel-R-command "/home/linuxbrew/.linuxbrew/opt/r/bin/R --slave --no-save")))

I no longer add Homebrew's lib directory to LD_LIBRARY_PATH for the whole of Emacs. Every program Emacs starts (git, flatpak, systemd-run, Claude Code) inherited it, which can load Homebrew's copies of system libraries into host programs. Homebrew's own binaries (R, node) find their libraries without it. The only thing that needs it is epdfinfo, which I build against Homebrew's poppler, so it is given the variable on its own in 4.11.1.

The systemd unit for the daemon also sets LD_LIBRARY_PATH, so remove those lines from the unit (systemctl --user edit --full emacs) for this to take full effect.

3.5. Setting up some basic parameters

Emacs has some strange behaviour hard-coded, that almost everyone customises/modifies16, this section has a bunch of them modified.

  • Use a single space in sentences
  • When showing errors, scroll to the first error
  • Accept short answers
  • Emacs ellipses look ugly to say the least
  • I do not foresee multiple/concurrent access to the same file(s), avoid locking files
  • Avoid writing into init file that is auto-generated, create a different custom file for writing them and load the file.
  • Treat all custom themes as safe and do not prompt for checking
  • Set tab-width as 4 and do not mix the usage of tabs and spaces, all tabs are not converted as spaces17
  • Since I have space (hard disks are now in TBs and not in MBs/GBs),
    • Keep numbered backups (version-control): the 20 newest and 5 oldest versions of each file are kept, and versions in between are deleted without asking. I had delete-old-versions set to 0, which also deletes without asking, and numbered backups were never turned on, so only one backup was ever kept
    • savehist-mode and recentf-mode are no longer enabled here. Doing it this early ran before no-littering had set their file names, so recentf read the old ~/.emacs.d/recentf. Both are enabled after start-up in their own sections (4.8.1, 4.7.5)
    • Trust my own configuration (~/.config/emacs/ and this repository) through trusted-content, so that Emacs 30 lets flymake byte-compile Emacs Lisp files there; elsewhere it refuses with "untrusted content"
    • Backup files that are under version control too
    • Make suitable changes to the file name
  • Save history for commands
  • Enable recent files (for dashboard and others)
  • Do not truncate history list
  • If an entry exists previously in history, then delete it (I don't need a log of commands in a chronological manner)
  • Save history from recorded mini-buffer too
  • Save search-ring and regexp-search-ring. I no longer save the kill-ring or register-alist: anything copied (passwords, API keys, tokens from the browser) was being written in plain text to data/savehist.el and kept across restarts
  • Save bookmarks to file every time it is created/deleted18
  • Allow for following visiting symbolically linked files under version control
  • Prefer Unicode and utf-8 system for Emacs and as I will be copying from multiple sources, set it to Unicode so that it is useful.
  • Delete trailing white-spaces for Emacs
  • Do not litter, yet another bad habit that Emacs has. Let us use the https://github.com/emacscollective/no-littering package as suggested by https://systemcrafters.net/emacs-tips/keeping-folders-clean/ to avoid any littering and text based on https://stackoverflow.com/questions/39091433/viewing-reloading-emacs-backup-files
  • Convert "yes or no" to "y or n"
  • Suppress “ad-handle-definition: .. redefined” warnings during Emacs start-up
  • Don't delay warnings and show warnings appropriately
  • Set-up time and date formats for local enIN
  • Modify controls for zooming
  • Silence obsolete byte-compiler warnings from long-lived daemons
  • Tells Emacs to “Prefer newer source quietly” in the notice/message buffer

    (setq
     sentence-end-double-space nil
     compilation-scroll-output 'first-error
     use-short-answers t
     truncate-string-ellipsis "…"
     create-lockfiles nil
    
     custom-safe-themes t
    
     version-control t
     kept-new-versions 20
     kept-old-versions 5
     delete-old-versions t
     vc-make-backup-files t
    
     history-delete-duplicates t
     savehist-save-minibuffer-history t
     savehist-additional-variables
     '(search-ring
       regexp-search-ring)
     bookmark-save-flag 1
     vc-follow-symlinks t
    
     select-enable-clipboard t
     select-enable-primary nil
     select-active-regions nil
     )
    
    (setq trusted-content
          (mapcar (lambda (dir)
                    (file-name-as-directory
                     (abbreviate-file-name (file-truename dir))))
                  '("~/.config/emacs/" "~/source/forgemacs/")))
    
    (custom-set-variables '(ad-redefinition-action (quote accept)))
    
    (setq-default tab-width 4
                  indent-tabs-mode nil)
    
    (defun dont-delay-compile-warnings (fun type &rest args)
      (if (eq type 'bytecomp)
          (let ((after-init-time t))
            (apply fun type args))
        (apply fun type args)))
    (advice-add 'display-warning :around #'dont-delay-compile-warnings)
    
    (setq byte-compile-warnings '(not obsolete))
    
    (setq load-prefer-newer noninteractive)
    
    (load custom-file t)
    
    (set-charset-priority 'unicode)
    (prefer-coding-system 'utf-8-unix)
    (customize-set-variable 'selection-coding-system 'utf-8)
    
    (add-hook 'before-save-hook #'delete-trailing-whitespace)
    
    
    (setq no-littering-etc-directory
          (expand-file-name "config/" user-emacs-directory))
    (setq no-littering-var-directory
          (expand-file-name "data/" user-emacs-directory))
    
    (use-package no-littering
      :ensure (:host github :repo "emacscollective/no-littering"))
    
    (defun force-backup-of-buffer ()
      (setq buffer-backed-up nil))
    (add-hook 'before-save-hook #'force-backup-of-buffer)
    
    (defun format-date (format)
      (let ((system-time-locale "en_IN.UTF-8"))
        (insert (format-time-string format))))
    
    (defun bmp/insert-date ()
      (interactive)
      (format-date "%A, %d %b %Y"))
    
    (defun bmp/insert-date-and-time ()
      (interactive)
      (format-date "%d/%m/%Y, %r"))
    
    (defun bmp/insert-time ()
      (interactive)
      (format-date "%r"))
    
    (global-set-key (kbd "C-+") 'text-scale-increase)
    (global-set-key (kbd "C--") 'text-scale-decrease)
    

3.6. GPG access

One of the running themes for this configuration file is that Emacs is not "just" a text editor in my case, I use it to connect to a number of services that require authentication, secrets and password management (not all of them use SSO or external token systems yet). Emacs provides a simple manner by which we can store them by encrypting the file with GPG. The post by Mickey Peterson at Mastering Emacs has more details. Ensure that loopback is configured in ./gpg-agent.conf with allow-loopback-pinentry. auth-source-debug stays off: when it is on, every lookup (hosts, users, ports) is written to *Messages*, and from there into anything that captures that buffer. Turn it on temporarily with M-: (setq auth-source-debug t) only while debugging a lookup.

(setq auth-source-debug nil)

(setq auth-sources '("~/.config/emacs/secrets/.authinfo.gpg"))

(setq epg-pinentry-mode 'loopback)

;; Use Aurora's GnuPG, whose agent the desktop session runs. Homebrew
;; installs a newer gpg as a dependency (of poppler and gpgme) and puts it
;; first on PATH; mixing the two warns that the agent is "older than us".
(setq epg-gpg-program "/usr/bin/gpg"
      epg-gpgconf-program "/usr/bin/gpgconf")

This function helps me access the GPG keys from within this configuration so that I can connect to external services easily (sourced from https://github.com/daviwil/emacs-from-scratch/blob/master/show-notes/Emacs-Tips-Pass.org).

(defun bmp/lookup-password (&rest keys)
  "Look up a password, flushing the cache if it fails to ensure no stale nils.

   This function searches for credentials using `auth-source-search`
   with the given KEYS, which should be property-value pairs like:

  (bmp/lookup-password :host \"example.com\")"
  (let ((result (apply #'auth-source-search (append keys '(:max 1 :require (:secret))))))
    (if result
        (let ((secret (plist-get (car result) :secret)))
          (cond
           ((functionp secret) (funcall secret))
           ((stringp secret) secret)
           (t nil)))
      (auth-source-forget keys)
      nil)))

(defun bmp/lookup-auth (&rest keys)
  "Look up full auth plist using `auth-source-search`, requiring at least :user."
  (car (apply #'auth-source-search (append keys '(:require (:user)))))
  )

3.7. Garbage collection

This section is to ensure that the GC remains "daemon-safe"


(defvar bmp/gc-idle-delay 5
  "Seconds of idle time before running garbage collection.")

(defun bmp/run-gc ()
  "Run garbage collection when Emacs is idle."
  (when (and (not (minibufferp))
             (not (active-minibuffer-window)))
    (let ((gc-cons-threshold (* 256 1024 1024))
          (gc-cons-percentage 0.6))
      (garbage-collect))))

(run-with-idle-timer bmp/gc-idle-delay t #'bmp/run-gc)

4. Configuration

4.1. Help with Emacs commands

While the help system in Emacs is very useful, it is sometimes prudent to use on-demand and completion-at-point methods to understand the commands being executed. I use which-key to remember short-cuts, helpful to show a better help and guru-mode to try and improve usage of Emacs commands rather than generic keys. In addition, I have added the opinionated casual-suite to help me keep up with commands.19


(use-package which-key
  :ensure t
  :init
  (which-key-mode 1))

(use-package which-key-posframe
  :ensure t
  :init
  (setq which-key-posframe-poshandler 'posframe-poshandler-frame-center)
  (which-key-posframe-mode 1))


(use-package helpful
  :ensure t)

(global-set-key (kbd "C-h f") #'helpful-callable)

(global-set-key (kbd "C-h v") #'helpful-variable)
(global-set-key (kbd "C-h k") #'helpful-key)
(global-set-key (kbd "C-h x") #'helpful-command)

(global-set-key (kbd "C-c h d") #'helpful-at-point)

(global-set-key (kbd "C-h F") #'helpful-function)

(use-package guru-mode
  :ensure t
  :config
  (setq guru-warn-only t)
  (guru-global-mode +1))

(use-package casual-suite
  :ensure t
  :config
  (with-eval-after-load 'calc
    (keymap-set calc-mode-map "C-o" #'casual-calc-tmenu))
  (with-eval-after-load 'dired
    (keymap-set dired-mode-map "C-o" #'casual-dired-tmenu))
  (keymap-set isearch-mode-map "C-o" #'casual-isearch-tmenu)
  (with-eval-after-load 'ibuffer
    (keymap-set ibuffer-mode-map "C-o" #'casual-ibuffer-tmenu)
    (keymap-set ibuffer-mode-map "F" #'casual-ibuffer-filter-tmenu)
    (keymap-set ibuffer-mode-map "s" #'casual-ibuffer-sortby-tmenu))
  (with-eval-after-load 'info
    (keymap-set Info-mode-map "C-o" #'casual-info-tmenu))
  (with-eval-after-load 're-builder
    (keymap-set reb-mode-map "C-o" #'casual-re-builder-tmenu)
    (keymap-set reb-lisp-mode-map "C-o" #'casual-re-builder-tmenu))
  (with-eval-after-load 'bookmark
    (keymap-set bookmark-bmenu-mode-map "C-o" #'casual-bookmarks-tmenu))
  (with-eval-after-load 'org-agenda
    (keymap-set org-agenda-mode-map "C-o" #'casual-agenda-tmenu))
  (with-eval-after-load 'symbol-overlay
    (keymap-set symbol-overlay-map "C-o" #'casual-symbol-overlay-tmenu))
  (keymap-global-set "M-g" #'casual-avy-tmenu)
  (keymap-global-set "C-o" #'casual-editkit-main-tmenu))

Each mode map only exists once its package is loaded, so the bindings are added with with-eval-after-load. Binding them directly failed on start-up with "Symbol's value as variable is void: calc-mode-map", which stopped the rest of this block, so none of these menus were bound.

4.1.1. Command usage

To learn which commands and keys I actually use (and which packages I do not), every command is counted: which command, and whether I ran it with a key (and which) or with M-x. Counting adds one entry to a table per command, which is not noticeable. The counts are kept in data/command-usage.el, saved every ten idle minutes and when Emacs exits, so they build up across restarts. Typing itself (self-insert commands) is not counted.

M-x bmp/command-usage-report shows:

  • commands I run with M-x that have a key (keys worth learning)
  • keys with a standard Emacs equivalent that I press instead (arrows for C-n / C-p / C-f / C-b, Home / End for C-a / C-e, Page Up / Down for M-v / C-v and so on)
  • my keys that replace a stock Emacs binding, with what emacs -Q has there (worked out by asking a clean Emacs)
  • the most used commands, with their package
  • installed packages none of whose commands I have used. Those that work through modes or hooks (jinx, valign, diff-hl and so on) are marked, since they are used without commands.

The report is read-only: n / p move between sections, TAB folds one, g refreshes it and q closes it. Keys pressed inside the minibuffer are counted as themselves (not as the key that opened it), and commands run by other commands are counted, but kept out of the key sections. guru-mode replaces every key on its list (arrows, Ctrl/Meta-arrows, Home, End, Page Up/Down, Delete and their Ctrl versions) with an unnamed wrapper that warns and runs the Emacs command, so those presses are counted under the command they stand for, using guru-mode's own list (plus Ctrl-Backspace, Delete and the mouse wheel). M-x bmp/command-usage-reset starts the counts again. guru-mode warns about arrow keys as I press them; the report shows the totals over weeks.

(defvar bmp/command-usage-file
  (expand-file-name "command-usage.el" no-littering-var-directory)
  "Where command counts are kept.")

(defvar bmp/command-usage (make-hash-table :test 'equal)
  "Counts, keyed by (COMMAND . HOW): HOW is a key description or \"M-x\".")

(defvar bmp/command-usage-since nil
  "When counting started.")

(defvar bmp/command-usage-ignored
  '(self-insert-command org-self-insert-command
    execute-extended-command ignore handle-switch-frame
    mouse-set-point mouse-drag-region)
  "Commands not counted (typing, M-x itself and mouse housekeeping).")

(defvar bmp/command-usage-extra-emacs-keys
  '(("<delete>" "C-d" delete-forward-char)
    ("C-<backspace>" "M-DEL" backward-kill-word)
    ("<wheel-down>" "C-v" scroll-up-command)
    ("<wheel-up>" "M-v" scroll-down-command))
  "Keys with an Emacs equivalent that guru-mode does not cover.")

(defun bmp/command-usage--emacs-keys ()
  "Return (KEY EMACS-KEY COMMAND) for every key with an Emacs equivalent.
Taken from guru-mode's own list, plus `bmp/command-usage-extra-emacs-keys';
KEY is normalised the way `key-description' writes it."
  (mapcar (lambda (cell)
            (list (key-description (kbd (car cell))) (cadr cell) (caddr cell)))
          (append (bound-and-true-p guru-affected-bindings-list)
                  bmp/command-usage-extra-emacs-keys)))

(defvar bmp/command-usage--via-mx nil
  "Command being run from M-x, already counted.")

(defun bmp/command-usage--count (command how)
  (when (and command (symbolp command)
             (not (memq command bmp/command-usage-ignored)))
    (cl-incf (gethash (cons command how) bmp/command-usage 0))))

(defun bmp/command-usage--record ()
  "Count the command just run, with the keys used (post-command)."
  (if (eq this-command bmp/command-usage--via-mx)
      (setq bmp/command-usage--via-mx nil)
    (let* ((keys (key-description (this-single-command-keys)))
           (how (if (string-empty-p keys) "(called by another command)" keys))
           (command
            (if (symbolp this-command)
                this-command
              ;; guru-mode runs keys like <down> through an anonymous
              ;; wrapper; count them as the command they stand for
              (or (nth 2 (assoc keys (bmp/command-usage--emacs-keys)))
                  '<anonymous>))))
      (bmp/command-usage--count command how))))

(defun bmp/command-usage--record-mx (_prefix &optional command-name &rest _)
  "Count the command named COMMAND-NAME, run from M-x."
  (let ((command (and command-name (intern-soft command-name))))
    (when command
      (setq bmp/command-usage--via-mx command)
      (bmp/command-usage--count command "M-x"))))

(defun bmp/command-usage-save ()
  "Write the counts to `bmp/command-usage-file'."
  (when (> (hash-table-count bmp/command-usage) 0)
    (let (alist)
      (maphash (lambda (k v) (push (cons k v) alist)) bmp/command-usage)
      (with-temp-file bmp/command-usage-file
        (let ((print-length nil) (print-level nil))
          (prin1 (list :since bmp/command-usage-since :counts alist)
                 (current-buffer)))))))

(defun bmp/command-usage-load ()
  "Read saved counts, if any."
  (setq bmp/command-usage-since (format-time-string "%F"))
  (when (file-exists-p bmp/command-usage-file)
    (let ((data (with-temp-buffer
                  (insert-file-contents bmp/command-usage-file)
                  (ignore-errors (read (current-buffer))))))
      (when data
        (setq bmp/command-usage-since (plist-get data :since))
        (dolist (entry (plist-get data :counts))
          (puthash (car entry) (cdr entry) bmp/command-usage))))))

(defun bmp/command-usage-reset ()
  "Forget all counts and start again from today."
  (interactive)
  (when (yes-or-no-p "Forget all command counts? ")
    (clrhash bmp/command-usage)
    (setq bmp/command-usage-since (format-time-string "%F"))
    (when (file-exists-p bmp/command-usage-file)
      (delete-file bmp/command-usage-file))
    (message "Command counts reset")))

(bmp/command-usage-load)
(add-hook 'post-command-hook #'bmp/command-usage--record)
(advice-add 'execute-extended-command :before #'bmp/command-usage--record-mx)
(run-with-idle-timer 600 t #'bmp/command-usage-save)
(add-hook 'kill-emacs-hook #'bmp/command-usage-save)

;; --- The report
(defun bmp/command-usage--package (command)
  "Return the package COMMAND comes from, or \"emacs\" for built-ins."
  (let* ((def (and (fboundp command) (symbol-function command)))
         ;; Not loaded yet: Elpaca keeps all autoloads in one cache file,
         ;; so use the library the autoload points to
         (file (if (autoloadp def)
                   (locate-library (nth 1 def))
                 (ignore-errors (symbol-file command 'defun)))))
    (cond ((subrp (indirect-function command)) "emacs") ; written in C
          ((null file) "?")
          ((string-match "/elpaca/builds/\\([^/]+\\)/" file) (match-string 1 file))
          ((string-match "/elpaca/sources/\\([^/]+\\)/" file) (match-string 1 file))
          (t "emacs"))))

(defun bmp/command-usage--emacs-q-bindings (keys)
  "Ask a clean Emacs (emacs -Q) what KEYS run, as an alist."
  (let ((form (format "(prin1 (mapcar (lambda (k) (cons k (let ((b (keymap-lookup global-map k))) (cond ((null b) nil) ((symbolp b) b) ((keymapp b) 'prefix) (t 'other))))) '%S))"
                      keys)))
    (ignore-errors
      (car (read-from-string
            (with-output-to-string
              (call-process (expand-file-name invocation-name invocation-directory)
                            nil standard-output nil "-Q" "--batch" "--eval" form)))))))

(defun bmp/command-usage--total (pred)
  (let ((table (make-hash-table :test 'equal)))
    (maphash (lambda (k v) (when (funcall pred k) (cl-incf (gethash k table 0) v)))
             bmp/command-usage)
    table))

(defun bmp/command-usage--sorted (table)
  (let (rows)
    (maphash (lambda (k v) (push (cons k v) rows)) table)
    (sort rows (lambda (a b) (> (cdr a) (cdr b))))))

(defun bmp/command-usage--mode-active-p (pkg)
  "Non-nil if PKG has a minor mode switched on (so it works without commands)."
  (seq-some (lambda (name)
              (let ((sym (intern-soft name)))
                (and sym (boundp sym) (default-value sym))))
            (list (concat pkg "-mode") (concat "global-" pkg "-mode")
                  (concat pkg "-global-mode"))))

(defvar-keymap bmp/command-usage-report-mode-map
  :doc "Keys in the command usage report."
  "n" #'org-next-visible-heading
  "p" #'org-previous-visible-heading
  "TAB" #'org-cycle
  "<backtab>" #'org-shifttab
  "g" #'bmp/command-usage-report
  "q" #'quit-window)

(define-minor-mode bmp/command-usage-report-mode
  "Read-only navigation for the command usage report.
\\{bmp/command-usage-report-mode-map}"
  :lighter " Usage")

(defun bmp/command-usage--section (title explanation header)
  "Insert a section TITLE with its EXPLANATION and a table HEADER."
  (unless (bolp) (insert "\n"))
  (insert "\n* " title "\n" explanation "\n\n| "
          (string-join header " | ") " |\n|-\n"))

(defun bmp/command-usage-report ()
  "Show which commands and keys I use, and packages I do not."
  (interactive)
  (bmp/command-usage-save)
  (let* ((total 0)
         (emacs-keys (bmp/command-usage--emacs-keys))
         (by-command (make-hash-table :test 'eq))
         (by-package (make-hash-table :test 'equal))
         (mx (make-hash-table :test 'eq))
         (keys (make-hash-table :test 'equal))
         (indirect "(called by another command)"))
    (maphash (lambda (k v)
               (cl-incf total v)
               (cl-incf (gethash (car k) by-command 0) v)
               (cl-incf (gethash (bmp/command-usage--package (car k)) by-package 0) v)
               (cond ((equal (cdr k) "M-x") (cl-incf (gethash (car k) mx 0) v))
                     ((equal (cdr k) indirect))
                     (t (cl-incf (gethash (cdr k) keys 0) v))))
             bmp/command-usage)
    (with-current-buffer (get-buffer-create "*Command usage*")
      (let ((inhibit-read-only t))
        (erase-buffer)
        (org-mode)
        (insert (format "#+title: Command usage since %s\n\n%d commands counted. n / p: next / previous section, TAB: fold, g: refresh, q: quit.\n\n"
                        bmp/command-usage-since total))
        ;; 1
        (bmp/command-usage--section
         "Commands run with M-x that have a key"
         "Commands I typed with M-x although they have a key; the key is quicker."
         '("Command" "Times via M-x" "Its key"))
        (dolist (row (bmp/command-usage--sorted mx))
          (let ((key (where-is-internal (car row) nil t)))
            (when key
              (insert (format "| %s | %d | %s |\n" (car row) (cdr row)
                              (key-description key))))))
        ;; 2
        (bmp/command-usage--section
         "Keys with a standard Emacs equivalent"
         "Arrow, Home/End, Page Up/Down, Delete and similar keys I pressed, with the Emacs key for the same command (guru-mode's list, plus Ctrl-Backspace, Delete and the mouse wheel)."
         '("I pressed" "Times" "Emacs key" "Command"))
        (dolist (row (bmp/command-usage--sorted keys))
          (let ((entry (assoc (car row) emacs-keys)))
            (when entry
              (insert (format "| %s | %d | %s | %s |\n" (car row) (cdr row)
                              (nth 1 entry) (nth 2 entry))))))
        ;; 3
        (bmp/command-usage--section
         "My keys that replace a stock Emacs binding"
         "Global keys I used whose command differs from a clean Emacs (emacs -Q), so habits here will not carry over to a stock Emacs. Keys in mode-specific maps (Org, Magit...) are not included."
         '("Key" "My command" "Times" "emacs -Q has"))
        (let* ((global (seq-filter
                        (lambda (row)
                          (and (not (assoc (car row) emacs-keys))
                               (let ((cmd (ignore-errors (keymap-lookup global-map (car row)))))
                                 (and cmd (symbolp cmd)))))
                        (bmp/command-usage--sorted keys)))
               (defaults (bmp/command-usage--emacs-q-bindings (mapcar #'car global))))
          (dolist (row global)
            (let ((mine (keymap-lookup global-map (car row)))
                  (stock (cdr (assoc (car row) defaults))))
              (when (and stock (not (eq stock mine)))
                (insert (format "| %s | %s | %d | %s |\n"
                                (car row) mine (cdr row) stock))))))
        ;; 4
        (bmp/command-usage--section
         "Most used commands"
         "Everything counted, however it was run (keys, M-x, or called by other commands)."
         '("Command" "Times" "Package"))
        (dolist (row (seq-take (bmp/command-usage--sorted by-command) 40))
          (insert (format "| %s | %d | %s |\n" (car row) (cdr row)
                          (bmp/command-usage--package (car row)))))
        ;; 5
        (bmp/command-usage--section
         "Installed packages none of whose commands I used"
         "Packages installed on their own (not as a dependency) with no command counted. Those working through a mode are in use without commands; umbrella packages (such as casual-suite, whose commands come from casual) always appear here."
         '("Package" "Note"))
        (when (fboundp 'elpaca--queued)
          (dolist (pair (sort (copy-sequence (elpaca--queued))
                              (lambda (a b) (string< (car a) (car b)))))
            (let ((pkg (symbol-name (car pair))))
              (unless (or (elpaca<-dependents (cdr pair))
                          (gethash pkg by-package)
                          (member pkg '("elpaca" "elpaca-use-package")))
                (insert (format "| %s | %s |\n" pkg
                                (cond ((bmp/command-usage--mode-active-p pkg)
                                       "works through a mode, so keep")
                                      ((featurep (car pair)) "loaded, but no commands used")
                                      (t "not loaded since start-up"))))))))
        (org-table-map-tables #'org-table-align t)
        (goto-char (point-min))
        (org-fold-show-all))
      (read-only-mode 1)
      (bmp/command-usage-report-mode 1)
      (pop-to-buffer (current-buffer)))))

4.2. Look and feel

Some basic customization with the look and feel for Emacs.

4.2.1. Theme

I have now moved to using ef-themes from Prot as they seem to have been tested well and fit well with KDE dark theme, and I also disable other themes to avoid any clash of colours. In the daemon there is no graphical frame at start-up, so the theme is loaded when the first client frame is created. It used to be reloaded for every new frame, which re-applied every face each time I ran emacsclient -c.

(use-package ef-themes
  :ensure t
  :config
  (setq ef-themes-mixed-fonts t
        ef-themes-variable-pitch-ui t)
  (mapc #'disable-theme custom-enabled-themes)

  (defun bmp/load-theme-once ()
    "Load the theme on the first client frame, then stop."
    (load-theme 'ef-owl :no-confirm)
    (remove-hook 'server-after-make-frame-hook #'bmp/load-theme-once))

  (if (daemonp)
      (add-hook 'server-after-make-frame-hook #'bmp/load-theme-once)
    (load-theme 'ef-owl :no-confirm)))
  1. Make sure background is setup currently

    Make sure unspecified bg is not displayed

    
    (defun bmp/fix-unspecified-bg (frame)
      "Ensure frames have a concrete background color to avoid GTK warnings."
      (with-selected-frame frame
        (unless (face-background 'default frame)
          (set-face-background 'default "#292c2f" frame))))
    
    (add-hook 'after-make-frame-functions #'bmp/fix-unspecified-bg)
    
    ;; Also apply to existing frames
    (dolist (frame (frame-list))
      (bmp/fix-unspecified-bg frame))
    
    

4.2.2. Display background colour for strings with the colour value

rainbow-mode is a minor mode for Emacs which displays strings representing colours with the colour they represent as background.

(use-package rainbow-mode
  :ensure t
  :config
  (add-hook 'prog-mode-hook 'rainbow-mode))

4.2.3. Padding between elements

This adds some space between various elements in Emacs: https://protesilaos.com/codelog/2023-06-03-emacs-spacious-padding/

(setq spacious-padding-widths
      '( :internal-border-width 10
         :header-line-width 4
         :mode-line-width 0
         :tab-width 4
         :right-divider-width 10
         :scroll-bar-width 2))

(use-package spacious-padding
  :ensure t
  :hook (after-init . spacious-padding-mode))

4.2.4. Modeline

The default modeline in Emacs is cluttered and does not suit me, I am using a number of modifications to improve the functionality of the modeline.

  1. Diminish

    Removes clutter from the modeline with https://www.emacswiki.org/emacs/DiminishedModes

    (use-package diminish
      :demand t
      :ensure t)
    
    ;; Diminish modes not loaded at Emacs startup from https://www.emacswiki.org/emacs/DiminishedModes
    (eval-after-load "filladapt" '(diminish 'filladapt-mode))
    
    ;; To fix :eval from the same page
    (defun diminished-modes ()
      "Echo all active diminished or minor modes as if they were minor.
    The display goes in the echo area; if it's too long even for that,
    you can see the whole thing in the *Messages* buffer.
    This doesn't change the status of any modes; it just lets you see
    what diminished modes would be on the mode-line if they were still minor."
      (interactive)
      (let ((minor-modes minor-mode-alist)
        message)
        (while minor-modes
      (when (symbol-value (caar minor-modes))
        ;; This minor mode is active in this buffer
        (let* ((mode-pair (car minor-modes))
         (mode (car mode-pair))
         (minor-pair (or (assq mode diminished-mode-alist) mode-pair))
         (minor-name (cadr minor-pair)))
          (when (symbolp minor-name)
            ;; This minor mode uses symbol indirection in the cdr
            (let ((symbols-seen (list minor-name)))
        (while (and (symbolp (callf symbol-value minor-name))
                (not (memq minor-name symbols-seen)))
          (push minor-name symbols-seen))))
          (push minor-name message)))
      (callf cdr minor-modes))
        ;; Handle :eval forms
        (setq message (mapconcat
             (lambda (form)
               (if (and (listp form) (eq (car form) :eval))
               (apply 'eval (cdr form))
             form))
             (nreverse message) ""))
        (when (= (string-to-char message) ?\ )
      (callf substring message 1))
        (message "%s" message)))
    
    
  2. Doom modeline

    I have tried a few options such as telephone-line, spaceline, simple-modeline and then some home-brew possibilities, I settled on doom-modeline as it works out of the box for all my needs. Install and use Doom Modeline from https://github.com/seagle0128/doom-modeline/, this text below is from the sample configuration provided in the repository.

    
    (setq doom-modeline-support-imenu t)
    
    (setq doom-modeline-height 28)
    
    (setq doom-modeline-bar-width 0)
    
    (setq nerd-icons-scale-factor 1)
    
    (setq doom-modeline-hud t)
    
    (setq doom-modeline-window-width-limit 85)
    
    (setq doom-modeline-project-detection 'auto)
    
    (setq doom-modeline-buffer-file-name-style 'auto)
    
    (setq doom-modeline-icon t)
    
    (setq doom-modeline-major-mode-icon t)
    
    (setq doom-modeline-major-mode-color-icon t)
    
    (setq doom-modeline-buffer-state-icon t)
    
    (setq doom-modeline-buffer-modification-icon t)
    
    (setq doom-modeline-lsp-icon t)
    
    (setq doom-modeline-time-icon t)
    
    (setq doom-modeline-time-live-icon t)
    
    (setq doom-modeline-unicode-fallback t)
    
    (setq doom-modeline-buffer-name t)
    
    (setq doom-modeline-highlight-modified-buffer-name t)
    
    (setq doom-modeline-column-zero-based t)
    
    (setq doom-modeline-percent-position '(-3 "%p"))
    
    (setq doom-modeline-position-line-format '("L%l"))
    
    (setq doom-modeline-position-column-format '("C%c"))
    
    (setq doom-modeline-position-column-line-format '("%l:%c"))
    
    (setq doom-modeline-minor-modes nil)
    
    (setq doom-modeline-enable-word-count t)
    
    ;; Counting every word of a large note on each redisplay made Org slow
    ;; (21% of the time in a profile); the count is shown for a selection only.
    (setq doom-modeline-continuous-word-count-modes nil)
    
    (setq doom-modeline-buffer-encoding t)
    
    (setq doom-modeline-indent-info nil)
    
    (setq doom-modeline-total-line-number t)
    
    (setq doom-modeline-check-simple-format t)
    
    (setq doom-modeline-number-limit 99)
    
    (setq doom-modeline-vcs-max-length 12)
    
    (setq doom-modeline-workspace-name t)
    
    (setq doom-modeline-persp-name t)
    
    (setq doom-modeline-display-default-persp-name nil)
    
    (setq doom-modeline-persp-icon t)
    
    (setq doom-modeline-lsp t)
    
    (setq doom-modeline-github nil)
    
    (setq doom-modeline-github-interval (* 30 60))
    
    (setq doom-modeline-modal t)
    
    (setq doom-modeline-modal-icon t)
    
    (setq doom-modeline-modal-modern-icon t)
    
    (setq doom-modeline-always-show-macro-register nil)
    
    (setq doom-modeline-gnus nil)
    
    (setq doom-modeline-gnus-timer 0)
    
    (setq doom-modeline-gnus-excluded-groups '("dummy.group"))
    
    (setq doom-modeline-irc t)
    
    (setq doom-modeline-irc-stylize 'identity)
    
    (setq doom-modeline-battery nil)
    
    (setq doom-modeline-time nil)
    
    (setq doom-modeline-display-misc-in-all-mode-lines t)
    
    (setq doom-modeline-buffer-file-name-function #'identity)
    
    (setq doom-modeline-buffer-file-truename-function #'identity)
    
    (setq doom-modeline-env-version t)
    (setq doom-modeline-env-enable-python t)
    (setq doom-modeline-env-enable-ruby t)
    (setq doom-modeline-env-enable-perl t)
    (setq doom-modeline-env-enable-go t)
    (setq doom-modeline-env-enable-elixir t)
    (setq doom-modeline-env-enable-rust t)
    
    (setq doom-modeline-env-python-executable "python")
    (setq doom-modeline-env-ruby-executable "ruby")
    (setq doom-modeline-env-perl-executable "perl")
    (setq doom-modeline-env-go-executable "go")
    (setq doom-modeline-env-elixir-executable "iex")
    (setq doom-modeline-env-rust-executable "rustc")
    
    (setq doom-modeline-env-load-string "...")
    
    (setq doom-modeline-always-visible-segments '(mastodon irc))
    
    (if (facep 'mode-line-active)
        (set-face-attribute 'mode-line-active nil :family "FiraCode Nerd Font" :height 100)
      (set-face-attribute 'mode-line nil :family "FiraCode Nerd Font" :height 100))
    (set-face-attribute 'mode-line-inactive nil :family "FiraCode Nerd Font" :height 100)
    
    (setq doom-modeline-before-update-env-hook nil)
    (setq doom-modeline-after-update-env-hook nil)
    
    (use-package doom-modeline
      :ensure t
      :init (doom-modeline-mode 1))
    
    

4.2.5. Frame setup

Moving this section from early-init.el to here to help with startup configuration. The menu, tool and scroll bars are global modes already turned off in early-init.el, so they are not toggled again for every frame.


(defun bmp/apply-frame-settings (frame)
  "Apply UI settings to newly created client frames.
Popup capture frames (see [[*Provide Emacs externally]]) are not maximized."
  (modify-frame-parameters
   frame
   `(,@(unless (frame-parameter frame 'prot-window-popup-frame)
         '((fullscreen . maximized)))
     (vertical-scroll-bars . nil)
     (internal-border-width . 2)
     (background-color . "#292c2f"))))

(add-hook 'after-make-frame-functions #'bmp/apply-frame-settings)

;; Apply immediately if not running as a daemon
(when (not (daemonp))
  (bmp/apply-frame-settings (selected-frame)))

  1. No title bar (KDE window rule)

    Under KDE Plasma on Wayland the window manager (KWin) draws the title bar, and Emacs cannot remove it: the undecorated frame parameter (on existing frames, or with emacsclient -c -F '((undecorated . t))') has no effect there, and undecorated-round, which I used to set, only exists on macOS. Without a title bar, the window is moved with Meta+left-drag and resized with Meta+right-drag. Choosing No titlebar from the title bar's own menu only affects that one window; a window rule applies to every Emacs frame. Checked with Plasma 6.7.5.

    In System Settings → Window Management → Window Rules → Add New…:

    • Description: Emacs: no title bar
    • Window class (application): Substring Match, emacs, with Match whole window class unticked (Emacs reports the class emacs; matching the whole class, "name class", does not match)
    • Add Property… → No titlebar and frame: Force, ticked (Apply Initially does not stick)

    The same rule from a terminal (when it is the only rule; otherwise use System Settings, which keeps the list of rules up to date). Both ways write the same entries to ~/.config/kwinrulesrc:

    id=$(uuidgen)
    kwriteconfig6 --file kwinrulesrc --group "$id" --key Description "Emacs: no title bar"
    kwriteconfig6 --file kwinrulesrc --group "$id" --key wmclass "emacs"
    kwriteconfig6 --file kwinrulesrc --group "$id" --key wmclassmatch 2       # substring
    kwriteconfig6 --file kwinrulesrc --group "$id" --key noborder true
    kwriteconfig6 --file kwinrulesrc --group "$id" --key noborderrule 2       # force
    kwriteconfig6 --file kwinrulesrc --group General --key count 1
    kwriteconfig6 --file kwinrulesrc --group General --key rules "$id"
    gdbus call --session --dest org.kde.KWin --object-path /KWin --method org.kde.KWin.reconfigure
    

4.2.6. Fonts

Use custom fonts based on my needs.

(custom-set-faces
 `(variable-pitch ((t (:family "EB Garamond" :height 120))))
 `(fixed-pitch ((t (:family "FiraCode Nerd Font Mono" :height 100)))))

(set-frame-font "FiraCode Nerd Font Mono")

(set-face-attribute 'default nil :font "FiraCode Nerd Font Mono" :height 100)

Additional font configurations,

  • Use Noto Color Emoji for emoji, which Emacs 29+ draws in colour without any package, and Symbola for other symbols. I used to use emojify for this; it is no longer needed.
  • Use a single icon set, nerd-icons (from the FiraCode Nerd Font already installed): nerd-icons-ibuffer here, nerd-icons-corfu for completion pop-ups, nerd-icons-completion in 4.8.10 and nerd-icons-dired in 4.2.11. This replaces all-the-icons and its extensions, font-lock+ (only needed by old versions of all-the-icons), and svg-lib with kind-icon. There is no separate font to install with M-x all-the-icons-install any more.
(setf use-default-font-for-symbols nil)
;; Colour emoji are drawn natively (Emacs 29+); Symbola covers other symbols
(set-fontset-font t 'emoji "Noto Color Emoji" nil 'prepend)
(set-fontset-font t 'symbol "Noto Color Emoji" nil 'append)
(set-fontset-font t 'symbol "Symbola" nil 'append)

(use-package nerd-icons
  :ensure t)

(use-package nerd-icons-ibuffer
  :ensure t
  :hook (ibuffer-mode . nerd-icons-ibuffer-mode))

(use-package nerd-icons-corfu
  :ensure t
  :after corfu
  :config
  (add-to-list 'corfu-margin-formatters #'nerd-icons-corfu-formatter))

4.2.7. Tabs

I use Emacs's built-in tab-bar-mode: each tab keeps its own window layout, for one task each (notes, configuration, a project…). The keys are Emacs's own, on C-x t: C-x t 2 new tab, C-x t 0 close, C-x t o / C-x t O next / previous, C-x t RET switch by name, C-x t r rename, C-x t u reopen the last closed tab, C-x t f / C-x t b / C-x t d open a file / buffer / directory in a new tab, C-x t p p open a project in its own tab. C-<tab> also moves to the next tab, except in Org buffers, where Org uses it. Tabs are not numbered: numbers are only there to jump with a modifier and a digit, and Super belongs to KDE while M-1… are Emacs's prefix-argument keys.

The look:

  • no close (×) or new (+) buttons;
  • tabs are all the same width, flat and padded, in the theme's colours: the active tab bold with the theme's accent line on top, inactive tabs dimmed, the bar itself in the dimmer background (set again whenever a theme loads);
  • each tab shows the icon of its buffer's mode and a short name, and the right end of the bar shows global-mode-string (the time, when display-time-mode is on).

The name follows one rule per kind of buffer, so it works for any mode, including ones I install later:

  1. an Org file with a #+title: the title (org-roam notes would otherwise show their timestamped file name);
  2. any other file: the file name (indirect buffers use their base file);
  3. a directory (Dired and modes built on it, recognised by dired-directory): the directory name and /;
  4. anything else: the buffer name without surrounding * and spaces, and without the ./ buffer-name-relative adds.

The name is cut to bmp/tab-bar-name-width characters with …; that shortened name is the tab's name, so C-x t RET lists the same names. The buffer is kept on the name, so each tab finds its buffer (and icon) even when it is not the current one.

The icon is found from the mode, not from a list: the mode's own icon if nerd-icons has one, otherwise that of its nearest parent mode that has one (the dashboard, Bufler and the org-roam buffer all inherit from special-mode), otherwise a generic one by category (folder, code, terminal, document, list or window). Inactive tabs draw the icon in the tab's dimmed colour; the active tab keeps the icon's colour. The icon's face, which names the icon font, is kept: replacing it made newer icons (Mastodon's) fall back to FiraCode, which does not have them.

activities.el keeps tab layouts across restarts. An activity is a named tab with its windows and the buffers in them; each has a default state (as I defined it) and a last state (as I left it), saved when I suspend it, when Emacs is idle and when it exits (in data/persist/activities-activities). Activity tabs show a briefcase and the activity's name; tabs I rename with C-x t r show their buffer's icon and my name.

Keys Does
C-x C-a C-d define the current tab's layout as a named activity
C-x C-a C-n start a new, empty activity
C-x C-a C-a resume an activity in its own tab (C-u: from its default state)
C-x C-a C-s save how it is and close its tab
C-x C-a C-k close it without saving (next time it starts from the default)
C-x C-a RET switch to an open activity
C-x C-a b switch among the activity's buffers
C-x C-a g go back to the default state
C-x C-a l list activities, to rename or discard them

Buffers come back through bookmarks, so they return only if their mode supports bookmarks: Org notes (found by their ID through org-bookmark-heading, so they are found even after moving), other files, Dired, Magit, Help, Info and eshell do; the dashboard, *Messages*, the org-roam buffer and live sessions (ERC, Mastodon, agent-shell) do not.

(setq tab-bar-close-button-show nil
      tab-bar-new-button-show nil
      tab-bar-tab-hints nil
      tab-bar-auto-width nil
      tab-bar-separator " "
      tab-bar-format '(tab-bar-format-tabs tab-bar-separator
                       tab-bar-format-align-right tab-bar-format-global))

(defvar bmp/tab-bar-name-width 18
  "Every tab name is shown in exactly this many characters.")

(defun bmp/tab-bar--tab-buffer (tab)
  "Return the buffer shown in TAB's selected window.
The tab's name carries it (see `bmp/tab-bar-tab-name')."
  (if (eq (car tab) 'current-tab)
      (window-buffer (or (minibuffer-selected-window) (selected-window)))
    (let ((name (alist-get 'name tab)))
      (or (and (stringp name) (get-text-property 0 'bmp-tab-buffer name))
          (and (stringp name) (get-buffer name))))))

(defun bmp/tab-bar--raw-name (tab buffer)
  "Name for TAB showing BUFFER, before shortening (see the rules above)."
  (if (not (buffer-live-p buffer))
      (or (and tab (alist-get 'name tab)) "")
    (with-current-buffer buffer
      (let ((file (buffer-file-name (or (buffer-base-buffer) buffer))))
        (cond
         ((and file (derived-mode-p 'org-mode) (org-get-title)))
         (file (file-name-nondirectory file))
         ((bound-and-true-p dired-directory)
          (file-name-as-directory
           (file-name-nondirectory (directory-file-name default-directory))))
         (t (string-remove-prefix
             "./" (string-trim (buffer-name) "[ *]+" "[ *]+"))))))))

(defun bmp/tab-bar--display-name (tab buffer)
  "TAB's name, cut to `bmp/tab-bar-name-width' with … and padded."
  (truncate-string-to-width (bmp/tab-bar--raw-name tab buffer)
                            bmp/tab-bar-name-width nil ?\s "…"))

(defun bmp/tab-bar-tab-name ()
  "Name a tab after the buffer in its selected window.
This is also the name `C-x t RET' lists; the buffer is kept on it."
  (let ((buffer (window-buffer (or (minibuffer-selected-window) (selected-window)))))
    (propertize (string-trim-right (bmp/tab-bar--display-name nil buffer))
                'bmp-tab-buffer buffer)))

(defun bmp/tab-bar--mode-with-icon (mode)
  "Return MODE, or its nearest parent mode, that nerd-icons has an icon for."
  (cl-loop for m = mode then (get m 'derived-mode-parent)
           while m
           when (assq m nerd-icons-mode-icon-alist) return m))

(defun bmp/tab-bar--icon (buffer)
  "Icon for BUFFER, found from its mode rather than a fixed list."
  (require 'nerd-icons)
  (if (not (buffer-live-p buffer))
      (nerd-icons-codicon "nf-cod-window")
    (with-current-buffer buffer
      (let ((known (bmp/tab-bar--mode-with-icon major-mode)))
        (cond
         ((or buffer-file-name (eq known major-mode))
          (nerd-icons-icon-for-buffer))
         (known (let ((spec (cdr (assq known nerd-icons-mode-icon-alist))))
                  (apply (car spec) (cdr spec))))
         ((bound-and-true-p dired-directory) (nerd-icons-codicon "nf-cod-folder"))
         ((derived-mode-p 'prog-mode) (nerd-icons-codicon "nf-cod-code"))
         ((derived-mode-p 'comint-mode 'term-mode 'eshell-mode)
          (nerd-icons-octicon "nf-oct-terminal"))
         ((derived-mode-p 'text-mode) (nerd-icons-codicon "nf-cod-file"))
         ((derived-mode-p 'special-mode) (nerd-icons-codicon "nf-cod-list_flat"))
         (t (nerd-icons-codicon "nf-cod-window")))))))

(defun bmp/tab-bar-tab-name-format (tab _i)
  "Show TAB as its icon and short name.
Activity tabs (see below) show a briefcase and the activity's name; tabs
I renamed (`C-x t r') show their buffer's icon and the name I gave."
  (let* ((current (eq (car tab) 'current-tab))
         (activity (alist-get 'activity (cdr tab)))
         (explicit (alist-get 'explicit-name tab))
         (buffer (bmp/tab-bar--tab-buffer tab))
         (icon (copy-sequence
                (if activity
                    (nerd-icons-codicon "nf-cod-briefcase")
                  (bmp/tab-bar--icon buffer))))
         (name (if (or activity explicit)
                   (truncate-string-to-width (alist-get 'name tab)
                                             bmp/tab-bar-name-width nil ?\s "…")
                 (bmp/tab-bar--display-name tab buffer)))
         (face (funcall tab-bar-tab-face-function tab)))
    (if current
        (add-face-text-property 0 (length icon) face 'append icon)
      (put-text-property 0 (length icon) 'face
                         (list :family nerd-icons-font-family :inherit face)
                         icon))
    (concat (propertize " " 'face face)
            icon
            (propertize (format " %s " name) 'face face))))

(defun bmp/tab-bar-faces (&rest _)
  "Flat, padded tabs in the current ef theme's colours."
  (when (seq-some (lambda (theme) (string-prefix-p "ef-" (symbol-name theme)))
                  custom-enabled-themes)
    (ef-themes-with-colors
      (set-face-attribute 'tab-bar nil :background bg-dim :box nil)
      (set-face-attribute 'tab-bar-tab nil :background bg-main :foreground fg-main
                          :weight 'bold :box `(:line-width (2 . 6) :color ,bg-main)
                          :underline `(:color ,accent-0 :position 0))
      (set-face-attribute 'tab-bar-tab-inactive nil :background bg-dim :foreground fg-dim
                          :weight 'normal :box `(:line-width (2 . 6) :color ,bg-dim)
                          :underline nil))))

(setq tab-bar-tab-name-function #'bmp/tab-bar-tab-name
      tab-bar-tab-name-format-function #'bmp/tab-bar-tab-name-format)
(add-hook 'enable-theme-functions #'bmp/tab-bar-faces)
(bmp/tab-bar-faces)
(tab-bar-mode 1)

(use-package activities
  :ensure t
  :custom
  ;; Activity tabs show a briefcase icon instead of the "α: " prefix
  (activities-name-prefix "")
  ;; Keep activities out of my bookmarks; the dashboard lists them instead
  (activities-bookmark-store nil)
  :init
  (activities-mode 1)
  (activities-tabs-mode 1)
  :bind (("C-x C-a C-n" . activities-new)
         ("C-x C-a C-d" . activities-define)
         ("C-x C-a C-a" . activities-resume)
         ("C-x C-a C-s" . activities-suspend)
         ("C-x C-a C-k" . activities-kill)
         ("C-x C-a RET" . activities-switch)
         ("C-x C-a b" . activities-switch-buffer)
         ("C-x C-a g" . activities-revert)
         ("C-x C-a l" . activities-list)))

;; An "Activities" section on the dashboard; selecting one resumes it
(defun bmp/dashboard-insert-activities (list-size)
  "Insert up to LIST-SIZE activities into the dashboard."
  (require 'activities)
  (dashboard-insert-section
   "Activities:"
   (dashboard-subseq (sort (mapcar #'car activities-activities) #'string<) list-size)
   list-size
   'activities
   (dashboard-get-shortcut 'activities)
   `(lambda (&rest _) (activities-resume (activities-named ,el)))
   el))

(with-eval-after-load 'dashboard
  (add-to-list 'dashboard-item-generators
               '(activities . bmp/dashboard-insert-activities))
  ;; Lower case: dashboard binds it to jump forward and its upper case back
  (add-to-list 'dashboard-item-shortcuts '(activities . "a"))
  (add-to-list 'dashboard-heading-icons
               '(activities . (nerd-icons-codicon "nf-cod-briefcase"))))

;; Emacs 30's vtable cannot redraw a table with no rows, so discarding
;; the last activity in C-x C-a l raised an error; close the list instead
(defun bmp/activities-list-close-when-empty (orig &rest args)
  "Call ORIG with ARGS, unless this is the activities list and it is empty."
  (if (and (equal (buffer-name) "*Activities*")
           (null activities-activities))
      (quit-window 'kill)
    (apply orig args)))

(advice-add 'vtable-revert-command :around #'bmp/activities-list-close-when-empty)

4.2.8. Distinguish between types of buffers

I used solaire-mode to give non-file buffers (side panels, help, the minibuffer) a slightly different background from file buffers. I no longer load it (kept for reference with :tangle no): the ef-themes deliberately use one background everywhere and tell buffers apart through the mode line and fringe, they do not support solaire, and in practice it did not help me much.

(use-package solaire-mode
  :ensure t
  :config
  (add-to-list 'solaire-mode-themes-to-face-swap "^ef-")
  (solaire-global-mode 1))

4.2.9. Create transparency

I'd been using transparency largely as a vanity feature until I realised it could be used as a way to take notes in video calls. This makes it easier to take notes without having to minimize the video, or resize the windows. This snippet is from EmacsWiki.

(defun bmp/transparency (value)
  "Sets the transparency of the frame window. 0=transparent/100=opaque"
  (interactive "nTransparency Value(0=transparent|100=opaque): ")
  (set-frame-parameter nil 'alpha-background value)
  (setf (alist-get 'alpha-background default-frame-alist) value))

4.2.10. Dashboard

While almost everyone that have a public init file disables the default Emacs starting screen, there are a number of ways people customize the home screen. I use Emacs Dashboard as a replacement for my needs. I will have to add further details and this is not complete yet (I need to add the specific icons files to set it up).

I first came across fortune in Slackware in the early 2000's, and have been a fan of it ever since. I have customised the dashboard so that a fortune is displayed at the bottom.

  • The dashboard is customized to show only certain keywords in for the agenda, and the path for recent files is truncated in the middle to show only 50 characters.
  • Ensure that dashboard is opened when client is launched. dashboard-setup-startup-hook does nothing in the daemon (it skips set-up when Emacs is started with arguments such as --fg-daemon), and the hooks I had put under :custom were never added, as :custom only sets variables. Instead, initial-buffer-choice calls bmp/dashboard-buffer, which rebuilds the dashboard for every new client frame with a fresh fortune; before, the fortune was picked once and stayed the same for as long as the daemon ran.
  • The logos are from https://github.com/egstatsml/emacs_fancy_logos, I have used ImageMagick to convert the SVG image to a WEBP format with size 180x180 to make it compatible with transparency.
(use-package dashboard
  :ensure t
  :config
  (dashboard-setup-startup-hook)
  :custom
  (dashboard-startup-banner (concat user-emacs-directory "gnu_color.webp"))
  (dashboard-image-banner-max-height 180)
  (dashboard-banner-logo-title nil)
  (dashboard-center-content t)
  (dashboard-vertically-center-content t)
  (dashboard-icon-type 'nerd-icons)
  (dashboard-set-heading-icons t)
  (dashboard-set-file-icons t)
  (dashboard-footer-icon nil)
  (dashboard-projects-backend 'project-el)
  (dashboard-display-icons-p t)
  (dashboard-items '(
                     (recents . 5)
                     (projects . 5)
                     (bookmarks . 5)
                     (activities . 5)   ; see [[*Tabs]]
                     ))
  (dashboard-path-style 'truncate-middle)
  (dashboard-path-max-length 50)
  (dashboard-bookmarks-item-format "%s")
  (dashboard-force-refresh t))

(defun bmp/dashboard-buffer ()
  "Rebuild the dashboard with a fresh fortune and return its buffer."
  (require 'dashboard)
  (setq dashboard-footer-messages
        (list (let ((inhibit-message t)) (fortune-message))))
  (dashboard-insert-startupify-lists t)
  (get-buffer dashboard-buffer-name))

(with-eval-after-load 'dashboard
  (add-hook 'window-size-change-functions #'dashboard-resize-on-hook 100)
  (add-hook 'window-setup-hook #'dashboard-resize-on-hook))

(setq initial-buffer-choice #'bmp/dashboard-buffer)

4.2.11. Nerd Icons in dired

After trying both all-the-icons-dired, I have settled on nerd-icons-dired as this looks better. 4.4.6 builds its window by cloning a Dired buffer, and nerd-icons-dired tries to draw icons in the clone before it has a directory listing, which fails with "No subdir-alist". So that first drawing is skipped when there is no listing yet; icons are drawn as soon as the listing arrives.

(defun bmp/dired-has-listing-p (&rest _)
  "Return non-nil when the current Dired buffer has a directory listing."
  (bound-and-true-p dired-subdir-alist))

(use-package nerd-icons-dired
  :ensure t
  :hook
  (dired-mode . nerd-icons-dired-mode)
  :config
  ;; Icons are drawn again after every listing (`dired-after-readin-hook'),
  ;; so skip the first refresh in a buffer that has no listing yet.
  (advice-add 'nerd-icons-dired--refresh :before-while
              #'bmp/dired-has-listing-p))

4.2.12. Focus mode

I've been searching for a mode that only highlights the text I require as it can remove distractions while writing, reading or even when using presentations. This mode dims the regions and focuses on a configurable portion based on my needs

(use-package focus
  :ensure t)

4.3. Versioning

Having moved from subversion systems to git-based systems, the best interface for Emacs is Magit.

4.3.1. Magit

Since, moving to Elpaca, I have had to load transient to fix race condition for git-commit and Magit as per: https://github.com/progfolio/elpaca/issues/302. Magit is astoundingly intuitive for users like me who do not have a complicated git work-flow20. And based on Phil's configuration,

  • I had disabled Emacs' built-in VC package for git repositories as it slows down Magit. I have turned it back on: diff-hl gets its diffs through VC, and the built-in project.el commands (C-x p …) find Git projects through it, so both stopped working in Git repositories
  • Improve performance by disabling buffer autorefresh

Added a bit of Magit linting based on the post by Bob Wilson, and have added the convention from https://www.conventionalcommits, where I have,

  • Added commit message length
  • conventional commit message type
  • No punctuation at the end of the message
(defvar bmp-conventional-commit-types
  '("feat" "fix" "docs" "style" "refactor" "perf" "test" "build" "ci" "chore" "revert"))

(defcustom bmp-conventional-commit-checks '(type-format summary-nopunct summary-length)
  "List of Conventional Commits checks."
  :type '(set (const type-format) (const summary-nopunct) (const summary-length))
  :group 'git-commit)

(defun bmp-git-commit-check-conventional-commits (force)
  "Enforce Conventional Commits (full summary)."
  (save-excursion
    (goto-char (point-min))
    ;; Skip comments, find first real line
    (when (re-search-forward "^\\([^#]\\)" nil t)
      (beginning-of-line)
      (let* ((summary (string-trim (thing-at-point 'line t)))  ;; FULL LINE
             (type-match (string-match
                          "^\\([a-z]+\\)\\(!?\\)\\(\\([a-z0-9]+\\)?:\\)?\\s-+\\(.*\\)"
                          summary))
             (commit-type (and type-match (match-string 1 summary)))
             (breaking (and type-match (match-string 2 summary)))
             (description (and type-match (match-string 5 summary))))


        ;; Your 4 checks (unchanged)
        (and
         ;; Check 1: FORMAT
         (or (not (memq 'type-format bmp-conventional-commit-checks))
             type-match
             (y-or-n-p (format "Invalid format: '%s'\nTry: feat: %s\nCommit anyway? "
                               summary (string-capitalize summary))))

         ;; Check 2: TYPE
         (or (not (memq 'type-format bmp-conventional-commit-checks))
             (null commit-type)
             (member commit-type bmp-conventional-commit-types)
             (y-or-n-p (format "Unknown type '%s'%s. Valid: %s\nCommit anyway? "
                               commit-type
                               (if breaking " (breaking!)" "")
                               (mapconcat #'identity bmp-conventional-commit-types ", "))))

         ;; Check 3: LENGTH - NOW WORKS!
         (or (not (memq 'summary-length bmp-conventional-commit-checks))
             (<= (length summary) 50)
             (y-or-n-p "Summary too long (>50 chars). Continue? "))

         ;; Check 4: PUNCTUATION
         (or (not (memq 'summary-nopunct bmp-conventional-commit-checks))
             (null description)
             (not (string-match-p "[.!?;,:]$" description))
             (y-or-n-p "No trailing punctuation. Commit anyway? ")))))))



;; Magit configuration
(use-package magit
  :ensure t
  :bind ("C-x g" . magit-status)
  :init
  (setq magit-define-global-key-bindings nil)
  (setq magit-section-visibility-indicator '(" ▼"))
  :config
  (setq git-commit-summary-max-length 50)
  (setq git-commit-fill-column 72)
  (setq git-commit-style-convention-checks nil)
  (setq magit-refresh-status-buffer nil)
  (setq magit-diff-refine-hunk t)
  (add-hook 'after-save-hook 'magit-after-save-refresh-status t)
  (add-to-list 'git-commit-finish-query-functions
               #'bmp-git-commit-check-conventional-commits))



4.3.2. DONE Diff-hl

Moved to diff-hl after trying out git-gutter for a while, this seems to provide the accurate highlights in the fringe/margin based on my needs. The options I have are to either use (diff-hl-margin-mode) or to set-up a specific fringe to enable it to work well with (set-fringe-style '(2 . 2)) or (fringe-mode '(8 . 8)). In order to increase the spaces in the fringe after the margin is set with (diff-hl-margin-mode), I have decided to use (fringe-mode nil) to set-up a gap. I have also added Magit hooks so that it integrates well.

C-x v *, or clicking a change mark in the fringe, shows that hunk in a small popup (diff-hl-show-hunk-posframe), from which I can revert or stage it, or move to the next or previous hunk. The fringe shows only one mark per line, so it is shared out: the left fringe holds diff-hl's change marks (and org-tidy's property-drawer marks in Org files), and the right fringe holds flymake's error marks, treesit-fold's fold arrows and bm's bookmarks.

(use-package diff-hl
  :ensure t
  :config
  (global-diff-hl-mode)
  (diff-hl-flydiff-mode)
  (add-hook 'dired-mode-hook 'diff-hl-dired-mode)
  (add-hook 'magit-pre-refresh-hook 'diff-hl-magit-pre-refresh)
  (add-hook 'magit-post-refresh-hook 'diff-hl-magit-post-refresh)
  ;; C-x v * (or a click on a change mark in the fringe) shows the hunk
  ;; in a popup, with keys to revert, stage or move to the next hunk
  (setq diff-hl-show-hunk-function #'diff-hl-show-hunk-posframe)
  (global-diff-hl-show-hunk-mouse-mode 1))

4.4. Navigation

Moving around within Emacs (buffers, frames, windows, etc.)

4.4.1. Winner mode

Capture and restore window configuration

(use-package winner
  :ensure nil
  :defer 1
  :config
  (winner-mode))

4.4.2. Relative buffer names

Emacs makes it hard to switch between buffers when they have full names, using buffer-name-relative makes it easier to recognize the names.

(use-package buffer-name-relative
  :ensure t
  :config
  (buffer-name-relative-mode 1))

4.4.3. Buffer management

Better buffer management with https://github.com/alphapapa/bufler.el.

(use-package bufler
  :ensure t)

(use-package ibuffer
  :hook (ibuffer-mode . ibuffer-auto-mode)
  :defer t)

4.4.4. Quick navigation in the mini-buffer

(use-package consult-dir
  :ensure t
  :after vertico consult
  :bind (("C-x C-d" . consult-dir)
         :map vertico-map
         ("C-x C-d" . consult-dir)
         ("C-x C-j" . consult-dir-jump-file))
  :config
  (setq consult-dir-project-list-function #'consult-dir-project-dirs))

4.4.5. Dired

From Prot https://protesilaos.com/codelog/2023-06-26-emacs-file-dired-basics/.

(file-name-shadow-mode 1)
(setq delete-by-moving-to-trash t)
(add-hook 'rfn-eshadow-update-overlay-hook #'vertico-directory-tidy)
(setq dired-dwim-target t)
(add-hook 'dired-mode-hook #'dired-hide-details-mode)
(setq dired-guess-shell-alist-user
  '(("\\.\\(png\\|jpe?g\\|tiff\\)" "feh" "xdg-open")
    ("\\.\\(mp[34]\\|m4a\\|ogg\\|flac\\|webm\\|mkv\\)" "mpv" "xdg-open")
  (".*" "xdg-open")))
(setq dired-recursive-copies 'always)
(setq dired-recursive-deletes 'always)
  1. Dired sorting

    While dired is powerful, a sort function provided by dired-quick-sort helps with better ordering.

    (use-package dired-quick-sort
      :ensure t)
    

4.4.6. Dired sidebar

Emulate a tree browser by leveraging Dired using https://github.com/jojojames/dired-sidebar

(use-package dired-sidebar
  :bind (("C-x C-n" . dired-sidebar-toggle-sidebar))
  :ensure t
  :commands (dired-sidebar-toggle-sidebar)
  :init
  (add-hook 'dired-sidebar-mode-hook
            (lambda ()
              (unless (file-remote-p default-directory)
                (auto-revert-mode))))
  :config
  (push 'toggle-window-split dired-sidebar-toggle-hidden-commands)
  (push 'rotate-windows dired-sidebar-toggle-hidden-commands)

  (setq dired-sidebar-subtree-line-prefix "__")
  (setq dired-sidebar-theme 'nerd-icons)
  (setq dired-sidebar-use-term-integration t)
  (setq dired-sidebar-use-custom-font t))

4.4.7. Media player preview

Using ready-player to auto-play media from within Emacs.

(use-package ready-player
  :ensure t
  :config
  (ready-player-mode +1))

4.4.8. Dired preview

Preview files in dired mode and use the ready-player window function from https://protesilaos.com/emacs/dired-preview#h:1765efb1-f9fe-4379-8ef3-668e573e299b


(use-package dired-preview
  :ensure t
  :after ready-player
  :config
  (setq dired-preview-max-size (expt 2 20))
  (setq dired-preview-delay 2)
  (setq dired-preview-ignored-extensions-regexp
        (concat "\\."
                "\\(gz\\|"
                "zst\\|"
                "tar\\|"
                "xz\\|"
                "rar\\|"
                "zip\\|"
                "iso\\|"
                "gpg\\|"
                "epub"
                "\\)"))
  (dired-preview-global-mode 1))

(defun bmp/ready-player-dired-preview-play-toggle ()
  "Call `ready-player-toggle-play-stop' on the currently previewed media file."
  (interactive)
  (dired-preview-with-window
    (if-let* ((file buffer-file-name)
             (media (concat "\\." (regexp-opt ready-player-supported-media t) "\\'"))
             (_ (string-match-p media file)))
        (call-interactively #'ready-player-toggle-play-stop)
      (user-error "Cannot do something useful with `ready-player' here"))))

4.4.9. File Explorer

I used a sidebar for browsing folders with treemacs: https://github.com/Alexander-Miller/treemacs. I no longer load it (the block below is kept for reference with :tangle no): 4.4.6 does the same job with all my Dired set-up (preview, sorting, casual menus, dwim-shell), and I did not use treemacs workspaces, its tag view or M-0. It also took over the C-x t tab-bar keys, which are back to Emacs' tab commands now.


(use-package treemacs
  :ensure t
  :defer t
  :init
  (with-eval-after-load 'winum
    (define-key winum-keymap (kbd "M-0") #'treemacs-select-window))
  :config
  (progn
    (setq treemacs-collapse-dirs                   (if treemacs-python-executable 3 0)
          treemacs-deferred-git-apply-delay        0.5
          treemacs-directory-name-transformer      #'identity
          treemacs-display-in-side-window          t
          treemacs-eldoc-display                   'simple
          treemacs-file-event-delay                2000
          treemacs-file-extension-regex            treemacs-last-period-regex-value
          treemacs-file-follow-delay               0.2
          treemacs-file-name-transformer           #'identity
          treemacs-follow-after-init               t
          treemacs-expand-after-init               t
          treemacs-find-workspace-method           'find-for-file-or-pick-first
          treemacs-git-command-pipe                ""
          treemacs-goto-tag-strategy               'refetch-index
          treemacs-header-scroll-indicators        '(nil . "^^^^^^")
          treemacs-hide-dot-git-directory          t
          treemacs-indentation                     2
          treemacs-indentation-string              " "
          treemacs-is-never-other-window           nil
          treemacs-max-git-entries                 5000
          treemacs-missing-project-action          'ask
          treemacs-move-forward-on-expand          nil
          treemacs-no-png-images                   t
          treemacs-no-delete-other-windows         t
          treemacs-project-follow-cleanup          nil
          treemacs-persist-file                    (expand-file-name ".cache/treemacs-persist" user-emacs-directory)
          treemacs-position                        'left
          treemacs-read-string-input               'from-child-frame
          treemacs-recenter-distance               0.1
          treemacs-recenter-after-file-follow      nil
          treemacs-recenter-after-tag-follow       nil
          treemacs-recenter-after-project-jump     'always
          treemacs-recenter-after-project-expand   'on-distance
          treemacs-litter-directories              '("/node_modules" "/.venv" "/.cask")
          treemacs-project-follow-into-home        nil
          treemacs-show-cursor                     nil
          treemacs-show-hidden-files               t
          treemacs-silent-filewatch                nil
          treemacs-silent-refresh                  nil
          treemacs-sorting                         'alphabetic-asc
          treemacs-select-when-already-in-treemacs 'move-back
          treemacs-space-between-root-nodes        t
          treemacs-tag-follow-cleanup              t
          treemacs-tag-follow-delay                1.5
          treemacs-text-scale                      nil
          treemacs-user-mode-line-format           nil
          treemacs-user-header-line-format         nil
          treemacs-wide-toggle-width               70
          treemacs-width                           35
          treemacs-width-increment                 1
          treemacs-width-is-initially-locked       t
          treemacs-workspace-switch-cleanup        nil)

    (treemacs-resize-icons 14)

    (treemacs-follow-mode t)
    (treemacs-filewatch-mode t)
    (treemacs-fringe-indicator-mode 'always)
    (when treemacs-python-executable
      (treemacs-git-commit-diff-mode t))

    (pcase (cons (not (null (executable-find "git")))
                 (not (null treemacs-python-executable)))
      (`(t . t)
       (treemacs-git-mode 'deferred))
      (`(t . _)
       (treemacs-git-mode 'simple)))

    (treemacs-hide-gitignored-files-mode nil))
  :bind
  (:map global-map
        ("M-0"       . treemacs-select-window)
        ("C-x t 1"   . treemacs-delete-other-windows)
        ("C-x t t"   . treemacs)
        ("C-x t d"   . treemacs-select-directory)
        ("C-x t B"   . treemacs-bookmark)
        ("C-x t C-t" . treemacs-find-file)
        ("C-x t M-t" . treemacs-find-tag)))

(use-package treemacs-projectile
  :after (treemacs projectile)
  :ensure t)

(use-package treemacs-icons-dired
  :ensure t)

(use-package treemacs-magit
  :after (treemacs magit)
  :ensure t)

(use-package treemacs-tab-bar
  :after (treemacs)
  :ensure t
  :config (treemacs-set-scope-type 'Tabs))

4.4.10. Navigate within some buffers in read only mode a.k.a

Using the built-in view-mode works like a charm, it converts buffers to view only and does not allow them to be edited. The following code is a reproduction from http://yummymelon.com/devnull/enhancing-navigation-in-emacs-view-mode.html.

(require 'view)

(add-hook
 'view-mode-hook
 (lambda ()
   (cond ((derived-mode-p 'org-mode)
          (define-key view-mode-map (kbd "p") 'org-previous-visible-heading)
          (define-key view-mode-map (kbd "n") 'org-next-visible-heading))
         ((derived-mode-p 'markdown-mode)
          (define-key view-mode-map (kbd "p") 'markdown-outline-previous)
          (define-key view-mode-map (kbd "n") 'markdown-outline-next))
         ((derived-mode-p 'html-mode)
          (define-key view-mode-map (kbd "p") 'sgml-skip-tag-backward)
          (define-key view-mode-map (kbd "n") 'sgml-skip-tag-forward))
         ((derived-mode-p 'python-mode)
          (define-key view-mode-map (kbd "p") 'python-nav-backward-block)
          (define-key view-mode-map (kbd "n") 'python-nav-forward-block))
         ((derived-mode-p 'emacs-lisp-mode)
          (define-key view-mode-map (kbd "p") 'backward-sexp)
          (define-key view-mode-map (kbd "n") 'forward-sexp))
         ((derived-mode-p 'makefile-mode)
          (define-key view-mode-map (kbd "p") 'makefile-previous-dependency)
          (define-key view-mode-map (kbd "n") 'makefile-next-dependency))
         ((derived-mode-p 'c-mode)
          (define-key view-mode-map (kbd "p") 'c-beginning-of-defun)
          (define-key view-mode-map (kbd "n") 'c-end-of-defun))
         (t
          (define-key view-mode-map (kbd "p") 'scroll-down-command)
          (define-key view-mode-map (kbd "n") 'scroll-up-command)))))

4.4.11. Handling bookmarks

I am now enabling visual bookmarks with bm.el.

(use-package bookmark
  :ensure nil
  :defer t)

(use-package bm
  :ensure t
  :config
  (setq bm-cycle-all-buffers t)
  (add-hook 'kill-buffer-hook #'bm-buffer-save)
  (add-hook 'kill-emacs-hook #'(lambda nil
    (bm-buffer-save-all)
    (bm-repository-save)))
  (add-hook 'after-save-hook #'bm-buffer-save)
  (add-hook 'find-file-hook   #'bm-buffer-restore)
  (add-hook 'after-revert-hook #'bm-buffer-restore)
  (setq bm-marker 'bm-marker-right)
  (global-set-key (kbd "<C-f2>") 'bm-toggle)
  (global-set-key (kbd "<f2>")   'bm-next)
  (global-set-key (kbd "<S-f2>") 'bm-previous)
  (global-set-key (kbd "<left-fringe> <mouse-5>") 'bm-next-mouse)
  (global-set-key (kbd "<left-fringe> <mouse-4>") 'bm-previous-mouse)
  (global-set-key (kbd "<left-fringe> <mouse-1>") 'bm-toggle-mouse))

4.5. Project handling

I use Emacs's built-in project.el. Any Git repository is a project, and so is any folder with an empty .project file in it (~/Notes, which is synced rather than kept in Git, has one). Its commands are on C-x p: C-x p f finds a file, C-x p g searches, C-x p p switches project, C-x p d opens Dired, C-x p c compiles and C-x p s opens a shell.

I used projectile before. Eglot, xref and consult-project-buffer already worked through project.el, so I had two systems keeping separate project lists; projectile and consult-projectile are gone, and the dashboard and consult-dir list project.el's projects. project.el knows the projects I have visited; bmp/project-remember-search-paths adds every repository directly inside the folders in bmp/project-search-paths (set in secrets/local.el, see 3.3) a few seconds after start-up. Switching project opens Dired, as before.

(use-package project
  :ensure nil
  :custom
  (project-vc-extra-root-markers '(".project"))
  (project-switch-commands #'project-dired))

4.6. Org mode

4.6.1. Basic customization

Provide location of Org files as ~/Org, and then ensure that Org mode starts automatically for the necessary files.

  • Basic customization
  • Improve Org mode looks
  • Font-lock substitution for list markers
  • Setting up variable-pitch and fixed-pitch faces
  • Use long lines and visual-line-mode
;; Org customization
;; (add-to-list 'load-path (expand-file-name "~/Org"))
;; (add-to-list 'auto-mode-alist '("\\.\\(org\\|org_archive\\|txt\\)$" . org-mode))

;; (setq org-agenda-start-with-log-mode t)
;; (setq org-log-done 'time)
;; (setq org-log-into-drawer t)

;; (setq-default org-startup-indented t
;;               org-pretty-entities t
;;               org-use-sub-superscripts "{}"
;;               org-hide-emphasis-markers t
;;               org-startup-with-inline-images t
;;               org-image-actual-width '(300))

;; (font-lock-add-keywords 'org-mode
;;                      '(("^ +\\([-*]\\) "
;;                         (0 (prog1 () (compose-region (match-beginning 1) (match-end 1) "◉"))))))



;; (setq org-fold-catch-invisible-edits 'smart)

;; (add-hook 'org-mode-hook 'variable-pitch-mode)

;; (add-hook 'org-mode-hook 'visual-line-mode)

4.6.2. Org configuration

Ensure that Org is loaded now as required and then add org-latex exporter along with org-latex exporter customization.

  • Ensuring code blocks are in monospace fonts.
  • Export with org-latex
  • Activate a bunch of languages for org-babel
  • Install org-babel dependencies
(use-package org
  :ensure nil
  :demand t  ;; CRITICAL: Forces Elpaca's Org to load immediately, beating the built-in version
  :init
  ;; 1. Bulletproof auto-mode registration using the strict string-end anchor (\\')
  (add-to-list 'auto-mode-alist '("\\.\\(org\\|org_archive\\)\\'" . org-mode))

  ;; --- Basic Customization ---
  ;; Inline images and LaTeX previews are not rendered when every file
  ;; opens; add "#+startup: inlineimages latexpreview" to notes that want it.
  (setq org-agenda-start-with-log-mode t
        org-log-done 'time
        org-log-into-drawer t
        org-startup-indented t
        org-pretty-entities t
        org-use-sub-superscripts "{}"
        org-hide-emphasis-markers t
        org-image-actual-width '(300)
        org-fold-catch-invisible-edits 'smart)

  (add-hook 'org-mode-hook 'variable-pitch-mode)
  (add-hook 'org-mode-hook 'visual-line-mode)

  ;; Force the export subsystem to load immediately (fixes the C-c C-e delay)
  (require 'ox)
  ;; <s TAB and friends for inserting blocks
  (require 'org-tempo)

  :config
  ;; 2. Explicit Keybindings: Hardcoded directly into the map to bypass any use-package macro quirks
  (define-key org-mode-map (kbd "C-M-<return>") 'org-insert-subheading)
  (define-key org-mode-map (kbd "C-c C-e") 'org-export-dispatch)

  ;; --- Org Configuration ---
  (require 'oc-basic)
  (unless (functionp 'org-link-make-string)
    (fset 'org-link-make-string 'org-make-link-string))

  (defun my-adjoin-to-list-or-symbol (element list-or-symbol)
    (let ((list (if (not (listp list-or-symbol))
                    (list list-or-symbol)
                  list-or-symbol)))
      (require 'cl-lib)
      (cl-adjoin element list)))

  (mapc
   (lambda (face)
     (set-face-attribute
      face nil
      :inherit
      (my-adjoin-to-list-or-symbol
       'fixed-pitch
       (face-attribute face :inherit))))
   (list 'org-code 'org-block))

  (with-eval-after-load 'ox-latex
    (add-to-list 'org-latex-classes
                 '("org-plain-latex"
                   "\\documentclass{article}\n[NO-DEFAULT-PACKAGES]\n[PACKAGES]\n[EXTRA]"
                   ("\\section{%s}" . "\\section*{%s}")
                   ("\\subsection{%s}" . "\\subsection*{%s}")
                   ("\\subsubsection{%s}" . "\\subsubsection*{%s}")
                   ("\\paragraph{%s}" . "\\paragraph*{%s}")
                   ("\\subparagraph{%s}" . "\\subparagraph*{%s}")))
    (setq org-latex-listings t)
    (add-to-list 'org-latex-packages-alist '("" "listings"))
    (add-to-list 'org-latex-packages-alist '("" "color"))
    (add-to-list 'org-latex-packages-alist '("AUTO" "babel" t ("pdflatex" "xelatex" "lualatex")))
    (add-to-list 'org-latex-packages-alist '("AUTO" "polyglossia" t ("xelatex" "lualatex"))))

  (org-babel-do-load-languages
   'org-babel-load-languages
   '((C . t) (css . t) (dot . t) (emacs-lisp . t)
     (java . t) (latex . t) (makefile . t) (org . t) (perl . t)
     (python . t) (R . t) (shell . t) (sql . t))))


(elpaca-wait)

(use-package ob-http
  :ensure t
  :after org
  :config
  (add-to-list 'org-babel-load-languages '(http . t))
  (org-babel-do-load-languages 'org-babel-load-languages org-babel-load-languages))

4.6.3. Toggle appearance of elements

Ensure that visibility of elements can be toggled, i.e, emphasis markers, links, etc.

(use-package org-appear
  :ensure t
  :after org
  :hook
  (org-mode . org-appear-mode))
  1. Beautify links in org
    (use-package org-link-beautify
      :ensure t
      :after org
      :config
      (org-link-beautify-mode 1))
    
  2. Enable in-line preview of PDFs
    (use-package org-inline-pdf
      :ensure t
      :after pdf-tools)
    
    (add-hook 'org-mode-hook #'org-inline-pdf-mode)
    

4.6.4. Improve org list handling and provision

(use-package org-autolist
  :ensure t
  :hook (org-mode . org-autolist-mode))

4.6.5. Clean up org property drawers

(use-package org-tidy
  :ensure t
  :hook
  (org-mode . org-tidy-mode)
  :config
  (setq org-tidy-properties-style 'fringe)
  ;; "Protecting" a hidden drawer puts a key map of org-tidy's own beside it,
  ;; which replaced Org's keys there (C-<return>, C-c , and others
  ;; reported as undefined at the end of a heading line)
  (setq org-tidy-protect-overlay nil))

4.6.6. DONE Bullets customization

I have moved from org-superstar to org-modern, which styles headings, list bullets, TODO keywords, tags, priorities, blocks and timestamps in one package. org-modern-indent keeps it working with org-indent-mode (which I start with).

  • Every heading gets the same ◉ rather than a different bullet per level, as before.
  • List items starting with - or * show ◉, replacing the hand-written font-lock rule I used to have.
  • Tables are left to valign (4.6.9), so org-modern does not draw table lines.
(use-package org-modern
  :ensure t
  :hook (org-mode . org-modern-mode)
  :custom
  (org-modern-star 'replace)
  (org-modern-replace-stars "◉")
  (org-modern-list '((?- . "◉") (?* . "◉") (?+ . "◦")))
  (org-modern-table nil)
  ;; Same as the default t, but as a list: org-link-beautify adds its
  ;; "transcribe" block to this with `add-to-list', which fails on t.
  (org-modern-block-name '((t . t))))

(use-package org-modern-indent
  :ensure (:host github :repo "jdtsmith/org-modern-indent")
  :config
  ;; As recommended upstream: run after org-modern in `org-mode-hook'
  (add-hook 'org-mode-hook #'org-modern-indent-mode 90))

4.6.7. Org-babel

Customize org-babel to ensure that necessary outputs and inputs are possible.

  • Make babel results blocks lower-case
  • Turn on syntax colouring
  • Add source code blocks for org-babel
  • Make responsive in-line images
  • Added an export and tangle function where I can tangle this file and then export it to html as well from https://emacs.stackexchange.com/questions/24645/exporting-and-tangling-simultaneously-in-org-mode
  • The export writes index.html, which Codeberg Pages serves. I used to link index.html to a forgemacs.html export with a symbolic link, which Codeberg did not serve, then exported both files; forgemacs.html was only a duplicate, so it is no longer generated.
  • Added corg to enable completion-at-point for Org mode source blocks, details are at https://github.com/isamert/corg.el
  • I no longer use ob-translate: it sends text to Google Translate (through google-translate). See 4.7.10
  • Enable compile with ob-compile
(setq org-babel-results-keyword "results")

(setq org-src-fontify-natively t)

(setq org-structure-template-alist
      '(("c" . "center\n")
        ("C" . "comment\n")
        ("e" . "example\n")
        ("q" . "quote\n")
        ("v" . "verse\n")
        ("E" . "export")
        ("a" . "export ascii\n")
        ("h" . "export html\n")
        ("l" . "export latex\n")
        ("M" . "export markdown\n")
        ("m" . "markdown\n")
        ("P" . "src py\n")
        ("j" . "src java\n")
        ("s" . "src\n")
        ("t" . "tex\n")
        ("el" . "src emacs-lisp\n")
        ("ein" . "src emacs-lisp :tangle ~/.config/emacs/init.el\n")
        ("py" . "src python\n")
        ("sh" . "src sh\n")
        ("md" . "src markdown\n")
        ("go" . "src go\n")
        ("tex" . "src tex\n")
        ("java" . "src java\n")
        ("sr" . "src R\n")
        ))

(defun bmp/export-tangle-init ()
  "Function to tangle the init file in the correct location, and export it to index.html"
  (interactive)
  (setq org-html-metadata-timestamp-format "%I:%M%p, %a %e %b %Y")
  ;; The format below is only used when the postamble is t; the default,
  ;; auto, writes Org's own "Created:" footer instead
  (setq org-html-postamble t)
  (setq org-html-postamble-format
        '(("en" "<p class=\"author\">Author: %a </p>\n<p class=\"date\">Modified: %C</p><p class=\"author\"><a href=\"&#109;&#97;&#105;&#108;&#116;&#111;&#58;&#109;&#97;&#105;&#108;&#64;&#98;&#104;&#97;&#114;&#97;&#116;&#104;&#112;&#97;&#108;&#97;&#118;&#97;&#108;&#108;&#105;&#46;&#99;&#111;&#109;\" target=\"_blank\" rel=\"noopener\">Questions</a></p>")))
  (org-export-to-file 'html "index.html")
  (org-babel-tangle))

(use-package corg
  :ensure (:host github :repo "isamert/corg.el"))

(add-hook 'org-mode-hook #'corg-setup)

(use-package ob-compile
  :ensure t
  :config (add-hook 'compilation-finish-functions #'ob-compile-save-file))

4.6.8. LaTeX previews with Org

Using org-fragtog to ensure preview of LaTeX code within Org files

(use-package org-fragtog
  :ensure t
  :after org
  :hook
  (org-mode . org-fragtog-mode)
  :custom
  (org-startup-with-latex-preview t)
  :config
  ;; Not `auto' colours: Org 10 then reads the face at point, which is nil
  ;; when a whole buffer is previewed on opening, and `org-mode' fails.
  (setq org-format-latex-options
        (plist-put org-format-latex-options :scale 1)))

4.6.9. Customizing tables

Provide visual alignment with valign from https://github.com/casouri/valign

(use-package valign
  :ensure t)

(add-hook 'org-mode-hook #'valign-mode)

(defun bmp/valign-skip-org-indent-realign ()
  "Stop valign realigning every table each time org-indent finishes.
valign still aligns tables as they are displayed; this extra pass took
84% of the time in large notes with many tables."
  (advice-remove 'org-indent-initialize-agent #'valign--org-indent-advice))

;; valign adds the advice whenever it is turned on, so remove it afterwards
(add-hook 'valign-mode-hook #'bmp/valign-skip-org-indent-realign)

And since I wanted tables to have useful numbers with comma delimitation, here is a function to help remove the commas for easy addition,


(defun bmp/org-sum-list-with-commas (val-list)
  "Takes a list of strings from an Org table, strips commas, sums them,
and returns the total cleanly formatted with thousands separators and bolded."
  (let ((total 0))
    (dolist (item val-list)
      ;; Force the item to a string, clear commas, and add to running total
      (let ((clean-string (replace-regexp-in-string "," "" (format "%s" item))))
        (setq total (+ total (string-to-number clean-string)))))

    (let ((total-str (number-to-string total)))
      ;; Inject thousands commas natively back into the final summation string
      (while (string-match "\\([-+]?[0-9]+\\)\\([0-9]\\{3\\}\\)" total-str)
        (setq total-str (replace-match "\\1,\\2" nil nil total-str)))

      ;; Output the total wrapped in bold markdown asterisks
      (format "*%s*" total-str))))

4.6.10. Image handling in the Org file

One consistent headache with Org image handling is that it does not resize images correctly, by using this custom function from SO21, the aim is to at least set them to Window size. It waits half a second after the last resize before redrawing, so dragging a window edge does not redisplay every image on each step, and it sets the width per buffer for each Org window on the resized frame.


(defvar bmp/org-image-resize-timer nil
  "Pending timer for `bmp/org-image-resize'.")

(defun bmp/org-image-resize-frame (frame)
  "Fit inline images in every Org window on FRAME to the window width."
  (when (frame-live-p frame)
    (dolist (win (window-list frame 'no-minibuf))
      (with-selected-window win
        (when (derived-mode-p 'org-mode)
          (setq-local org-image-actual-width
                      (if (< (window-total-width) 80)
                          (window-pixel-width)
                        (* 80 (window-font-width))))
          (org-link-preview-refresh))))))

(defun bmp/org-image-resize (frame)
  "Refit Org images on FRAME once resizing has settled."
  (when (timerp bmp/org-image-resize-timer)
    (cancel-timer bmp/org-image-resize-timer))
  (setq bmp/org-image-resize-timer
        (run-with-idle-timer 0.5 nil #'bmp/org-image-resize-frame frame)))

(add-hook 'window-size-change-functions #'bmp/org-image-resize)

  (defun bmp/display-inline-images ()
  (condition-case nil
      (org-link-preview-region)
    (error nil)))

(add-hook 'org-babel-after-execute-hook 'bmp/display-inline-images 'append)

4.6.11. Create notes for files in Org mode

org-noter allows me to have notes for individual files outside of the files. This makes it easier to make it portable and at the same time ensure that the source files aren't modified. The notes are maintained within org-roam to ensure compatibility. I used org-pdftools and org-noter-pdftools (https://github.com/fuxialexander/org-pdftools) alongside it, plus two org-noter functions copied in to fix old bugs. They are no longer maintained, and the maintained org-noter (1.6+) handles PDFs through pdf-tools itself. My notes only use org-noter's own NOTER_DOCUMENT and NOTER_PAGE properties (no pdf: links from org-pdftools), so they keep working. Use M-x org-noter in a PDF, and i / M-i to insert a note / a note at a precise position.

(use-package djvu
  :ensure t)

(use-package org-noter
  :ensure t
  :after pdf-tools nov org-roam
  :config
  (setq org-noter-always-create-frame t)
  (org-noter-enable-org-roam-integration))

(with-eval-after-load 'org-noter
  (setq org-noter-arrow-background-color "yellow"
        org-noter-arrow-foreground-color "red"))

;; org-noter narrows the notes buffer to the document's heading; jinx's
;; background check then reaches outside the narrowed part and fails with
;; args-out-of-range, so spell-checking is off in noter sessions only.
(add-hook 'org-noter-notes-mode-hook
          (lambda () (when (bound-and-true-p jinx-mode) (jinx-mode -1))))
;; In that narrowed copy, org-modern-indent's block brackets from folded
;; headings pile up as a column of corners below the last heading.
(add-hook 'org-noter-notes-mode-hook
          (lambda ()
            (when (bound-and-true-p org-modern-indent-mode)
              (org-modern-indent-mode -1))))


4.6.12. Rich paste

Make it easy to paste code blocks in org mode from https://github.com/unhammer/org-rich-yank

(use-package org-rich-yank
  :ensure t
  :demand t
  :bind (:map org-mode-map
              ("C-M-y" . org-rich-yank)))

4.6.13. Table of contents

Let's install and load the toc-org package after org mode is loaded. This is the package that automatically generates an up to date table of contents for us.

(use-package toc-org
  :ensure t
  :after org
  :init (add-hook 'org-mode-hook #'toc-org-enable))

4.6.14. Enable auto-tangle

(use-package org-auto-tangle
  :ensure t
  :after org
  :hook (org-mode . org-auto-tangle-mode))

4.6.15. Embed org-files

After looking at various methods, org-transclutions is the best option that is currently maintained I used to add a fringe marker to transcluded text with a snippet from https://github.com/nobiot/org-transclusion/issues/126 and https://github.com/nobiot/org-transclusion/issues/201. org-transclusion (1.4 and later) now draws that marker itself, and the function the snippet called no longer exists, so the snippet is gone.

(use-package org-transclusion
  :ensure t
  :after org
  :bind
  (:map global-map
        ("<f12>" . #'org-transclusion-add)
        ("C-c t" . #'org-transclusion-mode))
  :config
  ;; Pick a note from org-roam's database (fast) before falling back to
  ;; Org's general link prompt, whose id: completion scans every note
  (setq org-transclusion-insert-link-functions
        '(org-roam-node-insert org-insert-link)))

4.6.16. Exporters

Multiple export options for org-mode

  1. Auto export as and when needed

    This is a quality of life improvement to make exporting from certain org files better, it is from https://git.sr.ht/~zondo/org-autoexport and uses #+auto_export: gfm as the format in the file

    (use-package org-autoexport
      :ensure t
      :after org
      :hook (org-mode . org-autoexport-mode))
    
  2. Export to Markdown
    (use-package ox-gfm
      :ensure t
      :commands (org-gfm-export-as-markdown org-gfm-export-to-markdown)
      :after org
      )
    
  3. Pandoc Integration

    I have included the use of ox-pandoc, to ensure that all default options are used, I prefer xelatex over pdflatex and have set-up github options as indicated in the repository.

    (use-package ox-pandoc
      :ensure t
      :after org
      :config
      (setq org-pandoc-options '((standalone . t)))
      (setq org-pandoc-options-for-beamer-pdf '((pdf-engine . "xelatex")))
      (setq org-pandoc-options-for-latex-pdf '((pdf-engine . "xelatex")))
      (setq org-pandoc-format-extensions '(markdown_github+pipe_tables+raw_html))
      )
    

    I have also used pandoc-mode in order to make life simpler.

    (use-package pandoc-mode
      :ensure t
      :after org
      :config
      (add-hook 'markdown-mode-hook 'pandoc-mode)
      (add-hook 'pandoc-mode-hook 'pandoc-load-default-settings)
      )
    
  4. Presentation in HTML mode for org with org-re-reveal

    Have a look at https://gitlab.com/oer/org-re-reveal I need to M-x load-library, and then org-re-reveal to enable org-re-reveal in the exporter (i.e org-export-dispatch (C-c C-e))

    (use-package org-re-reveal
      :ensure t
      :after org
      :init
      (setq org-re-reveal-root "https://cdn.jsdelivr.net/npm/reveal.js"
            org-re-reveal-revealjs-version "4")
      )
    
  5. Ox-hugo
    (use-package ox-hugo
      :ensure t
      :after ox)
    

4.6.17. DONE Saving fold state of Org files

In some cases, I would like to retain the fold/unfolded state of an Org file, however, Org Mode currently only provides a default state of folder or unfolded. It does provide a possibility to store the fold state for each tree/sub-tree within the file, which makes it messy. I have used the code from Emacs Wiki to provide this functionality for myself22, with these changes:

  • The state is saved in ~/.config/emacs/data/orgfold/ (one file per Org file, named after its full path) instead of a .fold file next to every note, which had left over a thousand of them in ~/Notes and synced them to every device.
  • Org 9.6 and later fold with text properties rather than overlays, so the original check found nothing folded and every heading was saved as open. Folds are now detected with invisible-p and restored with Org's own org-fold functions.
  • It works together with Org's own start-up settings: a file without saved state opens according to #+startup: and :VISIBILITY: properties. M-x bmp/orgfold-forget deletes a file's saved state so those settings apply again.
  • Only buffers visiting a file are handled (capture buffers have none).
  • A file's own #+STARTUP: (this file has fold) applies until I have folded and closed it once; from then on the saved state is restored instead.
(defvar orgfold-directory (expand-file-name "orgfold/" no-littering-var-directory)
  "Directory holding the saved fold state of Org files.")

(defun orgfold-get-fold-info-file-name ()
  "Return the fold-state file for the current buffer's file."
  (expand-file-name (concat (url-hexify-string (file-truename (buffer-file-name)))
                            ".fold")
                    orgfold-directory))

(defun orgfold-save ()
  (save-excursion
    (goto-char (point-min))

    (let (foldstates)
      (unless (looking-at outline-regexp)
        (outline-next-visible-heading 1))

      (while (not (eobp))
        ;; Org 9.6+ folds with text properties, not overlays
        (push (and (invisible-p (line-end-position)) t) foldstates)
        (outline-next-visible-heading 1))

      (make-directory orgfold-directory t)
      (with-temp-file (orgfold-get-fold-info-file-name)
        (prin1 (nreverse foldstates) (current-buffer))))))

(defun orgfold-restore ()
  (save-excursion
    (goto-char (point-min))
    (let* ((foldfile (orgfold-get-fold-info-file-name))
           (foldstates
            (when (file-readable-p foldfile)
              (with-temp-buffer
                (insert-file-contents foldfile)
                (when (> (buffer-size) 0)
                  (read (current-buffer)))))))

      (when foldstates
        (org-fold-show-all)
        (goto-char (point-min))

        (unless (looking-at outline-regexp)
          (outline-next-visible-heading 1))

        (while (and foldstates
                    (not (eobp)))
          (when (pop foldstates)
            (org-fold-hide-subtree))

          (outline-next-visible-heading 1))

        (message "Restored saved folding")))))


(add-hook 'org-mode-hook 'orgfold-activate)

(defun orgfold-activate ()
  (when (and (buffer-file-name) (not (buffer-base-buffer)))
    (orgfold-restore)
    (add-hook 'kill-buffer-hook 'orgfold-kill-buffer nil t)))

(defun bmp/orgfold-forget ()
  "Delete the saved fold state of this file; Org's start-up settings apply again."
  (interactive)
  (let ((file (orgfold-get-fold-info-file-name)))
    (remove-hook 'kill-buffer-hook 'orgfold-kill-buffer t)
    (when (file-exists-p file)
      (delete-file file))
    (message "Fold state forgotten; #+startup and :VISIBILITY: apply next time")))

(defun orgfold-kill-buffer ()
  ;; Don't save folding info for unsaved buffers, or for indirect buffers
  ;; such as org-capture's, which have no file of their own but inherit
  ;; this hook from the note's buffer.
  (when (and (buffer-file-name)
             (not (buffer-base-buffer))
             (not (buffer-modified-p)))
    (orgfold-save)))

4.6.18. Add bookmark support

Provides Emacs bookmark support for Org mode from https://github.com/alphapapa/org-bookmark-heading. Org also sets a bookmark by itself after every capture and refile (to jump back to where it went), so org-capture-last-stored kept reappearing in C-x r l after every capture, including the org-roam ones and the tasks logged to my dailies. Org's automatic bookmarks are turned off (org-bookmark-names-plist); bookmarks I set myself with C-x r m are unaffected.

(use-package org-bookmark-heading
  :ensure t
  :after org)

;; Org sets a bookmark after every capture and refile; with
;; org-bookmark-heading each was renamed after its heading, so they
;; piled up in C-x r l.  Without these names Org sets none.
(setq org-bookmark-names-plist nil)

4.6.19. Creating QRZ.com links for amateur radio call signs

Convert a HAM call sign at point into a QRZ.com link with M-x bmp/ham-convert-to-qrz-link. This script is generated with the help of locally deployed LLMs.

This used to run automatically from post-self-insert-hook every time Space was pressed in an Org buffer. I have removed that: it sent every call-sign-shaped word I typed (including in private notes) to QRZ.com, and the synchronous request blocked typing for several seconds. The lookup now only happens when I ask for it.

User experience:

  • Place point on 'W1AW' and run M-x bmp/ham-convert-to-qrz-link
  • See: 'Checking W1AW on QRZ.com…'
  • Prompt: 'Convert W1AW to QRZ.com link? (y or n)'
  • If yes: Replaces with [[https://www.qrz.com/db/W1AW][W1AW]]
;; ============================================================
;; HAM Radio Call Sign to QRZ.com Link Conversion
;; Author: BMP
;; Description: Automatically converts HAM radio call signs
;;              to clickable QRZ.com links in Org mode files
;; ============================================================

(defcustom bmp/ham-qrz-timeout 10
  "Timeout in seconds for QRZ.com verification requests.
                Increase this value if you have a slow internet connection."
  :type 'integer
  :group 'org)

(defun bmp/ham-callsign-at-point ()
  "Detect and return a HAM radio call sign at the current cursor position.

  Supports multiple call sign formats:

  1. Standard ITU format (99% of calls):
     - 1-3 letters prefix + digit + letter-based suffix
     - Examples: W1AW, VU2GLJ, KD2XYZ, G3ABC

  2. Digit-prefix format (Israel, Croatia, etc.):
     - Starts with digit, followed by letter(s), digit, suffix
     - Examples: 4X1AB, 9A1A, 3D2AB

  3. New Indian format (June 2025):
     - VU2/VU3 + digit(1-9) + 2 letters
     - Examples: VU22AR, VU33IG

  Returns uppercased call sign or nil if invalid."
  (let ((word (thing-at-point 'word t)))
    (when (and word
               (>= (length word) 4)  ; Minimum 4 chars (blocks A1A, W1A)
               (or
                ;; Pattern 1: Standard format (letter prefix)
                ;; [A-Z]{1,3} + [0-9] + (all letters OR letters+digit+2+letters)
                (string-match-p
                 "^[A-Z]\\{1,3\\}[0-9]\\([A-Z]+\\|[A-Z]+[0-9][A-Z]\\{2,\\}\\)$"
                 (upcase word))

                ;; Pattern 2: Digit-prefix format (Israel, Croatia, Fiji, etc.)
                ;; [0-9] + [A-Z]{1,2} + [0-9] + [A-Z]{1,4}
                (string-match-p
                 "^[0-9][A-Z]\\{1,2\\}[0-9][A-Z]+$"
                 (upcase word))

                ;; Pattern 3: New Indian format
                ;; VU + [2 or 3] + [1-9] + [exactly 2 letters]
                (string-match-p
                 "^VU[23][1-9][A-Z]\\{2\\}$"
                 (upcase word))))
      (upcase word))))

(defun bmp/ham-check-qrz (callsign)
  "Verify if a HAM radio CALLSIGN exists on QRZ.com database.

              This function performs a synchronous HTTP request to QRZ.com to check
              if the given call sign has a valid entry in their database. It's useful
              to prevent creating dead links for non-existent or invalid call signs.

              Arguments:
                CALLSIGN - String representing the HAM radio call sign to verify

              Returns:
                - t (true) if the call sign exists on QRZ.com
                - nil if the call sign is not found, network error, or timeout

              Implementation details:
                - Uses Emacs built-in 'url' library for HTTP requests
                - Timeout: Configurable via `bmp/ham-qrz-timeout` (default 10 seconds)
                - Detection method: Searches for 'No results found' text in response
                - Automatically cleans up the buffer after checking
                - Can be cancelled with C-g (keyboard-quit)

              Error handling:
                - Network failures return nil with error message
                - Timeout returns nil with timeout message
                - User can press C-g to cancel the operation

              Note: This function requires an active internet connection."
  (require 'url)
  (condition-case err
      (let* ((url (format "https://www.qrz.com/db/%s" callsign))
             (url-request-extra-headers '(("User-Agent" . "Emacs")))
             (buffer (progn
                       (message "Contacting QRZ.com (press C-g to cancel)...")
                       (url-retrieve-synchronously url t nil bmp/ham-qrz-timeout))))
        (if (not buffer)
            (progn
              (message "Timeout: QRZ.com did not respond within %d seconds"
                       bmp/ham-qrz-timeout)
              nil)
          (with-current-buffer buffer
            (goto-char (point-min))
            (let ((exists (not (search-forward "No results found" nil t))))
              (kill-buffer buffer)
              (if exists
                  (message "Call sign %s verified on QRZ.com" callsign)
                (message "Call sign %s not found on QRZ.com" callsign))
              exists))))
    (quit
     ;; Handle C-g gracefully
     (message "QRZ.com check cancelled by user")
     nil)
    (error
     ;; Handle other errors (network issues, etc.)
     (message "Error checking QRZ.com: %s" (error-message-string err))
     nil)))

(defun bmp/ham-convert-to-qrz-link ()
  "Interactively convert HAM call sign at point to an Org mode QRZ.com link.

                  This function is designed to be called manually (via M-x) to convert
                  a HAM radio call sign under the cursor into a clickable Org mode link
                  that points to the call sign's page on QRZ.com.

                  Workflow:
                    1. Detects if there's a valid call sign at the current cursor position
                    2. Verifies the call sign exists on QRZ.com (network request)
                    3. Prompts user for confirmation before converting
                    4. Replaces the plain text call sign with an Org link

                  Example transformation:
                    Before: W1AW
                    After:  [[https://www.qrz.com/db/W1AW](https://www.qrz.com/db/W1AW)[W1AW]]

                  User interaction:
                    - Shows status messages during QRZ.com verification
                    - Asks 'Convert CALLSIGN to QRZ.com link? (y or n)'
                    - Only proceeds if user confirms with 'y'

                  Error handling:
                    - Displays message if no valid call sign found at point
                    - Displays message if call sign doesn't exist on QRZ.com

                  Network requirement: Active internet connection needed for verification."
  (interactive)
  (let ((callsign (bmp/ham-callsign-at-point)))
    (if (not callsign)
        (message "No valid HAM call sign found at point")
      (message "Checking %s on QRZ.com..." callsign)
      (if (bmp/ham-check-qrz callsign)
          (when (y-or-n-p (format "Convert %s to QRZ.com link? " callsign))
            (let* ((bounds (bounds-of-thing-at-point 'word))
                   (url (concat "https://www.qrz.com/db/" callsign))
                   (link-text (concat "[[" url "][" callsign "]]")))
              (delete-region (car bounds) (cdr bounds))
              (insert link-text)
              (message "Converted to QRZ link!")))
        (message "Call sign %s not found on QRZ.com" callsign)))))

4.7. Editing

A bunch of configuration to improve the text editing experience in Emacs.

4.7.1. Spell-checking

Trying out jinx from https://github.com/minad/jinx, and I have enabled it globally. It is the only spell checker: Magit used to turn on flyspell in commit messages and ERC its own erc-spelling-mode (ispell), which ran separate engines without my personal word list. jinx already checks both.

(use-package jinx
  :ensure t
  :hook (emacs-startup . global-jinx-mode)
  :bind (("M-$" . jinx-correct)
         ("C-M-$" . jinx-languages))
  :config

  (setq jinx-languages "en_GB"))

(defun jinx--add-to-abbrev (overlay word)
  "Add abbreviation to `global-abbrev-table'.
   The misspelled word is taken from OVERLAY.  WORD is the corrected word."
  (let ((abbrev (buffer-substring-no-properties
                 (overlay-start overlay)
                 (overlay-end overlay))))
    (message "Abbrev: %s -> %s" abbrev word)
    (define-abbrev global-abbrev-table abbrev word)))

(advice-add 'jinx--correct-replace :before #'jinx--add-to-abbrev)

I have also added LanguageTool for grammar and style, which vale and jinx do not cover. The package is langtool.el; the server is the one Homebrew installs (brew install languagetool), which runs as the systemd user service homebrew.languagetool on 127.0.0.1:8081.

The server is a Java program using over 3 GB of memory, so it does not run all the time: Emacs starts it when I run a check and stops it after bmp/langtool-idle-minutes without one (and when Emacs exits), like 4.7.10. Turn off its autostart once (and do not use brew services start languagetool afterwards, which turns it back on):

systemctl --user disable --now homebrew.languagetool

I used to start a second server from a jar, with its paths kept in authinfo (langtool-jar, langtool-java-bin, langtool-prop); those entries are no longer used. For language detection with fastText (only needed when the language is auto; I check British English), add fasttextModel=/path/to/lid.176.bin to /home/linuxbrew/.linuxbrew/etc/languagetool/server.properties, with the model from https://fasttext.cc/docs/en/language-identification.html.

Fixes and additions to langtool.el:

  • Encoding: text travels to the server percent-encoded, but Emacs's url-build-query-string leaves % and + as they are. Any % in a note (CSS such as max-width: 100%;) made the server reject the whole request with "Could not decode query", and every + arrived as a space. bmp/langtool--query-string encodes every value fully. No rule is involved.
  • Only prose is checked: before sending, code blocks, results, example and export blocks, drawers, keyword lines, LaTeX environments and link addresses are replaced with spaces, and inline code, verbatim and LaTeX fragments with a placeholder word of the same length, so every reported position still matches the buffer. Tables, headings, lists, quotes and link descriptions are checked. This is also what makes large notes work: one 2.2 MB note was 94% embedded Plotly HTML, and is 38,000 characters of prose.
  • Size: a check stops with a message when the prose is over bmp/langtool-max-chars; M-x bmp/langtool-check-section checks the heading at point (with its subheadings) instead. A selected region is checked on its own with M-x langtool-check too.
  • Rules: three are off by default: WHITESPACE_RULE (flags aligned spaces in drawers and the masked text), MORFOLOGIK_RULE_EN_GB (spelling, which jinx does) and OXFORD_SPELLING_Z_NOT_S (I write British -ise). With point on an issue, M-x bmp/langtool-rule shows its rule and turns it off for this buffer, for this Emacs session, or permanently (saved in data/langtool-disabled-rules.el, so it survives tangling); M-x bmp/langtool-rules lists what is off and where, and turns rules back on.
(use-package langtool
  :ensure t
  :config
  (setq langtool-default-language "en-GB"
        langtool-mother-tongue "en"
        langtool-http-server-host "localhost"
        langtool-http-server-port 8081
        langtool-bin nil
        langtool-language-tool-server-jar nil))

;; --- The server: started on demand, stopped when idle
(defvar bmp/langtool-unit "homebrew.languagetool"
  "Systemd user unit of the LanguageTool server.")
(defvar bmp/langtool-idle-minutes 10
  "Stop the LanguageTool server after this many minutes without a check.")
(defvar bmp/langtool--stop-timer nil)

(defun bmp/langtool--server-up-p ()
  "Return non-nil if the LanguageTool server answers properly."
  (require 'url)
  (let ((buffer (ignore-errors
                  (url-retrieve-synchronously
                   "http://127.0.0.1:8081/v2/languages" t nil 2))))
    (when buffer
      (with-current-buffer buffer
        (prog1 (and (bound-and-true-p url-http-end-of-headers)
                    (eql (bound-and-true-p url-http-response-status) 200))
          (kill-buffer))))))

(defun bmp/langtool--ensure-server (&rest _)
  "Start the LanguageTool service if needed and wait until it answers."
  (unless (bmp/langtool--server-up-p)
    (call-process "systemctl" nil nil nil "--user" "start" bmp/langtool-unit)
    (let ((deadline (+ (float-time) 180)))
      (while (not (bmp/langtool--server-up-p))
        (when (> (float-time) deadline)
          (user-error "LanguageTool did not start; see journalctl --user -u %s"
                      bmp/langtool-unit))
        (message "Starting LanguageTool (C-g to stop waiting)...")
        (sleep-for 1))))
  (when (timerp bmp/langtool--stop-timer)
    (cancel-timer bmp/langtool--stop-timer))
  (setq bmp/langtool--stop-timer
        (run-at-time (* 60 bmp/langtool-idle-minutes) nil
                     #'bmp/langtool--stop-server)))

(defun bmp/langtool--stop-server ()
  "Stop the LanguageTool service in the background."
  (setq bmp/langtool--stop-timer nil)
  (start-process "langtool-stop" nil
                 "systemctl" "--user" "stop" bmp/langtool-unit))

(add-hook 'kill-emacs-hook
          (lambda () (when (timerp bmp/langtool--stop-timer)
                       (bmp/langtool--stop-server))))

;; --- Rules: default, permanent, session and buffer levels
(defvar bmp/langtool-default-disabled-rules
  '("WHITESPACE_RULE" "MORFOLOGIK_RULE_EN_GB" "OXFORD_SPELLING_Z_NOT_S")
  "Rules off unless changed with `bmp/langtool-rule'.")
(defvar bmp/langtool-rules-file
  (expand-file-name "langtool-disabled-rules.el" no-littering-var-directory)
  "Where the permanent list of disabled rules is kept once changed.")
(defvar bmp/langtool-session-disabled-rules nil
  "Rules off for this Emacs session.")
(defvar-local bmp/langtool-buffer-disabled-rules nil
  "Rules off in this buffer.")

(defun bmp/langtool--permanent-rules ()
  "Return the permanent list (the saved one, or the defaults)."
  (if (file-exists-p bmp/langtool-rules-file)
      (with-temp-buffer
        (insert-file-contents bmp/langtool-rules-file)
        (read (current-buffer)))
    bmp/langtool-default-disabled-rules))

(defun bmp/langtool--save-permanent (rules)
  (make-directory (file-name-directory bmp/langtool-rules-file) t)
  (with-temp-file bmp/langtool-rules-file
    (insert ";; Rules LanguageTool skips; edit with M-x bmp/langtool-rules\n")
    (prin1 (sort (delete-dups (copy-sequence rules)) #'string<) (current-buffer))
    (insert "\n")))

(defun bmp/langtool--apply-rules (&rest _)
  "Give langtool the rules off at every level, before a check."
  (setq langtool-disabled-rules
        (delete-dups (append (bmp/langtool--permanent-rules)
                             bmp/langtool-session-disabled-rules
                             bmp/langtool-buffer-disabled-rules))))

(defun bmp/langtool-rule ()
  "Show the rule of the LanguageTool issue at point and turn it off."
  (interactive)
  (let* ((ov (car (langtool--current-error-overlays)))
         (rule (and ov (overlay-get ov 'langtool-rule-id))))
    (unless rule
      (user-error "No LanguageTool issue at point"))
    (pcase (read-char-choice
            (format "%s: %s\nTurn off in this [b]uffer, this [s]ession, [p]ermanently, or [q]uit? "
                    rule (overlay-get ov 'langtool-simple-message))
            '(?b ?s ?p ?q))
      (?b (cl-pushnew rule bmp/langtool-buffer-disabled-rules :test #'equal))
      (?s (cl-pushnew rule bmp/langtool-session-disabled-rules :test #'equal))
      (?p (bmp/langtool--save-permanent
           (cons rule (bmp/langtool--permanent-rules))))
      (?q (setq rule nil)))
    (when rule
      ;; Remove that rule's marks now; the next check leaves it out
      (dolist (o (langtool--overlays-region (point-min) (point-max)))
        (when (equal (overlay-get o 'langtool-rule-id) rule)
          (delete-overlay o)))
      (message "%s is off" rule))))

(defun bmp/langtool-rules ()
  "List the rules that are off, and turn one back on."
  (interactive)
  (let* ((rows (append
                (mapcar (lambda (r) (cons (format "%-32s permanent" r) (cons 'permanent r)))
                        (bmp/langtool--permanent-rules))
                (mapcar (lambda (r) (cons (format "%-32s session" r) (cons 'session r)))
                        bmp/langtool-session-disabled-rules)
                (mapcar (lambda (r) (cons (format "%-32s this buffer" r) (cons 'buffer r)))
                        bmp/langtool-buffer-disabled-rules)))
         (choice (cdr (assoc (completing-read "Turn back on (rule  level): "
                                              (mapcar #'car rows) nil t)
                             rows))))
    (pcase (car choice)
      ('permanent (bmp/langtool--save-permanent
                   (remove (cdr choice) (bmp/langtool--permanent-rules))))
      ('session (setq bmp/langtool-session-disabled-rules
                      (remove (cdr choice) bmp/langtool-session-disabled-rules)))
      ('buffer (setq bmp/langtool-buffer-disabled-rules
                     (remove (cdr choice) bmp/langtool-buffer-disabled-rules))))
    (message "%s is on again; run the check again to see its issues" (cdr choice))))

;; --- Encoding: send every character percent-encoded
(defvar bmp/langtool--text-start nil
  "Buffer position where the text being checked starts.")

(defun bmp/langtool--query-string (query)
  "Like `url-build-query-string', but also encodes % and +."
  (mapconcat (lambda (kv)
               (concat (url-hexify-string (format "%s" (car kv))) "="
                       (url-hexify-string (format "%s" (or (cadr kv) "")))))
             query "&"))

(defun bmp/langtool--encode-query (orig &optional begin finish lang)
  "Build the request with ORIG, encoding it fully; note where text starts."
  (let ((bmp/langtool--text-start (or begin (point-min))))
    (cl-letf (((symbol-function 'url-build-query-string)
               #'bmp/langtool--query-string))
      (funcall orig begin finish lang))))

;; --- Only prose: blank out everything else, keeping positions
(defvar bmp/langtool-max-chars 200000
  "Largest amount of prose (after masking) sent in one check.")

(defvar bmp/langtool-skip-elements
  '(src-block example-block export-block fixed-width comment-block
    keyword property-drawer drawer planning clock latex-environment)
  "Org elements replaced by spaces before checking.")

(defvar bmp/langtool-placeholder-objects
  '(latex-fragment inline-src-block code verbatim)
  "Org objects replaced by a placeholder word before checking.")

(defun bmp/langtool--non-prose-ranges ()
  "Return (BEG END FILL) for each part of the buffer that is not prose."
  (let ((tree (org-element-parse-buffer))
        ranges)
    (org-element-map tree bmp/langtool-skip-elements
      (lambda (el) (push (list (org-element-begin el) (org-element-end el) ?\s)
                         ranges)))
    ;; Code block results (#+RESULTS: and what follows)
    (org-element-map tree org-element-all-elements
      (lambda (el) (when (org-element-property :results el)
                     (push (list (org-element-begin el) (org-element-end el) ?\s)
                           ranges))))
    (org-element-map tree bmp/langtool-placeholder-objects
      (lambda (el) (push (list (org-element-begin el)
                               (- (org-element-end el) (org-element-post-blank el))
                               ?x)
                         ranges)))
    ;; Links: the address and brackets, not the description
    (org-element-map tree 'link
      (lambda (el)
        (let ((cb (org-element-contents-begin el))
              (ce (org-element-contents-end el))
              (end (- (org-element-end el) (org-element-post-blank el))))
          (if cb
              (progn (push (list (org-element-begin el) cb ?\s) ranges)
                     (push (list ce end ?x) ranges))
            (push (list (org-element-begin el) end ?x) ranges)))))
    ranges))

(defun bmp/langtool-mask-non-prose (query)
  "In Org buffers, blank out what is not prose in QUERY's text.
Stop if the remaining prose is over `bmp/langtool-max-chars'."
  (let ((text (assoc "text" query))
        (start (or bmp/langtool--text-start (point-min))))
    (when text
      (let ((s (copy-sequence (cadr text))))
        (when (derived-mode-p 'org-mode)
          (pcase-dolist (`(,b ,e ,fill) (bmp/langtool--non-prose-ranges))
            (cl-loop for p from (max b start) below e
                     for i = (- p start)
                     while (< i (length s))
                     unless (eq (aref s i) ?\n)
                     do (aset s i fill)))
          (setcar (cdr text) s))
        (let ((prose (length (replace-regexp-in-string "[ \n]+" " " s))))
          (when (> prose bmp/langtool-max-chars)
            (user-error "%d characters of prose is over the %d limit; use M-x bmp/langtool-check-section or select a region"
                        prose bmp/langtool-max-chars))))))
  query)

(defun bmp/langtool-check-section ()
  "Check the Org heading at point, with its subheadings."
  (interactive)
  (save-excursion
    (org-back-to-heading t)
    (org-mark-subtree)
    (langtool-check)))

(with-eval-after-load 'langtool
  (setq langtool-client-filter-query-function #'bmp/langtool-mask-non-prose)
  (advice-add 'langtool-client--make-post-data :around #'bmp/langtool--encode-query)
  (advice-add 'langtool-check-buffer :before #'bmp/langtool--ensure-server)
  (advice-add 'langtool-check-buffer :before #'bmp/langtool--apply-rules))

4.7.2. Highlighting line

Using pulsar to pulse highlight line on demand or after running select functions, integrate it with consult and with imenu.

(use-package pulsar
  :ensure t
  :config
  (setq pulsar-pulse t)
  (setq pulsar-delay 0.055)
  (setq pulsar-iterations 10)
  (setq pulsar-face 'pulsar-magenta)
  (setq pulsar-highlight-face 'pulsar-yellow)

  (let ((map global-map))
    (define-key map (kbd "C-c h p") #'pulsar-pulse-line)
    (define-key map (kbd "C-c h h") #'pulsar-highlight-line))

  (add-hook 'minibuffer-setup-hook #'pulsar-pulse-line)
  (add-hook 'minibuffer-setup-hook #'pulsar-pulse-line-blue)

  ;; integration with the `consult' package:
  (add-hook 'consult-after-jump-hook #'pulsar-recenter-top)
  (add-hook 'consult-after-jump-hook #'pulsar-reveal-entry)

  ;; integration with the built-in `imenu':
  (add-hook 'imenu-after-jump-hook #'pulsar-recenter-top)
  (add-hook 'imenu-after-jump-hook #'pulsar-reveal-entry)

  (pulsar-global-mode 1))


4.7.3. Smart Parentheses

Using smartparens to ensure parentheses are matched and balanced easily.

(use-package smartparens
  :ensure t
  :config
  (require 'smartparens-config)

  (add-hook 'org-mode-hook #'smartparens-mode)
  (add-hook 'css-base-mode-hook #'smartparens-mode)
  (add-hook 'python-base-mode-hook #'smartparens-mode)
  ;; Don't let electric-pair insert a second bracket where smartparens is on
  (add-hook 'smartparens-mode-hook
            (lambda () (electric-pair-local-mode (if smartparens-mode -1 1))))
  (add-hook 'minibuffer-setup-hook 'turn-on-smartparens-strict-mode)

  (show-smartparens-global-mode t)

  (sp-with-modes '(minibuffer-inactive-mode minibuffer-mode)
    (sp-local-pair "'" nil :actions nil)
    (sp-local-pair "(" nil :wrap "C-("))

  (sp-with-modes 'org-mode
    (sp-local-pair "=" "=" :wrap "C-="))

  (sp-with-modes 'web-mode
    (sp-local-pair "{{#if" "{{/if")
    (sp-local-pair "{{#unless" "{{/unless"))


  (sp-with-modes '(tex-mode plain-tex-mode latex-mode)
    (sp-local-tag "i" "\"<" "\">"))


  (defun my-add-space-after-sexp-insertion (id action _context)
    (when (eq action 'insert)
      (save-excursion
        (forward-char (sp-get-pair id :cl-l))
        (when (or (eq (char-syntax (following-char)) ?w)
                  (looking-at (sp--get-opening-regexp)))
          (insert " ")))))

  (defun my-add-space-before-sexp-insertion (id action _context)
    (when (eq action 'insert)
      (save-excursion
        (backward-char (length id))
        (when (or (eq (char-syntax (preceding-char)) ?w)
                  (and (looking-back (sp--get-closing-regexp))
                       (not (eq (char-syntax (preceding-char)) ?'))))
          (insert " ")))))

  (sp-with-modes sp--lisp-modes
    (sp-local-pair "(" nil
                   :wrap "C-("
                   :pre-handlers '(my-add-space-before-sexp-insertion)
                   :post-handlers '(my-add-space-after-sexp-insertion))))

4.7.4. Auto-pair special characters and parentheses

electric-mode does not always work well, so this needs some work and changes.

(electric-pair-mode 1)

(setq electric-quote-paragraph t
      electric-quote-comment t)

(setq electric-pair-pairs '(
                            (?\( . ?\))
                            (?\< . ?\>)
                            (?\[ . ?\])
                            (?\{ . ?\})
                            (?\~ . ?\~)
                            ))

4.7.5. Recent files

This is an inbuilt Emacs package, I am only customizing it here.

(use-package recentf
  :ensure nil
  :hook (after-init . recentf-mode)
  :config
  (setq recentf-max-saved-items 100)
  (setq recentf-max-menu-items 25)
  (setq recentf-save-file-modes nil)
  (setq recentf-keep nil)
  (setq recentf-auto-cleanup nil)
  (setq recentf-initialize-file-name-history nil)
  (setq recentf-filename-handlers nil)
  (setq recentf-show-file-shortcuts-flag nil))

4.7.6. Undo

I was using undo-tree earlier, but vundo is quicker and is intuitive for my use-case. It is currently hard-coded to my current theme which is ef-owl

(use-package vundo
  :ensure t
  :bind ("C-x C-u" . vundo)
  :config
  (setq vundo-compact-display t)
  (setq vundo-window-max-height 8)
  (setq vundo-glyph-alist vundo-unicode-symbols)
  (set-face-attribute 'vundo-default nil :family "FiraCode Nerd Font Mono")
  (ef-themes-with-colors
    (set-face-foreground 'vundo-node fg-main)
    (set-face-foreground 'vundo-stem fg-dim)
    (set-face-foreground 'vundo-saved green-cooler)
    (set-face-foreground 'vundo-last-saved green)
    (set-face-foreground 'vundo-highlight fg-changed)))

(use-package undo-fu
  :ensure t)

(use-package undo-fu-session
  :ensure t
  :config
  (add-to-list 'undo-fu-session-incompatible-major-modes 'org-mode)
  (setq undo-fu-session-incompatible-files '("/COMMIT_EDITMSG\\'" "/git-rebase-todo\\'"))
  (undo-fu-session-global-mode))

4.7.7. Markdown

(use-package markdown-mode
  :ensure t
  :mode ("README\\.md\\.njk\\'" . gfm-mode)
  :init (setq markdown-command "multimarkdown | pandoc"))

(add-to-list 'auto-mode-alist '("\\.njk\\'" . web-mode))
(add-to-list 'auto-mode-alist
             '("\\.\\(?:md\\|markdown\\|mkd\\|mdown\\|mkdn\\|mdwn\\)\\'" . markdown-mode))
(add-to-list 'auto-mode-alist '("README\\.md\\'" . gfm-mode))

4.7.8. CSV Mode

Emacs is one of the best editors for csv files and can guess and set the separator.

(use-package csv-mode
  :ensure nil
  :config
  (add-hook 'csv-mode-hook 'csv-guess-set-separator))

(add-to-list 'auto-mode-alist '("\\.csv\\'" . csv-mode))

4.7.9. HTML

https://github.com/netguy204/imp.el allows us to see the changes in the browser as you type.

  • Install simple-httpd for additional functions
  • Enable the web server provided by simple-httpd: ~M-x httpd-start
  • Publish buffers by enabling the minor mode impatient-mode: M-x impatient-mode.
  • And then point your browser to http://localhost:8080/imp/, select a buffer, and watch your changes appear as you type!

    (use-package simple-httpd
      :ensure t)
    
    (use-package impatient-mode
      :ensure t)
    

4.7.10. Translation

I used lingva.el (https://codeberg.org/martianh/lingva.el) with the public lingva.ml instance to translate without tracking from Google, but that instance no longer serves requests (it returns HTTP 403). Nothing I use for translation may send text to Google, so ob-translate has gone as well.

Translation now goes to a LibreTranslate server running locally in Podman (see 1.6), so the text never leaves this machine. Public LibreTranslate instances come and go (in October 2026 only the paid official one still answered), so I do not rely on them. Select a region and run M-x bmp/translate, then choose the source language (auto lets LibreTranslate guess, but it mistakes Esperanto for Spanish, so pick eo for Esperanto) and the target language. The translation is shown in the echo area and copied to the kill ring, and with a prefix argument (C-u) it replaces the region instead. The server is started when needed (the first translation after a while waits for it to come up) and stopped after bmp/translate-idle-minutes without use. The languages offered are the ones the server has loaded; M-x bmp/translate-add-language adds one (its models download on the next start).

(defvar bmp/libretranslate-url "http://127.0.0.1:5000/translate"
  "Local LibreTranslate endpoint.")

(defvar bmp/translate-default-languages "en,eo,es,fr,de"
  "Languages written to the env file the first time it is created.")

(defvar bmp/translate-env-file
  (expand-file-name "~/.config/libretranslate/languages.env")
  "File holding LT_LOAD_ONLY, read by the libretranslate service.")

(defvar bmp/translate-idle-minutes 10
  "Stop the LibreTranslate service after this many minutes without use.")

(defvar bmp/translate--stop-timer nil)

(defun bmp/translate--ensure-env-file ()
  "Create `bmp/translate-env-file' from the defaults if it is missing."
  (unless (file-exists-p bmp/translate-env-file)
    (make-directory (file-name-directory bmp/translate-env-file) t)
    (with-temp-file bmp/translate-env-file
      (insert "LT_LOAD_ONLY=" bmp/translate-default-languages "\n"))))

(defun bmp/translate--json (url &optional timeout)
  "Fetch URL and return its JSON body as an alist, or nil.
Only a complete HTTP 200 reply counts: while the server is starting, a
refused connection can still give back an empty reply buffer."
  (require 'url)
  (let ((buffer (ignore-errors
                  (url-retrieve-synchronously url t nil (or timeout 10)))))
    (when buffer
      (with-current-buffer buffer
        (prog1 (and (bound-and-true-p url-http-end-of-headers)
                    (eql (bound-and-true-p url-http-response-status) 200)
                    (ignore-errors
                      (json-parse-string
                       (decode-coding-string
                        (buffer-substring-no-properties
                         url-http-end-of-headers (point-max))
                        'utf-8)
                       :object-type 'alist :array-type 'list)))
          (kill-buffer))))))

(defun bmp/translate--languages-url ()
  (replace-regexp-in-string "/translate\\'" "/languages" bmp/libretranslate-url))

(defun bmp/translate--server-up-p ()
  "Return non-nil if the LibreTranslate server answers properly."
  (and (bmp/translate--json (bmp/translate--languages-url) 2) t))

(defun bmp/translate--systemctl (action)
  "Run systemctl --user ACTION on the libretranslate service.
Stopping runs in the background so Emacs never waits for it; other
actions wait, because the server is needed straight afterwards."
  (if (equal action "stop")
      (start-process "libretranslate-stop" nil
                     "systemctl" "--user" "stop" "libretranslate")
    (call-process "systemctl" nil nil nil "--user" action "libretranslate")))

(defun bmp/translate--ensure-server ()
  "Start the LibreTranslate service if needed and wait until it answers."
  (unless (bmp/translate--server-up-p)
    (bmp/translate--ensure-env-file)
    (bmp/translate--systemctl "start")
    (let ((deadline (+ (float-time) 600)))
      (while (not (bmp/translate--server-up-p))
        (when (> (float-time) deadline)
          (user-error "LibreTranslate did not start; see journalctl --user -u libretranslate"))
        (message "Starting LibreTranslate (C-g to stop waiting)...")
        (sleep-for 1)))
    (message "LibreTranslate is ready")))

(defun bmp/translate--schedule-stop ()
  "Stop the service after `bmp/translate-idle-minutes' without use."
  (when (timerp bmp/translate--stop-timer)
    (cancel-timer bmp/translate--stop-timer))
  (setq bmp/translate--stop-timer
        (run-at-time (* 60 bmp/translate-idle-minutes) nil
                     #'bmp/translate--systemctl "stop")))

(add-hook 'kill-emacs-hook
          (lambda ()
            (when (timerp bmp/translate--stop-timer)
              (bmp/translate--systemctl "stop"))))

(defun bmp/translate-add-language (code)
  "Add language CODE (for example \"ja\") to the LibreTranslate service.
The service is restarted if running; the new models are downloaded on
its next start."
  (interactive "sLanguage code to add (e.g. ja, it, pt): ")
  (bmp/translate--ensure-env-file)
  (with-temp-buffer
    (insert-file-contents bmp/translate-env-file)
    (goto-char (point-min))
    (if (re-search-forward "^LT_LOAD_ONLY=\\(.*\\)$" nil t)
        (let* ((beg (match-beginning 1))
               (end (match-end 1))
               (codes (split-string (match-string 1) "," t "[ \t]+")))
          (when (member code codes)
            (user-error "%s is already loaded" code))
          (delete-region beg end)
          (goto-char beg)
          (insert (string-join (append codes (list code)) ",")))
      (goto-char (point-max))
      (insert "LT_LOAD_ONLY=" bmp/translate-default-languages "," code "\n"))
    (write-region nil nil bmp/translate-env-file))
  (when (bmp/translate--server-up-p)
    (bmp/translate--systemctl "restart"))
  (message "Added %s; its models download the next time LibreTranslate starts"
           code))

(defun bmp/translate-languages ()
  "Return the language codes loaded in the local LibreTranslate server.
Starts the server if needed."
  (bmp/translate--ensure-server)
  (let ((languages (bmp/translate--json (bmp/translate--languages-url))))
    (unless languages
      (user-error "LibreTranslate did not answer at %s" bmp/libretranslate-url))
    (mapcar (lambda (language) (alist-get 'code language)) languages)))

(defun bmp/translate (beg end source target &optional replace)
  "Translate the region from BEG to END from SOURCE into TARGET.
Uses the local LibreTranslate server.  SOURCE may be \"auto\" to let it
detect the language.  Show the result and copy it to the kill ring.
With a prefix argument REPLACE, replace the region instead."
  (interactive
   (if (use-region-p)
       (let ((languages (bmp/translate-languages)))
         (list (region-beginning) (region-end)
               (completing-read "Translate from (default auto): "
                                (cons "auto" languages) nil t nil nil "auto")
               (completing-read "Translate to: " languages nil t)
               current-prefix-arg))
     (user-error "Select the text to translate first")))
  (require 'url)
  (bmp/translate--ensure-server)
  (bmp/translate--schedule-stop)
  (let* ((url-request-method "POST")
         (url-request-extra-headers '(("Content-Type" . "application/json")))
         (url-request-data
          (encode-coding-string
           (json-encode `(("q" . ,(buffer-substring-no-properties beg end))
                          ("source" . ,source)
                          ("target" . ,target)))
           'utf-8))
         (result (alist-get 'translatedText
                            (bmp/translate--json bmp/libretranslate-url 60))))
    (unless result
      (user-error "LibreTranslate returned no translation; see journalctl --user -u libretranslate"))
    (if replace
        (progn (delete-region beg end)
               (goto-char beg)
               (insert result))
      (kill-new result)
      (message "%s" result))))

4.7.11. Using templates

Using yasnippets is a powerful way to ensure that I can improve the writing quality within Emacs. I have also ensured that snippet backquote-change warnings are suppressed.

(use-package yasnippet
  :ensure t
  :demand t
  :diminish yas-minor-mode
  :config
  (yas-global-mode 1)
  )

(add-to-list 'warning-suppress-types '(yasnippet backquote-change))

(use-package yasnippet-snippets
  :ensure t
  :after yasnippet)

Here are a bunch of snippets that I currently use with my configuration, and this is a growing list of snippets. Since org-mode is my primary mode, I will first create a directory for it.

# Create ~/.config/emacs/config/yasnippet/snippets/org-mode folder
if [ -d ~/.config/emacs/config/yasnippet/snippets/org-mode ]
then
    echo "Org-mode snippets folder exists"
else
    echo "Org-mode snippets folder does not exist"
    mkdir -p ~/.config/emacs/config/yasnippet/snippets/org-mode/
fi
  1. Blog for Getting On The Air
    # -*- mode: snippet -*-
    # name: Hugo Frontmatter Gota
    # key: !!gota
    # --
    
    #+date: $0
    #+hugo_base_dir: `(or bmp/hugo-base-dir "")`
    #+hugo_auto_set_lastmod: t
    #+options: author:nil
    
  2. Org Image Caption Attributes
    # -*- mode: snippet -*-
    # name: Org Image Caption
    # key: !!oic
    # --
    
    #+caption: ${1:Caption}
    #+name: ${2:Name}
    #+attr_html:  width="${2:Width}px" alt="${3:Alt}" :align center
    #+attr_org:  :width ${2:Width}px
    $0
    
  3. Org Center Image
    # -*- mode: snippet -*-
    # name: Org Image Center
    # key: !!oci
    # --
    
    #+begin_export hugo
    <style>
    .img-center{
      margin-left:  auto;
      margin-right: auto;
      max-width: ${1}%;
    }
    </style>
    $0
    #+end_export
    
  4. Org Image Sizes Hugo
    # -*- mode: snippet -*-
    # name: Org Image Sizes Hugo
    # key: !!oih
    # --
    
    #+begin_export hugo
    <style>
    .img-small{
    margin-left:  auto;
    margin-right: auto;
    max-width: 30%;
    }
    .img-med{
    margin-left:  auto;
    margin-right: auto;
    max-width: 50%;
    }
    .img-large{
    margin-left:  auto;
    margin-right: auto;
    max-width: 70%;
    }
    .img-full{
    margin-left:  auto;
    margin-right: auto;
    max-width: 100%;
    }
    </style>
    #+end_export
    
  5. Org Image Caption Alt Only Attributes
    # -*- mode: snippet -*-
    # name: Org Image Caption Alt Only Attributes
    # key: !!oiac
    # --
    
    #+caption: ${1:Caption}
    #+name: ${2:Name}
    #+attr_html: :class "${3:Class}" :alt "${4:Alt}"
    $0
    
  6. Org Reveal New Slide
    # -*- mode: snippet -*-
    # name: Reveal split slide
    # key: !!rebr
    # --
    
    #+REVEAL: split
    $0
    
  7. Org Reveal do not add to ToC
    # -*- mode: snippet -*-
    # name: Reveal no ToC
    # key: !!rentoc
    # --
    
    :PROPERTIES:
    :UNNUMBERED: notoc
    :END:
    $0
    
  8. Org Reveal front matter for footnotes
    # -*- mode: snippet -*-
    # name: Front matter for reveal footnotes
    # key: !!refn
    # --
    
    #+REVEAL_MARGIN: 0.1
    #+REVEAL_HEAD_PREAMBLE: <style type="text/css">
    #+REVEAL_HEAD_PREAMBLE: .footpara { display: inline; }
    #+REVEAL_HEAD_PREAMBLE: .footdef { text-align: left; font-size: .6em;}
    #+REVEAL_HEAD_PREAMBLE:  body { font-family: "Georgia", Georgia, serif; }
    #+REVEAL_HEAD_PREAMBLE: </style>
    
    $0
    
  9. Org Reveal Title Slide
    # -*- mode: snippet -*-
    # name: Reveal Title
    # key: !!ret
    # --
    
    #+REVEAL_TITLE_SLIDE: ${1:HTMLTitle}
    $0
    
  10. Org add last accessed URL on code
    # -*- mode: snippet -*-
    # name: Last accessed date
    # key: !!laud
    # --
    /Last accessed on `(format-time-string "%A, %d %b %Y")`/
    
  11. Presentation button within ox-hugo post
    # -*- mode: snippet -*-
    # name: oxhugpres
    # key: !!ohp
    # --
    
    #+begin_export html
    <style>
      .preso-container {
        text-align: center;
        margin: 3rem 0;
      }
      .preso-btn-link {
        display: inline-block;
        padding: 12px 26px;
        background-color: var(--tag-hover-bg);
        color: var(--tag-hover-color) !important;
        border: 1px solid var(--tag-hover-bg);
        text-decoration: none;
        border-radius: 5px;
        font-weight: bold;
        font-size: 1rem;
        transition: opacity 0.2s ease, transform 0.2s ease;
      }
      .preso-btn-link:hover {
        opacity: 0.9;
        transform: translateY(-2px);
      }
      .preso-caption {
        margin-top: 10px;
        font-size: 0.85rem;
        color: var(--helptext-color);
        font-style: italic;
      }
    </style>
    
    <div class="preso-container">
      <a href="presentation.html" target="_blank" class="preso-btn-link">
        View Presentation
      </a>
      <div class="preso-caption">An accompanying presentation is available for this post.</div>
    </div>
    #+end_export
    

4.7.12. Writing aids

Improvement plain-text writing within Emacs

  1. Grammar

    Highlights text based on a set of weasel-words, passive-voice and duplicate words, by using https://github.com/bnbeckwith/writegood-mode. It no longer turns on in every text buffer: vale (see 4.15.2) does the same checks, with explanations and a list of findings, so writegood-mode is for drafting only, toggled with C-c g when I want instant highlighting as I write.

    (use-package writegood-mode
      :ensure (:host github :repo "bnbeckwith/writegood-mode")
      :config
      (require 'writegood-mode)
      (global-set-key "\C-cg" 'writegood-mode)
      (writegood-weasels-turn-on)
      (writegood-passive-voice-turn-on)
      (writegood-duplicates-turn-on))
    
  2. Distraction free writing

    After considering both Darkroom and Olivetti, I have settled on using writeroom mode for distraction free writing in Emacs from https://github.com/joostkremers/writeroom-mode.

    ;; Distraction free writing using writeroom-mode https://github.com/joostkremers/writeroom-mode
    (use-package writeroom-mode
      :ensure t)
    
    (with-eval-after-load 'writeroom-mode
      (define-key writeroom-mode-map (kbd "<C-M-left>") #'writeroom-decrease-width)
      (define-key writeroom-mode-map (kbd "<C-M-right>") #'writeroom-increase-width)
      (define-key writeroom-mode-map (kbd "<C-M-=>") #'writeroom-adjust-width))
    
    (advice-add 'text-scale-adjust :after
        #'visual-fill-column-adjust)
    
    (setq writeroom-mode-line '(" " global-mode-string))
    (setq writeroom-local-effects '(display-time-mode))
    
  3. Thesaurus

    emacs-powerthesaurus is a plug-in that integrates Emacs with the amazing powerthesaurus.org service for finding synonyms, antonyms, and related terms from https://github.com/SavchenkoValeriy/emacs-powerthesaurus

    (use-package powerthesaurus
      :ensure t)
    
  4. Search and replace

    ripgrep can be handled in a number of ways in Emacs, I already have consult-ripgrep working, in addition I am using https://github.com/Wilfred/deadgrep for a different interface.

    (use-package deadgrep
      :ensure t)
    
    (global-set-key (kbd "<f5>") #'deadgrep)
    

    recoll is a wonderful package that needs to be included for all things search.

    (use-package consult-recoll
      :ensure t
      :config
      (setq consult-recoll-inline-snippets t))
    

4.7.13. Using calculator

(use-package calc
  :defer t)

4.8. Completion modules

After trying multiple combinations of completion frameworks, I have started using a set orderless, corfu, consult, embark, marginalia, vertico as the current set of completions that would work for me23.

4.8.1. Saving minibuffer history

I'd like to save history of the mini-buffer too so that it becomes accessible. The file location is left to no-littering (data/savehist.el), and the kill-ring is deliberately not saved (see 3.5).

(use-package savehist
  :ensure nil
  :hook (after-init . savehist-mode)
  :config
  (setq history-length 100)
  (setq history-delete-duplicates t)
  (setq savehist-save-minibuffer-history t))

4.8.2. Dynamic abbreviation

Using the in-built package for text completion, dabbrev reads the contents of the buffer and then expands the text for possible matches.

(use-package dabbrev
  :ensure nil
  :commands (dabbrev-expand dabbrev-completion)
  :config
  (setq dabbrev-abbrev-char-regexp "\\sw\\|\\s_")
  (setq dabbrev-abbrev-skip-leading-regexp "[$*/=~']")
  (setq dabbrev-backward-only nil)
  (setq dabbrev-case-distinction 'case-replace)
  (setq dabbrev-case-fold-search nil)
  (setq dabbrev-case-replace 'case-replace)
  (setq dabbrev-check-other-buffers t)
  (setq dabbrev-eliminate-newlines t)
  (setq dabbrev-upcase-means-case-search t)
  (setq dabbrev-ignored-buffer-modes
        '(archive-mode image-mode doc-view-mode pdf-view-mode tags-table-mode)))

4.8.3. Abbreviations

Abbreviation mode from http://xahlee.info/emacs/emacs/emacs_abbrev_mode_tutorial.html, http://xahlee.info/emacs/emacs/emacs_hippie_expand_setup.html and https://www.masteringemacs.org/article/text-expansion-hippie-expand

(setq-default abbrev-mode t)

(setq save-abbrevs 'silently)

(bind-key "M-/" 'hippie-expand)

(remove-hook 'save-some-buffers-functions #'abbrev--possibly-save)

(setq hippie-expand-try-functions-list
    '(yas-hippie-try-expand
      try-expand-all-abbrevs
      try-complete-file-name-partially
      try-complete-file-name
      try-expand-dabbrev
      try-expand-dabbrev-from-kill
      try-expand-dabbrev-all-buffers
      try-expand-list
      try-expand-line
      try-complete-lisp-symbol-partially
      try-complete-lisp-symbol))

4.8.4. Minibuffer

Some customization of how the minibuffer is handled to enable the completion frameworks and it is based on https://protesilaos.com/emacs/dotemacs#h:de61a607-0bdf-462b-94cd-c0898319590e, the details for which has been helpfully provided by Prot.

(use-package minibuffer
  :ensure nil
  :config
  (setq completions-format 'one-column)
  (setq completion-auto-help 'always)
  (setq completion-auto-select t)
  (setq completions-detailed t)
  (setq completion-show-inline-help t)
  (setq completions-max-height 48)
  (setq completions-header-format (propertize "%s candidates:\n" 'face 'bold-italic))
  (setq completions-highlight-face 'completions-highlight)
  (setq minibuffer-completion-auto-choose t)
  ;; Completion styles are set in one place, in [[*Orderless]]
  (setq minibuffer-visible-completions t))
  1. Editing mini-buffer

    Sometimes you want to be able to do fancy things with the text that you're entering into the minibuffer. Sometimes you just want to be able to read it, especially when it comes to lots of text. This binds C-M-e in a minibuffer so that you can edit the contents of the minibuffer before submitting it.

    (use-package miniedit
      :ensure t
      :commands minibuffer-edit
      :init (miniedit-install))
    

4.8.5. Corfu

corfu provides in-buffer text-completion with a pop-up. It works well with vertico. The current candidates are shown in a pop-up below or above the point. The candidates can be selected by moving up and down. As recommended at https://github.com/minad/corfu, I also use cape (https://github.com/minad/cape) a.k.a "Completion At Point Extensions" in conjunction to enhance functionality

(use-package corfu
  :ensure t
  :custom
  (corfu-auto t)
  (corfu-cycle t)
  (corfu-preselect 'prompt)
  :config
  (setq corfu-popupinfo-delay (cons nil 1.0))
  (corfu-history-mode 1)
  (with-eval-after-load 'savehist
    (add-to-list 'savehist-additional-variables 'corfu-history))
  :bind
  (:map corfu-map
        ("TAB" . corfu-next)
        ([tab] . corfu-next)
        ("M-TAB" . corfu-previous)
        ([backtab] . corfu-previous))
  :init
  (global-corfu-mode)
  (corfu-popupinfo-mode))

(use-package cape
  :ensure t
  :bind (("C-c ip" . completion-at-point)
         ("C-c it" . complete-tag)
         ("C-c id" . cape-dabbrev)
         ("C-c ih" . cape-history)
         ("C-c if" . cape-file)
         ("C-c ik" . cape-keyword)
         ("C-c is" . cape-symbol)
         ("C-c ia" . cape-abbrev)
         ("C-c il" . cape-line)
         ("C-c iw" . cape-dict)
         ("C-c i\\" . cape-tex)
         ("C-c i_" . cape-tex)
         ("C-c i^" . cape-tex)
         ("C-c i&" . cape-sgml)
         ("C-c ir" . cape-rfc1345))
  :init
  (add-to-list 'completion-at-point-functions #'cape-dabbrev)
  (add-to-list 'completion-at-point-functions #'cape-abbrev)
  (add-to-list 'completion-at-point-functions #'cape-file)
  (add-to-list 'completion-at-point-functions #'cape-elisp-block)
  (add-to-list 'completion-at-point-functions #'cape-history)
  (add-to-list 'completion-at-point-functions #'cape-keyword)
  (add-to-list 'completion-at-point-functions #'cape-tex))

4.8.6. Consult

consult provides a way by which you can search, filter, preview and select entries based on the lists provided by completion-at-point. I have also included a few additional consult packages.

  • consult-yasnippet to help with expand yasnippet
  • Consult 3.x previews candidates in *Completions* automatically, and removed consult-preview-at-point-mode. I had it hooked to completion-list-mode and embark-collect-mode, which failed with "Autoloading file consult.elc failed to define function" whenever a *Completions* buffer appeared.

(use-package consult
  :ensure nil
  :bind
  (
   ("C-x M-:" . consult-complex-command)
   ("C-x b" . consult-buffer)
   ("C-x 4 b" . consult-buffer-other-window)
   ("C-x 5 b" . consult-buffer-other-frame)
   ("C-x r b" . consult-bookmark)
   ("C-x p b" . consult-project-buffer)
   ("M-y" . consult-yank-pop)
   ("M-s d" . consult-find)
   ("M-s D" . consult-locate)
   ("M-s g" . consult-grep)
   ("M-s G" . consult-git-grep)
   ("M-s r" . consult-ripgrep)
   ("M-s l" . consult-line)
   ("M-s L" . consult-line-multi)
   ("M-s k" . consult-keep-lines)
   ("M-s u" . consult-focus-lines)
   :map isearch-mode-map
   ("M-e" . consult-isearch-history)
   ("M-s e" . consult-isearch-history)
   ("M-s l" . consult-line)
   ("M-s L" . consult-line-multi)
   :map minibuffer-local-map
   ("M-s" . consult-history)
   ("M-r" . consult-history)
   :map consult-narrow-map
   ("?" . consult-narrow-help))
  :config
  (setq consult-line-numbers-widen t)
  (setq consult-async-min-input 3)
  (setq consult-async-input-debounce 0.5)
  (setq consult-async-input-throttle 0.8)
  (setq consult-narrow-key nil)
  (setq consult-find-args
        (concat "find . -not ( "
                "-path */.git* -prune "
                "-or -path */.cache* -prune )"))
  (setq consult-preview-key 'any)
  (setq consult-project-function nil)
  (setq consult-narrow-key "<")

  (add-to-list 'consult-mode-histories '(vc-git-log-edit-mode . log-edit-comment-ring))

  (require 'consult-imenu)

  (with-eval-after-load 'pulsar
    (setq consult-after-jump-hook nil)
    (dolist (fn '(pulsar-recenter-top pulsar-reveal-entry))
      (add-hook 'consult-after-jump-hook fn))))

(use-package consult-yasnippet
  :ensure t)

4.8.7. Embark

The best way to describe embark is to think of it as a contextual execution of actions. While I have the basic covered here, I will have to learn how to harness the embark to automate some tasks.

(use-package embark
  :ensure t
  :bind
  (("C-." . embark-act)
   ("C-;" . embark-dwim)
   ("C-h B" . embark-bindings))
  :config
  (setq prefix-help-command #'embark-prefix-help-command)
  (setq embark-confirm-act-all nil)
  (setq embark-mixed-indicator-both nil)
  (setq embark-mixed-indicator-delay 1.0)
  (setq embark-indicators '(embark-mixed-indicator embark-highlight-indicator))
  (setq embark-verbose-indicator-nested nil)
  (setq embark-verbose-indicator-buffer-sections '(bindings))
  (setq embark-verbose-indicator-excluded-actions
        '(embark-cycle embark-act-all embark-collect embark-export embark-insert)))

(use-package embark-consult
  :ensure t
  :after consult)

4.8.8. Vertico

Using vertico with orderless is a powerful combination. I am doing the following (based on the configuration provided in the sample),

  • Enabling "multiform" mode in order to get the desired manner for each type and similar to Ido
  • Using vertico-directory to navigate directories in a manner similar to Ido

(use-package vertico
  :ensure t
  :init
  :config
  (setq vertico-scroll-margin 0)
  (setq vertico-count 5)
  (setq vertico-resize t)
  (setq vertico-cycle t)
  (vertico-multiform-mode 1)
  (vertico-mode 1))

(use-package emacs
  :init
  (defun crm-indicator (args)
    (cons (format "[CRM%s] %s"
                  (replace-regexp-in-string
                   "\\`\\[.*?]\\*\\|\\[.*?]\\*\\'" ""
                   crm-separator)
                  (car args))
          (cdr args)))
  (advice-add #'completing-read-multiple :filter-args #'crm-indicator)
  (setq minibuffer-prompt-properties
        '(read-only t cursor-intangible t face minibuffer-prompt))
  (add-hook 'minibuffer-setup-hook #'cursor-intangible-mode)
  (setq enable-recursive-minibuffers t))

(use-package vertico-directory
  :after vertico
  :ensure nil
  :bind
  (:map vertico-map
        ("RET" . vertico-directory-enter)
        ("DEL" . vertico-directory-delete-char)
        ("M-DEL" . vertico-directory-delete-word))
  :hook
  (rfn-eshadow-update-overlay . vertico-directory-tidy))

(setq vertico-multiform-categories
      '((buffer flat (vertico-cycle . t))))

4.8.9. Orderless

orderless is a pattern matching package for parsing user input and then parsing it appropriately. This configuration for orderless is from https://github.com/minad/consult/wiki#minads-orderless-configuration, and the minibuffer completion removes ? and SPC to ensure it doesn't have an issue with other regexp constructs. This is the only place completion-styles and completion-category-overrides are set: the 4.8.4 section used to set them too, but this section ran later and replaced them, so those settings never took effect.

(use-package orderless
  :ensure t
  :config

  (defun +orderless--consult-suffix ()
    "Regexp which matches the end of string with Consult tofu support."
    (if (and (boundp 'consult--tofu-char) (boundp 'consult--tofu-range))
        (format "[%c-%c]*$"
                consult--tofu-char
                (+ consult--tofu-char consult--tofu-range -1))
      "$"))

  (defun +orderless-consult-dispatch (word _index _total)
    (cond
     ((string-suffix-p "$" word)
      `(orderless-regexp . ,(concat (substring word 0 -1) (+orderless--consult-suffix))))
     ((and (or minibuffer-completing-file-name
               (derived-mode-p 'eshell-mode))
           (string-match-p "\\`\\.." word))
      `(orderless-regexp . ,(concat "\\." (substring word 1) (+orderless--consult-suffix))))))

  (orderless-define-completion-style +orderless-with-initialism
    (orderless-matching-styles '(orderless-initialism orderless-literal orderless-regexp)))

  (setq completion-styles '(orderless basic)
        completion-category-defaults nil
        completion-category-overrides '((file (styles partial-completion))
                                        (command (styles +orderless-with-initialism))
                                        (variable (styles +orderless-with-initialism))
                                        (symbol (styles +orderless-with-initialism)))
        orderless-component-separator #'orderless-escapable-split-on-space
        orderless-style-dispatchers (list #'+orderless-consult-dispatch
                                          #'orderless-affix-dispatch))

  :bind ( :map minibuffer-local-completion-map
          ("SPC" . nil)
          ("?" . nil))
  )

4.8.10. Marginalia

Completion candidates are a great feature in Emacs, for people like me though, having annotations and notes next to the completion candidate is where it becomes useful. marginalia does this specific function of providing a brief description. I have also included nerd-icons-completion in conjunction to ensure better styling.

(use-package marginalia
  :ensure t
  :after compat
  :defer 1
  :bind (("M-A" . marginalia-cycle)
         :map minibuffer-local-map
         ("M-A" . marginalia-cycle))
  :config
  (setq marginalia-max-relative-age 0)
  (marginalia-mode t))

(use-package nerd-icons-completion
  :ensure t
  :after marginalia
  :config
  (nerd-icons-completion-mode t)
  (add-hook 'marginalia-mode-hook #'nerd-icons-completion-marginalia-setup))

4.9. Shell customization

A few modifications,

  • Paths come from the systemd unit and 3.4.1. I used to use exec-path-from-shell, but it only runs on macOS and X11 frames, never under pgtk or in the daemon, so it did nothing here
  • Eshell is customized to start from the starting of the page
  • Shell commands can now be executed using https://github.com/xenodium/dwim-shell-command

    (require 'eshell)
    (require 'em-smart)
    
    (setq eshell-where-to-jump 'begin)
    (setq eshell-review-quick-commands nil)
    (setq eshell-smart-space-goes-to-end t)
    
    (use-package dwim-shell-command
      :ensure t
      :bind (([remap shell-command] . dwim-shell-command)
             :map dired-mode-map
             ([remap dired-do-async-shell-command] . dwim-shell-command)
             ([remap dired-do-shell-command] . dwim-shell-command)
             ([remap dired-smart-shell-command] . dwim-shell-command)))
    

4.10. Reference management

Emacs and Zotero Integration from https://github.com/emacsmirror/zotxt https://github.com/egh/zotxt & https://github.com/egh/zotxt-emacs, requires the following within Zotero for this to work well

I no longer use zotxt (and zotfile does not work with Zotero 7). Zotero stays the place where references are managed: Better BibTeX automatically re-exports my library (and selected collections) as .bib files whenever the library changes (the files are listed in bmp/bibliography, see 3.3), and citar reads them (see 4.18.1):

  • C-c C-x @ in Org inserts a citation (Org's built-in [cite:@key], exported through org-cite), with completion from both bibliographies.
  • C-c b (citar-open) opens a reference's PDF (from the file field Better BibTeX exports, pointing into ~/Zotero/storage), its URL or DOI, or its note.
  • Notes on a reference are org-roam nodes in ~/Notes/References/, created and found through citar-org-roam, so they show up in the roam graph and backlinks.

4.11. PDF & EPUBs

Handling different formats such as PDFs and EPUBs for now

4.11.1. PDF Handling

Managing PDFs with PDFTools (https://pdftools.wiki & https://github.com/vedang/pdf-tools), and enable the view mode when a PDF is opened.

(use-package pdf-tools
    :ensure t
    :mode (("\\.pdf$" . pdf-view-mode))
    :hook ((pdf-view-mode . auto-revert-mode)
           (pdf-view-mode . (lambda () (cua-mode 0)))
           (pdf-view-mode . pdf-view-midnight-minor-mode)
           (pdf-view-mode . pdf-annot-minor-mode))
    :config
    ;; 1. Prevent pdf-tools from trying to use dnf on your immutable host
    (setq pdf-tools-install-build-options '("--no-error-if-failed"))

    ;; 2. Give Homebrew's poppler to epdfinfo only: its build (pkg-config)
    ;; and its runtime (LD_LIBRARY_PATH), without changing the environment
    ;; of anything else Emacs starts. When Homebrew updates poppler,
    ;; `pdf-tools-install' finds the new version and rebuilds epdfinfo.
    (defun bmp/with-brew-poppler (orig &rest args)
      "Call ORIG with ARGS, with Homebrew's poppler visible to epdfinfo."
      (let* ((opt "/home/linuxbrew/.linuxbrew/opt/")
             (pkgconfig (append (file-expand-wildcards (concat opt "*/lib/pkgconfig"))
                                (file-expand-wildcards (concat opt "*/share/pkgconfig"))))
             (process-environment
              (append (list "LD_LIBRARY_PATH=/home/linuxbrew/.linuxbrew/lib"
                            "PKG_CONFIG=/home/linuxbrew/.linuxbrew/bin/pkg-config"
                            (concat "PKG_CONFIG_PATH=" (string-join pkgconfig ":")))
                      process-environment)))
        (apply orig args)))
    (dolist (fn '(pdf-tools-install pdf-info-check-epdfinfo
                  pdf-info-process-assert-running))
      (advice-add fn :around #'bmp/with-brew-poppler))

    ;; 3. Preferences
    (setq pdf-info-epdfinfo-program
          (expand-file-name "elpaca/builds/pdf-tools/server/epdfinfo" user-emacs-directory))
    (setq-default pdf-view-display-size 'fit-width)
    (setq pdf-tools-install-no-query t)

    ;; 4. Initialize
    (pdf-tools-install t))

4.11.2. EPUBs

Handing Epubs from https://depp.brause.cc/nov.el/

(use-package nov
  :ensure t)

(add-to-list 'auto-mode-alist '("\\.epub\\'" . nov-mode))

;; Customize font
(defun my-nov-font-setup ()
  (face-remap-add-relative 'variable-pitch :family "EB Garamond"
                           :height 1.0))
(add-hook 'nov-mode-hook 'my-nov-font-setup)

;; Text width
(setq nov-text-width 80)
(setq visual-fill-column-center-text t)
(add-hook 'nov-mode-hook 'visual-line-mode)
(add-hook 'nov-mode-hook 'visual-fill-column-mode)

4.11.3. Calibre

Handling Calibre library from https://github.com/chenyanming/calibredb.el and assumes pdf-tools and nov.el are installed, and the handling is from: https://dindi.garjola.net/calibredb-view.html

(use-package calibredb
  :ensure t
  :defer t
  :config
  (setq calibredb-root-dir bmp/calibre-directory)
  (setq calibredb-db-dir (expand-file-name "metadata.db" calibredb-root-dir))
  (setq calibredb-library-alist (list (list bmp/calibre-directory)))
  (setq calibredb-id-width 4)
  (setq calibredb-comment-width 0)
  (setq calibredb-tag-width 0)
  (setq calibredb-size-show t)
  (setq calibredb-format-width 12)
  (setq calibredb-format-nerd-icons t))

(defun bmp/calibredb-open-file-with-emacs (&optional candidate)
  "Open file with Emacs.
Optional argument CANDIDATE is the selected item."
  (interactive "P")
  (unless candidate
    (setq candidate (car (calibredb-find-candidate-at-point))))
  (find-file (calibredb-get-file-path candidate t)))

4.12. LaTeX

Based on a couple of posts by Michael Neuper and Karthink,

  • Format LaTeX strings with calc
  • Use cdlatex to deal with AUCTeX and Emacs
  • Use pdf-tools to as the previewer and auto-update the view when the TeX file is modified/recompiled
(use-package auctex :defer t
  :ensure ( :pre-build (("./autogen.sh")
                        ("./configure"
                         "--without-texmf-dir"
                         "--with-packagelispdir=./"
                         "--with-packagedatadir=./")
                        ("make"))
            :build (:not elpaca--compile-info)
            :files ("*.el" "doc/*.info*" "etc" "images" "latex" "style")
            :version (lambda (_) (require 'auctex) AUCTeX-version))
  :mode (("\\.tex\\'" . LaTeX-mode)))


(use-package latex
  :ensure nil
  :hook ((LaTeX-mode . prettify-symbols-mode))
  :bind (:map LaTeX-mode-map
              ("C-S-e" . latex-math-from-calc))
  :config
  ;; Format math as a Latex string with Calc
  (defun latex-math-from-calc ()
    "Evaluate `calc' on the contents of line at point."
    (interactive)
    (cond ((region-active-p)
           (let* ((beg (region-beginning))
                  (end (region-end))
                  (string (buffer-substring-no-properties beg end)))
             (kill-region beg end)
             (insert (calc-eval `(,string calc-language latex
                              calc-prefer-frac t
                              calc-angle-mode rad)))))
          (t (let ((l (thing-at-point 'line)))
               (end-of-line 1) (kill-line 0)
               (insert (calc-eval `(,l
                            calc-language latex
                            calc-prefer-frac t
                            calc-angle-mode rad))))))))


(use-package preview-latex
  :ensure nil
  :after latex
  :hook ((LaTeX-mode . preview-larger-previews))
  :config
  (defun preview-larger-previews ()
    (setq preview-scale-function
          (lambda () (* 1.0
                        (funcall (preview-scale-from-face)))))))

(use-package cdlatex
  :ensure t
  :hook (LaTeX-mode . turn-on-cdlatex)
  :bind (:map cdlatex-mode-map
              ("<tab>" . cdlatex-tab)))

(add-hook 'LaTeX-mode-hook #'turn-on-cdlatex)
(add-hook 'latex-mode-hook #'turn-on-cdlatex)

(setq TeX-view-program-selection '((output-pdf "PDF Tools"))
      TeX-source-correlate-start-server t)

(add-hook 'TeX-after-compilation-finished-functions
          #'TeX-revert-document-buffer)

(setq TeX-PDF-mode t)

(setq TeX-engine 'xetex)

Minor mode to help with tables in LaTeX with latex-table-wizard

(use-package latex-table-wizard
  :ensure t
  :after transient)

Using math mode for LaTeX with the use of math-delimiters & latex-change-env

(use-package math-delimiters
  :ensure (:host github :repo "oantolin/math-delimiters"))

(autoload 'math-delimiters-insert "math-delimiters")

(with-eval-after-load 'org
  (define-key org-mode-map "$" #'math-delimiters-insert))

(with-eval-after-load 'tex              ; for AUCTeX
  (define-key TeX-mode-map "$" #'math-delimiters-insert))

(with-eval-after-load 'cdlatex
  (define-key cdlatex-mode-map "$" nil))

(use-package latex-change-env
  :ensure t
  :after latex
  :bind (:map LaTeX-mode-map ("C-c r" . latex-change-env))
  :config
  (setq latex-change-env-math-display math-delimiters-display)
  (setq latex-change-env-math-inline  math-delimiters-inline))

Enable latex-labeler tomake it easier to use labesl with LaTeX

(use-package latex-labeler
  :ensure t
  :config
  (setq LaTeX-equation-label nil)
  (setq latex-labeler-string-before-label "\n")
  (setq latex-labeler-label-with-indent t))

(add-hook 'LaTeX-mode-hook #'latex-labeler-mode)
(advice-add 'TeX-command-master :before #'latex-labeler-update)

Small quality of life improvement with latex-extre

(use-package latex-extra
  :ensure t)

(add-hook 'LaTeX-mode-hook #'latex-extra-mode)

4.13. DONE Typst

Typst is the latest open-source tool for typesetting and page layout24, and it seems to be fairly easier to typeset documents and handle them. Pandoc seems to be an intuitive way to convert between LaTeX documents and Typst documents25.

tinymist (from Homebrew, see 1.6) is the language server, started through eglot: completion, errors as I type, jump to definition and formatting. C-c C-p toggles typst-preview, a live preview in the browser (Firefox, through browse-url) that updates as I type and follows the cursor. This replaces typst-ts-mode-watch-options for day-to-day work; --open still applies when compiling with typst-ts-mode's own watch command.

(use-package typst-ts-mode
  :ensure (
           :host sourcehut
           :repo "meow_king/typst-ts-mode")
  :custom
  (typst-ts-mode-watch-options "--open")
  (typst-ts-mode-enable-raw-blocks-highlight t)
  :config
  ;; tinymist (installed with Homebrew) is Typst's language server:
  ;; completion, errors as you type, jump to definition, formatting
  (with-eval-after-load 'eglot
    (add-to-list 'eglot-server-programs '(typst-ts-mode . ("tinymist"))))
  (add-hook 'typst-ts-mode-hook #'eglot-ensure))

;; Live preview in the browser, scrolled to follow the cursor
(use-package typst-preview
  :ensure (:host github :repo "havarddj/typst-preview.el")
  :after typst-ts-mode
  :bind (:map typst-ts-mode-map
              ("C-c C-p" . typst-preview-mode)))

4.14. Handling audio & video

Use MPV through Emacs to handle audio and video.

(use-package mpv
  :ensure t)

4.14.1. Watching Emacs videos

Watching videos using the Emacs.tv package.

(use-package emacstv
  :ensure (:host github :repo "emacstv/emacstv.github.io" :files (:defaults "*.org")))

This script checks if mpv config folder exists

# Create ~/.config/mpv folder
if [ -d ~/.config/mpv ]
then
    echo "MPV folder exists"
else
    echo "MPV folder does not exist"
    mkdir -p ~/.config/mpv
fi

Add the MPV configuration for subtitles

ytdl-raw-options='ignore-config=,sub-langs="en,en-US",write-subs=,write-auto-subs='
sub-auto=fuzzy
slang=en,eng,enUS,en-US

4.15. Programming

This will be expanded over a period of time and need to explore eglot and tree-sitter to check which one works better for me.

4.15.1. Eglot

Emacs Polyglot is mostly preconfigured, here is an elementary configuration. In order to avoid a warning about eldoc I have added the latest version from https://github.com/progfolio/elpaca/issues/236

  • Added bash-language-server
  • Added clangd llvm
  • Added gopls
  • Eglot is only started for the modes listed below (and their tree-sitter -ts-mode versions, which treesit-auto switches to). It used to be hooked to every prog-mode buffer, so it tried to start a language server in Emacs Lisp and configuration files too.
(use-package eglot
    :ensure nil
    :after jsonrpc
    :hook
    ((sh-mode bash-ts-mode
      c-mode c-ts-mode c++-mode c++-ts-mode
      go-mode go-ts-mode
      rust-mode rust-ts-mode
      html-mode html-ts-mode
      css-mode css-ts-mode
      python-mode python-ts-mode) . eglot-ensure)
    :bind (:map
           eglot-mode-map
           ("C-c c a" . eglot-code-actions)
           ("C-c c o" . eglot-code-actions-organize-imports)
           ("C-c c r" . eglot-rename)
           ("C-c c f" . eglot-format)
           ("C-c c d" . eldoc))
    :config
    (setq eglot-stay-out-of '(flymake))
    (setq-default eglot-workspace-configuration
                  '((:gopls .
                            ((staticcheck . t)
                             (matcher . "CaseSensitive")))))
    )

4.15.2. DONE Syntax checking

I'm using the default built-in flymake for checking syntax and enabling vale integration with it. bmp/flymake-vale-setup turns vale on by itself in Org, Markdown and plain-text files (no more enabling flymake-mode and flymake-vale-maybe-load by hand), as long as vale and its configuration from 1.6 are installed. Vale works out the format from the file's extension, which is why only buffers visiting a file are checked. Blog posts (with a #+hugo_base_dir: line) are checked with the publish configuration, which adds readability scores; everything else with the notes configuration. M-x bmp/vale-toggle-config switches the current buffer between the two and re-checks it. Vale cannot read lines longer than about 64 KB (notes with embedded HTML charts have lines of hundreds of kilobytes), so it is not started for files with lines over bmp/vale-max-line-length; if it still fails, its error is shown as a message instead of breaking flymake. C-c x l lists a buffer's diagnostics, and C-c x n / C-c x p move between them (not C-c !, which Org uses to insert a timestamp).

(use-package flymake
  :ensure t
  :hook (prog-mode . flymake-mode)
  :custom
  ;; The left fringe is diff-hl's (see [[*Diff-hl]])
  (flymake-fringe-indicator-position 'right-fringe)
  :bind (:map flymake-mode-map
              ;; Not C-c !, which Org uses to insert a timestamp
              ("C-c x n" . flymake-goto-next-error)
              ("C-c x p" . flymake-goto-prev-error)
              ;; flymake 1.4 starts this list from the diagnostic at point
              ;; and fails elsewhere; consult-flymake lists them all
              ("C-c x l" . consult-flymake)))

(use-package flymake-vale
  :ensure (
           :host github
           :repo "tpeacock19/flymake-vale"
           ))

(defvar bmp/vale-configs
  `((notes . ,(expand-file-name "~/.config/vale/.vale.ini"))
    (publish . ,(expand-file-name "~/.config/vale/publish.ini")))
  "Vale configurations: everyday notes, and writing to be published.")

(defvar-local bmp/vale-config nil
  "The vale configuration this buffer is checked with (notes or publish).")

(defun bmp/vale-use-config (name)
  "Check the current buffer with the vale configuration NAME."
  (setq bmp/vale-config name)
  (setq-local flymake-vale-program-args
              (list (concat "--config=" (alist-get name bmp/vale-configs)))))

(defun bmp/vale--publishing-p ()
  "Return non-nil if this buffer is a blog post (it has #+hugo_base_dir:)."
  (save-excursion
    (goto-char (point-min))
    (let ((case-fold-search t))
      (re-search-forward "^#\\+hugo_base_dir:" 4000 t))))

(defvar bmp/vale-max-line-length 60000
  "Do not run vale on files with longer lines; it cannot read them.")

(defun bmp/vale--long-line-p ()
  "Return non-nil if the buffer has a line longer than vale can read."
  (save-excursion
    (goto-char (point-min))
    (let (long)
      (while (and (not long) (not (eobp)))
        (setq long (> (- (line-end-position) (point)) bmp/vale-max-line-length))
        (forward-line 1))
      long)))

(defun bmp/flymake-vale--safe-output (orig output source start)
  "Parse vale's OUTPUT with ORIG; if vale reported an error, say so instead."
  (condition-case nil
      (funcall orig output source start)
    (error
     (message "vale could not check %s: %s" (buffer-name source)
              (if (string-match "\"Text\": *\"\\([^\"]*\\)\"" output)
                  (match-string 1 output)
                "unexpected output"))
     (with-current-buffer source (flymake-vale--check-all nil start)))))

(defun bmp/flymake-vale--existing-directory (orig &rest args)
  "Run vale (ORIG with ARGS) from a folder that exists.
A new note's folder is only created when the note is first saved, and
a process cannot start in a folder that does not exist yet."
  (let ((default-directory (if (file-directory-p default-directory)
                               default-directory
                             (expand-file-name "~/"))))
    (apply orig args)))

(with-eval-after-load 'flymake-vale
  (advice-add 'flymake-vale--output-to-errors :around #'bmp/flymake-vale--safe-output)
  (advice-add 'flymake-vale--start :around #'bmp/flymake-vale--existing-directory))

(defun bmp/flymake-vale-setup ()
  "Check prose with vale through flymake in Org, Markdown and text files.
Blog posts use the publish configuration, everything else the notes one.
Only when vale and its configuration are installed (see Prerequisites)."
  (when (and buffer-file-name
             (or (derived-mode-p 'org-mode 'markdown-mode)
                 (eq major-mode 'text-mode))
             (executable-find "vale")
             (file-exists-p (alist-get 'notes bmp/vale-configs))
             (not (bmp/vale--long-line-p)))
    (bmp/vale-use-config (if (bmp/vale--publishing-p) 'publish 'notes))
    (flymake-vale-load)
    (flymake-mode 1)))

(defun bmp/vale-toggle-config ()
  "Switch this buffer between the notes and publish vale configurations."
  (interactive)
  (bmp/vale-use-config (if (eq bmp/vale-config 'publish) 'notes 'publish))
  (when flymake-mode (flymake-start))
  (message "Vale: checking with the %s configuration" bmp/vale-config))

(add-hook 'text-mode-hook #'bmp/flymake-vale-setup)

4.15.3. Adding documentation

Enable eldoc across all Emacs with the latest version from https://github.com/progfolio/elpaca/issues/236#issuecomment-1879838229, has been moved to the elpaca declaration.

4.15.4. TODO Parsing programming languages

Using the built-in tree-sitter for syntax and parsing, and treesit-fold for code folding. ts-fold was written for the older external tree-sitter package (its indicators hooked tree-sitter-after-on-hook, which the built-in tree-sitter never runs) and also loaded that package's tsc-dyn module at start-up. treesit-fold is its successor for the built-in tree-sitter, and its fringe indicators turn on in any buffer with a tree-sitter parser.

(use-package treesit-auto
  :ensure t
  :custom
  (treesit-auto-install 'prompt)
  :config
  (treesit-auto-add-to-auto-mode-alist 'all)
  (global-treesit-auto-mode))

(use-package treesit-fold
  :ensure t
  :custom
  ;; The left fringe is diff-hl's (see [[*Diff-hl]])
  (treesit-fold-indicators-fringe 'right-fringe)
  :config
  (global-treesit-fold-indicators-mode 1))

4.15.5. Languages & Frameworks

  1. Emacs ESS

    Using Emacs with R using https://ess.r-project.org/Manual/ess.html

    (use-package ess
      :ensure t
      :config
      (setf (cdr (assoc 'ess-indent-with-fancy-comments ess-own-style-list)) nil)
      (setq ess-style 'OWN)
      (setq ess-indent-offset 4)
    
      ;; auto-width
      (setq ess-auto-width 'window)
    
      ;; Stop R repl eval from blocking emacs.
      (setq ess-eval-visibly 'nowait)
    
      ;; Syntax highlighting
      (setq ess-R-font-lock-keywords
            '((ess-R-fl-keyword:keywords . t)
              (ess-R-fl-keyword:constants . t)
              (ess-R-fl-keyword:modifiers . t)
              (ess-R-fl-keyword:fun-defs . t)
              (ess-R-fl-keyword:assign-ops . t)
              (ess-R-fl-keyword:%op% . t)
              (ess-fl-keyword:fun-calls . t)
              (ess-fl-keyword:numbers . t)
              (ess-fl-keyword:operators . t)
              (ess-fl-keyword:delimiters . t)
              (ess-fl-keyword:= . t)
              (ess-R-fl-keyword:F&T . t))))
    
  2. Python

    I used elpy for Python, but it is no longer actively maintained and Emacs now covers the same ground with built-in pieces:

    • python-ts-mode (chosen automatically by treesit-auto) for editing
    • eglot with basedpyright for completion, documentation and jumping to definitions (see 4.15.1)
    • flymake with ruff for checking. Eglot is told to stay out of flymake, so ruff's diagnostics are the ones shown.

    Python code blocks in Org run in a dedicated virtualenv, ~/.local/share/emacs-python, which holds the packages my notes need (matplotlib, pandas, geopandas and so on). Setting org-babel-python-command means blocks no longer need a :python header. The virtualenv is built on Homebrew's python3, and I let Homebrew manage upgrades. When Homebrew moves to a new Python minor version (3.15 to 3.16, say), the virtualenv stops finding its packages, so bmp/python-venv-check compares the two versions at start-up and puts a warning in *Warnings* when they differ. The block below rebuilds the virtualenv when that happens.

    (when (executable-find "ruff")
      (setq python-flymake-command
            '("ruff" "check" "--quiet" "--output-format=concise"
              "--stdin-filename=stdin" "-")))
    
    (defvar bmp/python-venv (expand-file-name "~/.local/share/emacs-python/")
      "Virtualenv used to run Python code blocks in Org.")
    
    (setq org-babel-python-command
          (expand-file-name "bin/python" bmp/python-venv))
    
    (defun bmp/python-venv-check ()
      "Warn if Homebrew's python3 is a different version from `bmp/python-venv'."
      (let* ((brew (file-truename "/home/linuxbrew/.linuxbrew/bin/python3"))
             (brew-version (and (string-match "python\\([0-9]+\\.[0-9]+\\)\\'" brew)
                                (match-string 1 brew)))
             (lib (car (file-expand-wildcards
                        (expand-file-name "lib/python3.*" bmp/python-venv))))
             (venv-version (and lib (string-remove-prefix
                                     "python" (file-name-nondirectory lib)))))
        (when (and brew-version venv-version
                   (not (string= brew-version venv-version)))
          (display-warning
           'python
           (format "%s was built for Python %s, but Homebrew's python3 is now %s; run the rebuild block in the Python section of forgemacs.org"
                   bmp/python-venv venv-version brew-version)))))
    
    (add-hook 'after-init-hook #'bmp/python-venv-check)
    

    Rebuild the virtualenv on Homebrew's current Python (run with C-c C-c, or paste into a terminal). Add packages to the second pip install line as my notes need them.

    /home/linuxbrew/.linuxbrew/bin/python3 -m venv --clear ~/.local/share/emacs-python
    ~/.local/share/emacs-python/bin/pip install --upgrade pip
    ~/.local/share/emacs-python/bin/pip install matplotlib numpy pandas geopandas \
        folium plotly requests cloudscraper pyproj shapely pyogrio
    
  3. GO

    Go files open in the built-in go-ts-mode (chosen by treesit-auto), with gopls through eglot for completion, documentation (C-c c d), renaming (C-c c r), organising imports (C-c c o) and formatting (C-c c f). This replaces go-mode, whose godef commands depend on a tool that is no longer maintained. Go files are formatted (gofmt) when saved.

      (defun bmp/eglot-format-on-save ()
        "Format the buffer through its language server before saving.
    Does nothing when eglot is not managing the buffer, so a missing
    server never stops a file from being saved."
        (add-hook 'before-save-hook
                  (lambda () (when (eglot-managed-p) (eglot-format-buffer)))
                  nil t))
    
      (add-hook 'go-ts-mode-hook #'bmp/eglot-format-on-save)
    
  4. Rust

    Rust files open in the built-in rust-ts-mode (chosen by treesit-auto), with rust-analyzer through eglot. Files are formatted (rustfmt, through rust-analyzer) when saved, which replaces rust-format-on-save from rust-mode.

    (add-hook 'rust-ts-mode-hook #'bmp/eglot-format-on-save)
    (add-hook 'rust-ts-mode-hook #'prettify-symbols-mode)
    
  5. Astro

    Astro mode for tree-sitter

    ;; (use-package astro-ts-mode
    ;;   :ensure t
    ;;   :config
    ;;   (global-treesit-auto-mode))
    

4.16. AI assistants

Two complementary tools for working with Claude inside Emacs:

  • gptel for in-buffer work, especially Org files (rewriting regions, chats that live in Org buffers). Billed against the Claude API key in .authinfo.gpg.
  • agent-shell for agentic coding and larger multi-file tasks, talking to Claude Code over the Agent Client Protocol (ACP). Uses the Claude subscription login, not the API key.

What leaves the machine:

  • gptel-send sends the region, or the buffer up to point, to Anthropic. gptel-rewrite sends the region. Anything added with gptel-add (files, buffers) is sent with every request until removed. Be careful in ~/Notes and anything with personal details.
  • agent-shell runs Claude Code in the project directory. It can read any file the user can, so the deny rules below keep it away from secrets.
  • agent-shell keeps transcripts and screenshots, which can contain whatever was pasted into a session. They are stored under data/agent-shell/ (see below) instead of a .agent-shell/ folder inside each repository, so they never end up in a commit.

4.16.1. Prerequisites (run once in a terminal)

Node and Claude Code (the claude-code cask) both come from Homebrew, so their binaries live in /home/linuxbrew/.linuxbrew/bin, which 3.4.1 already puts on the daemon's PATH. The ACP adapter must be installed with Homebrew's npm (not a mise-managed one) so it lands in the same directory. Run claude once and log in with the Claude subscription, then /exit. Do not export ANTHROPIC_API_KEY anywhere in the shell or systemd environment, otherwise Claude Code silently switches to API billing.

claude        # log in with the Claude subscription, then /exit
/home/linuxbrew/.linuxbrew/bin/npm install -g @agentclientprotocol/claude-agent-acp
which claude claude-agent-acp   # both should be under /home/linuxbrew/.linuxbrew/bin

Keep Claude Code updated with brew upgrade --cask claude-code, and the adapter with the same npm install -g command.

Add the API key to ~/.config/emacs/secrets/.authinfo.gpg (open it in Emacs, it is decrypted and re-encrypted transparently):

machine api.anthropic.com login apikey password sk-ant-XXXXXXXX

Keep Claude Code away from secrets and private history with deny rules in ~/.claude/settings.json. These apply to every Claude Code session, whether started from agent-shell or a terminal:

{
  "permissions": {
    "deny": [
      "Read(~/.config/emacs/secrets/**)",
      "Read(~/.gnupg/**)",
      "Read(**/.authinfo*)",
      "Read(~/.config/emacs/data/savehist.el)",
      "Read(~/.config/emacs/data/mastodon-client--token)",
      "Read(~/.config/emacs/data/ement*)",
      "Read(~/.config/emacs/data/agent-shell/**)"
    ]
  }
}

4.16.2. gptel

  • Backend is Anthropic, with the key read through bmp/lookup-password from 3.6. The key is fetched only when a request is made and is never stored in a variable.
  • New chat buffers open in Org mode.
  • gptel-org-branching-context makes each Org heading branch its own conversation, so one Org file can hold several independent threads.
(use-package gptel
  :ensure t
  :commands (gptel gptel-send gptel-menu gptel-rewrite gptel-mode)
  :config
  (setq gptel-backend
        (gptel-make-anthropic "Claude"
          :stream t
          :key (lambda () (bmp/lookup-password :host "api.anthropic.com"))
          :models '(claude-sonnet-5-5 claude-opus-5-5 claude-haiku-5-5))
        gptel-model 'claude-sonnet-5-5
        gptel-default-mode 'org-mode
        gptel-org-branching-context t))

4.16.3. agent-shell

  • Uses the subscription login (:login t). With this, agent-shell sets ANTHROPIC_API_KEY to an empty string for the agent process, so an exported key can never switch it to API billing.
  • The agent process always inherits the daemon's environment (acp.el appends process-environment), so it finds node, claude and claude-agent-acp on the Homebrew PATH without any extra setting. I used to pass :inherit-env t, but that only added a second copy of the same environment.
  • Transcripts and screenshots go to data/agent-shell/<project>/ through agent-shell-dot-subdir-function, not to .agent-shell/ in the project root.
  • Claude edits files on disk, so buffers should pick up its changes automatically.
(defun bmp/agent-shell-dot-subdir (subdir)
  "Return a directory for agent-shell SUBDIR outside the current project."
  (expand-file-name
   (file-name-concat "agent-shell"
                     (file-name-nondirectory
                      (directory-file-name (agent-shell-cwd)))
                     subdir)
   no-littering-var-directory))

(use-package agent-shell
  :ensure t
  :commands (agent-shell agent-shell-toggle agent-shell-send-dwim
             agent-shell-send-region agent-shell-send-current-file)
  :config
  (setq agent-shell-anthropic-authentication
        (agent-shell-anthropic-make-authentication :login t)
        agent-shell-dot-subdir-function #'bmp/agent-shell-dot-subdir
        agent-shell-preferred-agent-config
        (agent-shell-anthropic-make-claude-code-config)))

;; Rely on file-change notifications instead of checking every buffer
;; every few seconds, which showed up as 7% of the time in a profile
(setq auto-revert-avoid-polling t)
(global-auto-revert-mode 1)

4.16.4. Key bindings

Everything lives under C-c j, which is free in this configuration (C-c RET and C-c l would clash with Org conventions).

(defvar-keymap bmp/ai-map
  :doc "Keymap for AI assistants."
  "c" #'gptel                         ; open a gptel chat buffer
  "s" #'gptel-send                    ; send region / buffer up to point
  "m" #'gptel-menu                    ; model, context and options
  "r" #'gptel-rewrite                 ; rewrite the active region in place
  "a" #'agent-shell                   ; start or reuse a Claude Code session
  "t" #'agent-shell-toggle            ; show / hide the session
  "d" #'agent-shell-send-dwim         ; send region or error at point
  "f" #'agent-shell-send-current-file); send the current file

(keymap-global-set "C-c j" bmp/ai-map)

4.17. Social Networking

I use Mastodon, Matrix and IRC through Emacs

4.17.1. Mastodon

Mastodon mode from https://codeberg.org/martianh/mastodon.el with the following fixes,

  • GPG/PLStore issue is fixed for crashing
  • Standard emoji are drawn natively by Emacs, so emojify is no longer used (mastodon-use-emojify is nil)
(setq plstore-cache-passphrase-for-symmetric-encryption t)

(advice-add 'plstore--decrypt :around
            (lambda (orig-fun &rest args)
              (let ((epg-pinentry-mode 'loopback))
                (apply orig-fun args))))

(use-package mastodon
  :ensure t
  :commands (mastodon)
  :init
  ;; My instance and username are personal (see [[*Personal and local settings]])
  (add-hook 'bmp/personal-loaded-hook
            (lambda ()
              (setq mastodon-instance-url bmp/mastodon-instance-url
                    mastodon-active-user bmp/mastodon-user)))
  ;; Keep token in a writable data path
  (setq mastodon-client--token-file
        (expand-file-name "data/mastodon-client--token" user-emacs-directory))

  :config
  ;; Standardize variables (Using a single setq to avoid void-function errors)
  (setq mastodon-toot-timestamp-format "%c"
        mastodon-toot--enable-completion-for-mentions "all"
        mastodon-toot--enable-custom-instance-emoji nil
        mastodon-tl--enable-relative-timestamps nil
        mastodon-tl--show-avatars t
        mastodon-media--enable-image-caching t
        mastodon-media--avatar-height 30
        mastodon-media--preview-max-height 200
        mastodon-tl--enable-proportional-font t
        mastodon-toot-enable-completion t
        mastodon-tl--highlight-current-toot t
        mastodon-images-in-notifs t
        mastodon-toot-display-orig-in-reply-buffer t
        mastodon-use-emojify nil)

  (mastodon-discover)

  ;; 1. Prevent crash on notification 404 errors
  (advice-add 'mastodon-notifications--update-check-cb :around
              (lambda (orig-fun json)
                (unless (and (listp json) (assoc 'error json))
                  (funcall orig-fun json))))

  ;; 2. Auto-fill handling
  (add-hook 'mastodon-toot-mode-hook (lambda () (auto-fill-mode -1))))

  1. DONE Show per-instance custom emoji in toots

    Custom emoji (:blobcat: and similar) belong to an instance: two instances can use the same shortcode for different images, and a post shows the emoji of the instance it was written on. mastodon.el can only show them through emojify, and only my own instance's, all in one folder, so emoji in posts from other instances never displayed correctly.

    Every post the server sends already lists its own custom emoji (shortcode and image address on the post's home instance), for the post, a boosted post and their authors' names. bmp/mastodon-emoji-apply wraps mastodon-tl--insert-status: after a post is inserted, it shows each :shortcode: in that post as the matching image, scaled to the line height. Images are downloaded in the background the first time and cached in ~/.config/emacs/data/mastodon-emoji/<instance>/, one folder per instance, so the same shortcode from different instances never collides. The instance is taken from the author's account (user@instance), not from the image address: my home instance serves its own cached copy of every other instance's emoji, so all addresses point at it. Only shortcodes and addresses made of safe characters are used for file names.

    (defvar bmp/mastodon-emoji-dir
      (expand-file-name "mastodon-emoji/" no-littering-var-directory)
      "Custom emoji images, cached in one folder per instance.")
    
    (defun bmp/mastodon-emoji--instance (account)
      "Return the instance ACCOUNT belongs to.
    The image addresses cannot be used for this: my home instance serves its
    own cached copy of every other instance's emoji."
      (let ((acct (or (alist-get 'acct account) "")))
        (if (string-match "@\\(.+\\)\\'" acct)
            (match-string 1 acct)
          (url-host (url-generic-parse-url mastodon-instance-url)))))
    
    (defun bmp/mastodon-emoji--list (toot)
      "Return (INSTANCE . EMOJI) for each custom emoji in TOOT.
    A post's emoji, and its author's, belong to the author's instance; the
    same applies to a boosted post."
      (let ((reblog (alist-get 'reblog toot)))
        (mapcan (lambda (post)
                  ;; consp: no boost may be nil or :null, depending on the parser
                  (when (consp post)
                    (let ((instance (bmp/mastodon-emoji--instance
                                     (alist-get 'account post))))
                      (mapcar (lambda (emoji) (cons instance emoji))
                              (append (alist-get 'emojis post)
                                      (alist-get 'emojis (alist-get 'account post))
                                      nil)))))
                (list toot reblog))))
    
    (defun bmp/mastodon-emoji--url (emoji)
      "Return the image address of EMOJI (the still image when there is one)."
      (or (alist-get 'static_url emoji) (alist-get 'url emoji)))
    
    (defun bmp/mastodon-emoji--file (instance emoji)
      "Return the cache file for EMOJI of INSTANCE, or nil if the data looks unsafe."
      (let* ((shortcode (alist-get 'shortcode emoji))
             (url (bmp/mastodon-emoji--url emoji))
             (ext (and url (file-name-extension (car (split-string url "?"))))))
        (when (and shortcode instance ext
                   (string-match-p "\\`[A-Za-z0-9_-]+\\'" shortcode)
                   (string-match-p "\\`[A-Za-z0-9.-]+\\'" instance)
                   (string-match-p "\\`[A-Za-z]+\\'" ext))
          (expand-file-name (format "%s/%s.%s" instance shortcode (downcase ext))
                            bmp/mastodon-emoji-dir))))
    
    (defun bmp/mastodon-emoji--show (shortcode file beg end)
      "Show :SHORTCODE: between markers BEG and END as the image in FILE."
      (when (and (marker-buffer beg) (file-exists-p file))
        (with-current-buffer (marker-buffer beg)
          (let ((inhibit-read-only t)
                (image (create-image file nil nil :ascent 'center
                                     :height (default-font-height))))
            (save-excursion
              (goto-char beg)
              (while (search-forward (concat ":" shortcode ":") end t)
                (put-text-property (match-beginning 0) (match-end 0)
                                   'display image)))))))
    
    (defun bmp/mastodon-emoji--fetch (url file callback)
      "Download URL to FILE in the background, then call CALLBACK."
      (url-retrieve
       url
       (lambda (status)
         (unless (plist-get status :error)
           (goto-char (point-min))
           (when (re-search-forward "\r?\n\r?\n" nil t)
             (make-directory (file-name-directory file) t)
             (let ((coding-system-for-write 'binary))
               (write-region (point) (point-max) file nil 'silent))
             (funcall callback)))
         (kill-buffer))
       nil t t))
    
    (defun bmp/mastodon-emoji-apply (orig toot &rest args)
      "Insert TOOT with ORIG, then show its custom emoji as images."
      (let ((beg (point-marker)))
        (prog1 (apply orig toot args)
          (let ((end (point-marker)))
            (pcase-dolist (`(,instance . ,emoji) (bmp/mastodon-emoji--list toot))
              (let ((shortcode (alist-get 'shortcode emoji))
                    (file (bmp/mastodon-emoji--file instance emoji)))
                (when file
                  (if (file-exists-p file)
                      (bmp/mastodon-emoji--show shortcode file beg end)
                    (bmp/mastodon-emoji--fetch
                     (bmp/mastodon-emoji--url emoji) file
                     (lambda ()
                       (bmp/mastodon-emoji--show shortcode file beg end)))))))))))
    
    (with-eval-after-load 'mastodon-tl
      (advice-add 'mastodon-tl--insert-status :around #'bmp/mastodon-emoji-apply))
    
    (defun bmp/mastodon-emoji-report ()
      "Report cached custom emoji per instance, and those still missing here.
    Missing means a :shortcode: in the current buffer that shows as text."
      (interactive)
      (let ((instances
             (when (file-directory-p bmp/mastodon-emoji-dir)
               (mapcar (lambda (dir)
                         (cons (file-name-nondirectory dir)
                               (length (directory-files dir nil "\\`[^.]"))))
                       (seq-filter #'file-directory-p
                                   (directory-files bmp/mastodon-emoji-dir t "\\`[^.]")))))
            (missing nil))
        (save-excursion
          (goto-char (point-min))
          (while (re-search-forward ":[A-Za-z0-9_-]+:" nil t)
            (unless (get-text-property (match-beginning 0) 'display)
              (push (match-string-no-properties 0) missing))))
        (with-current-buffer (get-buffer-create "*Mastodon emoji*")
          (let ((inhibit-read-only t))
            (erase-buffer)
            (insert "Cached custom emoji, per instance:\n")
            (dolist (instance instances)
              (insert (format "  %-40s %d\n" (car instance) (cdr instance))))
            (insert (format "\nShown as text in the buffer you came from (%d):\n"
                            (length (delete-dups missing))))
            (dolist (shortcode missing)
              (insert "  " shortcode "\n")))
          (special-mode)
          (display-buffer (current-buffer)))))
    

4.17.2. Matrix

Connecting to Matrix using https://github.com/alphapapa/ement.el

(setq ement-save-sessions t
      ement-directory-item-indent 4
      ement-room-left-margin-width 20
      ement-room-timestamp-header-with-date-format "%A : %d-%m-%Y"
      ement-room-image-initial-height 0.4
      ement-room-list-column-Name-max-width 32
      ement-room-list-column-Topic-max-width 42)

;; Install Ement.
(use-package ement
  :ensure t)

4.17.3. IRC

Since erc had issues with respect to passwords, I had decided to switch to circe from https://github.com/emacs-circe/circe. But I am now back on erc after figuring out how to use passwords with it.


(use-package erc
  :ensure t
  :config
  (setq erc-interpret-mirc-color t
        erc-kill-buffer-on-part t
        erc-kill-queries-on-quit t
        erc-kill-server-buffer-on-quit t
        erc-query-display 'buffer
        erc-server-coding-system '(utf-8 . utf-8)
        erc-use-auth-source-for-nickserv-password t
        erc-prompt-for-password nil
        erc-track-remove-disconnected-buffers t
        erc-track-exclude-types '("JOIN" "NICK" "PART" "QUIT" "MODE"
                                  "324" "329" "332" "333" "353" "477")
        erc-save-buffer-on-part t)

  (erc-track-mode 1)
  (erc-truncate-mode +1)
  (erc-ring-mode 1)
  (erc-fill-mode 1)
  (erc-scrolltobottom-mode 1))

;; My networks and channels are personal (see [[*Personal and local settings]])
(add-hook 'bmp/personal-loaded-hook
          (lambda ()
            (setq erc-autojoin-channels-alist bmp/irc-channels
                  erc-networks-alist bmp/irc-networks)))

(defun bmp/erc-clean-up ()
  "Clean up dead ERC buffers."
  (interactive)
  (mapc #'kill-buffer (erc-buffer-list (lambda () (null (erc-server-process-alive)))))
  (erc-update-mode-line))

;; If my nick is still held (a stale connection, or another client), ask
;; NickServ to hand it back with REGAIN (supported by Libera.Chat)
(setq erc-services-regain-alist
      '((Libera.Chat . erc-services-issue-regain)))

(defun bmp/erc-connect-with-auth (network host)
  "Connect to HOST with TLS, logging in with SASL from authinfo.
The authinfo line for HOST needs a login (my account and nick) and a
password.  SASL logs in to the account while connecting, whatever nick
the server gives me first, and `services-regain' then takes the nick back
if it is in use."
  (let* ((auth (bmp/lookup-auth :host host))
         (user (plist-get auth :user))
         (port (or (plist-get auth :port) 6697))
         (secret (plist-get auth :secret))
         (password (if (functionp secret) (funcall secret) secret)))
    (if (and user password)
        (let* ((erc-modules (append '(sasl services-regain) erc-modules))
               (buffer (progn
                         (message "Connecting to %s@%s (SASL)" user host)
                         (erc-tls :server host
                                  :port (string-to-number (format "%s" port))
                                  :nick user
                                  :user user
                                  :password password))))
          ;; On these SASL connections ERC's own autojoin did not join the
          ;; channels, although running it afterwards did; so run it once
          ;; the connection is complete (it skips channels already joined)
          (when (buffer-live-p buffer)
            (with-current-buffer buffer
              (add-hook 'erc-after-connect
                        (lambda (&rest _) (erc-autojoin--join))
                        90 t))))
      (message "❌ authinfo needs a login and password for %s — aborting connect"
               host))))

(defun bmp/erc-connect-anon (network host)
  "Connect to HOST using a nick from authinfo, without password."
  (let* ((auth (car (auth-source-search :host host :require '(:user))))
         (user (plist-get auth :user))
         (port (or (plist-get auth :port) 6697))
         (nick (or user "guest")))
    (message "Connecting anonymously to %s@%s" nick host)
    (erc-tls :server host
             :port (string-to-number (format "%s" port))
             :nick nick)))

(defun bmp/erc-connect-network (network host)
  (if (memq network bmp/irc-password-networks)
      (bmp/erc-connect-with-auth network host)
    (bmp/erc-connect-anon network host)))

(defun bmp/erc-connect-all ()
  "Manually connect to all ERC networks."
  (interactive)
  (dolist (entry erc-autojoin-channels-alist)
    (let* ((network (car entry))
           (host (cadr (assoc network erc-networks-alist))))
      (when host
        (bmp/erc-connect-network network host)))))

(defun bmp/erc-connect-all-with-refresh ()
  "Flush auth cache and connect to all ERC networks."
  (interactive)
  (auth-source-forget-all-cached)
  (bmp/erc-connect-all))

(use-package erc-hl-nicks
  :ensure t
  :after erc
  :config
  (add-to-list 'erc-modules 'hl-nicks))

(use-package erc-image
  :ensure t
  :after erc
  :config
  (setq erc-image-inline-rescale 300)
  (add-to-list 'erc-modules 'image))

4.18. Personal Knowledge Management

I use of a combination of services to manage my PKM, I depend on org-roam, and self-host Shaarli, FreshRSS, Wallabag for my needs. In order to have a smooth work-flow I use the following Emacs packages to help me. At some point I would have to also find an interface for Shaarli.

4.18.1. Note-taking

There are numerous options with Emacs to use, ranging from org-rr, denote and org-roam to name a few. Based on my needs, I have decided to use org-roam as the second brain with https://www.orgroam.com/manual.html#Introduction%5Bfn:11%5D.

  • Install dependencies
  • Custom templates from https://systemcrafters.net/build-a-second-brain-in-emacs/capturing-notes-efficiently/
  • Load all tags instead of only the first tag for org-roam-ui from https://github.com/org-roam/org-roam-ui/issues/289#issuecomment-1830366138
  • Lists subdirectories and current directory
  • Custom org-roam-capture that creates a node in one of the subdirectory or in the base directory
  • Custom templates for creating nodes
  • Sets the current tag, and filters by the tag
  • Checks for nodes in subdirectories and base directory
  • Insert a node in a subdirectory directly using org-roam-node-insert
  • Garbage collection
  • Use consult-org-roam for greater flexibility
  • This is from https://gist.github.com/d12frosted/a60e8ccb9aceba031af243dff0d19b2e
  • I was using the functions for org-roam-bibtex, bibtex-completion and org-ref from https://github.com/progfolio/elpaca/issues/263, but have now reverted it to the basic definitions
  • Added two functions for taking notes on tea
  • One function, bmp/org-roam-templates, builds every capture template, for the top of ~/Notes or for a subdirectory. Notes in a subdirectory get its name as a tag, plus any extra tags I type. The subdirectory is part of the file path (for example Projects/Radio/…), rather than temporarily changing org-roam-directory as before. C-c n b finds or creates a note in a subdirectory, C-c n n captures to one, and C-c n a inserts a link to one. Leave the subdirectory empty for the whole of ~/Notes.
  • Templates use :target, org-roam's current name for :if-new. The template files are tangled from 4.18.2, so this file is their single source.
  • Dailies (C-c n j) have a journal entry (d) and a task under a "Tasks" heading (t).
  • org-roam-protocol captures web pages from the browser into ~/Notes/Web/ (see 4.18.3).
(use-package f
  :ensure nil)

;; (use-package emacsql
;;   :ensure t)

(use-package magit-section
  :ensure nil)

(use-package org-roam
  :ensure t
  ;; Load at start-up, not on the first key press: the database sync,
  ;; citar-org-roam and org-mem (the agenda) all depend on it.
  :demand t
  :bind (("C-c n l" . org-roam-buffer-toggle)
         ("C-c n f" . org-roam-node-find)
         ("C-c n g" . org-roam-graph)
         ("C-c n i" . bmp/force-org-roam-node-insert)
         ("C-c n c" . org-roam-capture)
         ("C-c n j" . org-roam-dailies-capture-today)
         :map org-mode-map
         ("C-M-i" . completion-at-point))
  :config
  (setq org-roam-completion-everywhere t)
  ;; Syncthing keeps both versions when two devices change a note at once
  ;; ("*.sync-conflict-*.org"), plus old versions in .stversions/; they
  ;; carry the same IDs as the real note, so keep them out of org-roam
  ;; (org-mem already skips them)
  (setq org-roam-file-exclude-regexp
        (list org-attach-id-dir "\\.sync-conflict-" "/\\.stversions/"))
  ;; (setq org-roam-database-connector 'sqlite)

  ;; Templates for notes in the top of `org-roam-directory'; the
  ;; subdirectory commands below build the same set for a subdirectory.
  (setq org-roam-capture-templates (bmp/org-roam-templates))

  (setq org-roam-dailies-capture-templates
        '(("d" "Journal" entry "* %<%H:%M> %?"
           :target (file+head "%<%Y-%m-%d>.org" "#+title: %<%Y-%m-%d>\n"))
          ("t" "Task" entry "* TODO %?"
           :target (file+head+olp "%<%Y-%m-%d>.org" "#+title: %<%Y-%m-%d>\n"
                                  ("Tasks")))))

  ;; Web pages captured from the browser (see org-roam-protocol below)
  (setq org-roam-capture-ref-templates
        '(("r" "Web page" plain "${body}%?"
           :target (file+head "Web/%<%Y%m%d%H%M%S>-${slug}.org"
                              "#+title: ${title}\n#+date: %<%Y-%m-%d>\n#+filetags: :web:\n\n")
           :unnarrowed t)))

  (setq org-roam-node-display-template (concat "${title:*} " (propertize "${tags:10}" 'face 'org-tag)))
  (org-roam-db-autosync-mode)
  (require 'org-roam-protocol))

(defun bmp/tea-note ()
  "Return a tea note Org-roam entry string with dynamic filetags and formatted content."
  (let* ((name (org-roam-node-title org-roam-capture--node))
         (origin (completing-read "Origin: " '("Single Origin" "Blend")))
         (estate (when (string= origin "Single Origin")
                   (read-string "Estate: ")))
         (type-choice (completing-read "Type: "
                                       '("Green" "White" "Black" "Orthodox" "Tisane" "Oolong" "Other")))
         (type (if (string= type-choice "Other")
                   (read-string "Enter custom type: ")
                 type-choice))
         (grade (when (and (string= origin "Single Origin") (string= type "Black"))
                  (read-string "Grade (e.g. FTGFOP1, BOP, etc.): ")))
         (blend-details (when (and (string= origin "Blend") (string= type "Black"))
                          (read-string "Blend Details (mixture of grades): ")))
         (season (when (and (string= origin "Single Origin")
                            (member type '("Green" "White" "Black" "Orthodox")))
                   (let ((flush (completing-read "Season: "
                                                 '("First Flush" "Second Flush" "Monsoon Flush"
                                                   "Autumn Flush" "Winter Flush" "Other"))))
                     (if (string= flush "Other")
                         (read-string "Enter custom season: ")
                       flush))))
         (region (read-string "Region: "))
         (vendor (read-string "Vendor: "))
         (leaf-appearance (read-string "Leaf Appearance (Colour, Graininess, Size, Cleanliness, Bloom): "))
         (liquor-appearance (read-string "Liquor Appearance (Colour, Brightness, Fullness, Briskness): "))
         (infusion-appearance (read-string "Infusion Appearance: "))
         (liquor-taste (when (member type '("Black" "Tisane"))
                         (read-string "Taste of Liquor: ")))
         (primary-note (when (not (member type '("Black" "Tisane")))
                         (read-string "Primary Taste Note (e.g. sweet, grassy): ")))
         (secondary-note (when (not (member type '("Black" "Tisane")))
                           (read-string "Secondary Taste Note (e.g. nutty, floral): ")))
         (tail-note (when (not (member type '("Black" "Tisane")))
                      (read-string "Tail Note (e.g. mineral, bitter): ")))
         (liquor-aroma (read-string "Aroma of Liquor: "))
         (temperature (concat (read-string "Temperature (e.g. 85): ") "°C"))
         (time (concat (read-string "Steeping Time (in minutes): ") " minutes"))
         (infusions (read-string "Recommended No. of Infusions: "))
         (weight (concat (read-string "Weight per cup (e.g. 2.5): ") " g"))
         (date (format-time-string "%A, %d %b %Y"
                                   (org-read-date nil t nil "Tasting Date: ")))
         (price (concat "INR " (read-string "Price (e.g. 250): ")))
         (comments (read-string "Any other comments: "))
         (tags (mapconcat (lambda (s) (concat ":" (replace-regexp-in-string " " "_" (downcase s)) ":"))
                          (list origin type region vendor) "")))
    (concat
     ;; (format "#+title: %s\n" name)
     (format "#+filetags: %s\n\n" tags)
     "* Tea Details\n"
     (format "** Name: %s\n" name)
     (format "** Origin: %s\n" origin)
     (if estate         (format "** Estate: %s\n" estate) "")
     (if grade          (format "** Grade: %s\n" grade) "")
     (if blend-details  (format "** Blend Details: %s\n" blend-details) "")
     (if season         (format "** Season: %s\n" season) "")
     (format "** Type: %s\n" type)
     (format "** Region: %s\n" region)
     (format "** Vendor: %s\n" vendor)
     "** Appearance:\n"
     "| Element  | Description                                                       |\n"
     "|----------+-------------------------------------------------------------------|\n"
     (format "| Leaf     | %s |\n" leaf-appearance)
     (format "| Liquor   | %s |\n" liquor-appearance)
     (format "| Infusion | %s |\n" infusion-appearance)
     "** Taste of Liquor:\n"
     (if liquor-taste
         (format "%s\n" liquor-taste)
       (concat
        (format "*** Primary Note: %s\n" primary-note)
        (format "*** Secondary Note: %s\n" secondary-note)
        (format "*** Tail Note: %s\n" tail-note)))
     (format "** Aroma of Liquor: %s\n" liquor-aroma)
     "** Preparation:\n"
     (format "*** Temperature: %s\n" temperature)
     (format "*** Steeping Time: %s\n" time)
     (format "*** Recommended No. of Infusions: %s\n" infusions)
     (format "*** Per cup: %s\n" weight)
     "** Acquired/Tasted:\n"
     (format "*** Date: %s\n" date)
     (format "*** Price: %s\n" price)
     "* Comments:\n"
     (format "%s\n" comments))))

(defun bmp/tea-blend-note ()
  "Return a tea blend Org-roam entry string with base tea, additives, combinations, taste, and score tables."
  (let* ((blend-name (org-roam-node-title org-roam-capture--node))
         (base-tea (read-string "Base Tea: "))
         (additive-list '())
         (more-additives t)
         (date (format-time-string "%A, %d %b %Y"
                                   (org-read-date nil t nil "Date of Blend: ")))
         (possible-names (read-string "Possible Names (comma-separated): "))
         (comments (read-string "Final comments: ")))

    (while more-additives
      (let ((additive (read-string "Additive (leave empty to finish): ")))
        (if (string-empty-p additive)
            (setq more-additives nil)
          (push additive additive-list))))
    (setq additive-list (reverse additive-list))

    (let* ((tag-items (append (list base-tea) additive-list))
           (tags (mapconcat (lambda (s) (concat ":" (replace-regexp-in-string " " "_" (downcase s)) ":"))
                            (cons "blend" tag-items) "")))

      (let* ((num-combos (string-to-number (read-string "Number of combinations: ")))
             (combos '())
             (taste-table "")
             (score-table ""))

        (dotimes (i num-combos)
          (let ((row (list (format "%d" (1+ i)))))
            (push (concat (read-string (format "Combo %d - %s (g): " (1+ i) base-tea)) " g") row)
            (dolist (add additive-list)
              (push (concat (read-string (format "Combo %d - %s (g): " (1+ i) add)) " g") row))
            (setq combos (append combos (list (reverse row)))))

          (let ((leaf (read-string (format "Taste for Combo %d - Leaf: " (1+ i))))
                (liquor (read-string (format "Taste for Combo %d - Liquor: " (1+ i))))
                (infusion (read-string (format "Taste for Combo %d - Infusion: " (1+ i)))))
            (setq taste-table
                  (concat taste-table
                          (format "| %d | %s | %s | %s |\n" (1+ i) leaf liquor infusion))))

          (let* ((leaf-score (string-to-number (read-string (format "Score for Combo %d - Leaf (0–10): " (1+ i)))))
                 (liquor-score (string-to-number (read-string (format "Score for Combo %d - Liquor (0–10): " (1+ i)))))
                 (infusion-score (string-to-number (read-string (format "Score for Combo %d - Infusion (0–10): " (1+ i)))))
                 (total-score (+ leaf-score liquor-score infusion-score)))
            (setq score-table
                  (concat score-table
                          (format "| %d | %d | %d | %d | %d |\n"
                                  (1+ i) leaf-score liquor-score infusion-score total-score)))))

        (let* ((header-cols (append (list "S.No" base-tea) additive-list))
               (combo-header (concat "| " (mapconcat #'identity header-cols " | ") " |\n"))
               (divider (concat "|-" (mapconcat (lambda (_) "--------") header-cols "-+-") "|\n"))
               (combo-rows (mapconcat (lambda (row)
                                        (concat "| " (mapconcat #'identity row " | ") " |"))
                                      combos "\n"))
               (taste-header "| S.No | Leaf | Liquor | Infusion |\n|------+-------+--------+----------|\n")
               (score-header "| S.No | Leaf | Liquor | Infusion | Total |\n|------+-------+--------+----------+--------|\n"))

          (concat
           ;; (format "#+title: %s\n" blend-name)
           (format "#+filetags: %s\n\n" tags)
           (format "* Date of Blend: %s\n" date)
           (format "* Name: %s\n" blend-name)
           (format "* Base Tea: %s\n" base-tea)
           (format "* Additives: %s\n" (string-join additive-list ", "))
           "* Combinations:\n"
           combo-header divider combo-rows "\n"
           "* Taste Table:\n"
           taste-header taste-table "\n"
           "* Score Table:\n"
           score-header score-table "\n"
           "* Possible Names:\n"
           (format "%s\n" possible-names)
           "* Final Comments:\n"
           (format "%s\n" comments)))))))


(defun bmp/org-roam--subdirectories ()
  "Return the subdirectories of `org-roam-directory' that hold notes.
Hidden directories (such as .venv) and the Templates, images and daily
directories are left out."
  (let ((skip '("Templates" "images" "daily")))
    (sort
     (delq nil
           (mapcar (lambda (dir)
                     (let ((name (file-name-nondirectory (directory-file-name dir))))
                       (and (file-directory-p dir)
                            (not (string-prefix-p "." name))
                            (not (member name skip))
                            (file-relative-name dir org-roam-directory))))
                   (directory-files-recursively
                    org-roam-directory "" t
                    (lambda (dir)
                      (let ((name (file-name-nondirectory (directory-file-name dir))))
                        (not (or (string-prefix-p "." name) (member name skip))))))))
     #'string<)))

(defun bmp/org-roam--read-subdirectory ()
  "Ask for a subdirectory of `org-roam-directory'; empty means the top level.
A name that is not an existing subdirectory needs RET twice, so a note
title typed here by mistake does not become a new folder."
  (completing-read "Subdirectory (empty for the top level): "
                   (bmp/org-roam--subdirectories) nil 'confirm))

(defun bmp/org-roam--clean-tag (string)
  "Turn STRING into a valid Org tag."
  (replace-regexp-in-string "[ /]" "_" (directory-file-name string)))

(defun bmp/org-roam-filetags (subdir)
  "Return a #+filetags value: SUBDIR as a tag plus any extra tags asked for."
  (let* ((extra (split-string
                 (read-string "Extra tags (space separated, or leave blank): ")
                 "[ :]+" t))
         (tags (append (unless (string-empty-p subdir)
                         (list (bmp/org-roam--clean-tag subdir)))
                       (mapcar #'bmp/org-roam--clean-tag extra))))
    (if tags (concat ":" (string-join tags ":") ":") "")))

(defvar bmp/org-roam-templates-directory
  (expand-file-name "Templates/" org-roam-directory)
  "Directory of the note templates (tangled from this file).")

(defun bmp/org-roam-templates (&optional subdir)
  "Return org-roam capture templates for notes in SUBDIR.
SUBDIR is relative to `org-roam-directory'; nil or empty means its top."
  (let* ((subdir (if (and subdir (not (string-empty-p subdir)))
                     (file-name-as-directory subdir)
                   ""))
         (file (concat subdir "%<%Y%m%d%H%M%S>-${slug}.org"))
         (head (format "#+title: ${title}\n#+date: %%<%%Y-%%m-%%d>\n#+filetags: %%(bmp/org-roam-filetags %S)\n\n"
                       subdir))
         (title-only "#+title: ${title}\n")
         (template (lambda (name)
                     (list 'file (expand-file-name name bmp/org-roam-templates-directory)))))
    `(("d" "Default" plain "%?"
       :target (file+head ,file ,head) :unnarrowed t)
      ("b" "Book Notes" plain ,(funcall template "BookNotesTemplate.org")
       :target (file+head ,file ,head) :unnarrowed t)
      ("a" "Author" plain ,(funcall template "AuthorTemplate.org")
       :target (file+head ,file ,head) :unnarrowed t)
      ("p" "Podcast" plain ,(funcall template "PodcastTemplate.org")
       :target (file+head ,file ,head) :unnarrowed t)
      ("f" "RSS Feed" plain ,(funcall template "RSSFeedTemplate.org")
       :target (file+head ,file ,head) :unnarrowed t)
      ;; These two write their own #+filetags
      ("t" "Tea Note" plain (function bmp/tea-note)
       :target (file+head ,file ,title-only) :unnarrowed t)
      ("s" "Tea Blend" plain (function bmp/tea-blend-note)
       :target (file+head ,file ,title-only) :unnarrowed t))))

(defun bmp/org-roam--in-directory (subdir)
  "Return a node filter for notes under SUBDIR of `org-roam-directory'."
  (let ((dir (file-name-as-directory
              (file-truename (expand-file-name subdir org-roam-directory)))))
    (lambda (node) (string-prefix-p dir (org-roam-node-file node)))))

(defun bmp/org-roam-node-find-all ()
  "Find a note in a subdirectory, or create it there."
  (interactive)
  (let ((subdir (bmp/org-roam--read-subdirectory)))
    (org-roam-node-find nil nil (bmp/org-roam--in-directory subdir) nil
                        :templates (bmp/org-roam-templates subdir))))

(defun bmp/org-roam-capture-all ()
  "Capture to a note in a subdirectory, existing or new."
  (interactive)
  (let ((subdir (bmp/org-roam--read-subdirectory)))
    (org-roam-capture- :node (org-roam-node-read nil (bmp/org-roam--in-directory subdir))
                       :templates (bmp/org-roam-templates subdir))))

(defun bmp/org-roam-insert-in-subdirectory ()
  "Insert a link to a note in a subdirectory, creating it there if new."
  (interactive)
  (let ((subdir (bmp/org-roam--read-subdirectory)))
    (org-roam-node-insert (bmp/org-roam--in-directory subdir)
                          :templates (bmp/org-roam-templates subdir))))

(global-set-key (kbd "C-c n b") 'bmp/org-roam-node-find-all)
(global-set-key (kbd "C-c n n") 'bmp/org-roam-capture-all)
(global-set-key (kbd "C-c n a") 'bmp/org-roam-insert-in-subdirectory)

;; (setq org-roam-db-node-include-function
;;     (lambda ()
;;       (condition-case nil
;;           (not (member "ATTACH" (org-get-tags nil t)))
;;         (error t))))

(add-to-list 'display-buffer-alist
             '("\\*org-roam\\*"
               (display-buffer-in-side-window)
               (side . right)
               (slot . 0)
               (window-width . 0.33)
               (window-parameters . ((no-other-window . t)
                                     (no-delete-other-windows . t)))))

(setq org-roam-db-gc-threshold most-positive-fixnum)

(defun bmp/force-org-roam-node-insert ()
  "Bypass package conflicts and forcefully insert an Org-roam link with its title."
  (interactive)

  ;; 1. Wake up Org-roam and force a DB sync if the cache is cold
  (require 'org-roam)
  (unless org-roam-db-autosync-mode
    (org-roam-db-autosync-mode 1))

  (let* ((region-text (when (use-region-p)
                        (buffer-substring-no-properties (region-beginning) (region-end))))
         (node (org-roam-node-read region-text nil nil nil "Node: "))
         (id (org-roam-node-id node))
         (title (or region-text (org-roam-node-title node))))

    (if id
        (progn
          ;; Clear the highlighted text if there is any
          (when (use-region-p)
            (delete-region (region-beginning) (region-end)))
          ;; Force the exact bracket syntax, bypassing all package hooks
          (insert (format "[[id:%s][%s]]" id title)))

      ;; 2. If it's a NEW node, pass instructions to insert the link AFTER capture is finished
      (org-roam-capture- :node node
                         :props (list :finalize 'insert
                                      :insert-at (point-marker)
                                      :region-start (and (use-region-p) (region-beginning))
                                      :region-end (and (use-region-p) (region-end)))))))

(use-package org-download
  :ensure t
  :after org
  :bind
  (:map org-mode-map
        (("s-Y" . org-download-screenshot)
         ("s-y" . org-download-yank)))
  :config
  (add-hook 'dired-mode-hook 'org-download-enable)
  (setq org-download-heading-lvl nil)
  (setq-default org-download-heading-lvl nil)

  ;; Was (concat org-directory "images"), which gave ~/orgimages
  (setq-default org-download-image-dir (expand-file-name "images/" bmp/notes-directory)))


;; Citations: Zotero (Better BibTeX keeps these .bib files current) -> citar
;; The .bib files are personal (see [[*Personal and local settings]])
(add-hook 'bmp/personal-loaded-hook
          (lambda ()
            (let ((files (mapcar #'expand-file-name bmp/bibliography)))
              (setq citar-bibliography files
                    org-cite-global-bibliography files))))

(use-package citar
  :ensure t
  :custom
  (org-cite-insert-processor 'citar)
  (org-cite-follow-processor 'citar)
  (org-cite-activate-processor 'citar)
  :hook (org-mode . citar-capf-setup)
  :bind (("C-c b" . citar-open)
         :map org-mode-map
         ("C-c C-x @" . org-cite-insert)))

(use-package citar-embark
  :ensure t
  :after (citar embark)
  :config
  (citar-embark-mode 1))

(use-package citar-org-roam
  :ensure t
  :after (citar org-roam)
  :custom
  (citar-org-roam-subdir "References")
  (citar-org-roam-note-title-template "${author} - ${title}")
  :config
  (citar-org-roam-mode 1))

(use-package org-roam-ui
  :ensure
  (:host github :repo "org-roam/org-roam-ui" :branch "main" :files ("*.el" "out"))
  :after org-roam
  :config
  (setq org-roam-ui-sync-theme t
        org-roam-ui-follow t
        org-roam-ui-update-on-save t
        org-roam-ui-open-on-start nil))

(use-package consult-org-roam
  :ensure t
  :after org-roam
  :init
  (require 'consult-org-roam)
  (consult-org-roam-mode 1)
  :custom

  (consult-org-roam-grep-func #'consult-ripgrep)
  (consult-org-roam-buffer-narrow-key ?r)
  (consult-org-roam-buffer-after-buffers t)
  :config
  (consult-customize
   consult-org-roam-forward-links
   :preview-key "M-.")
  :bind
  ("C-c n e" . consult-org-roam-file-find)
  ("C-c n B" . consult-org-roam-backlinks)
  ("C-c n L" . consult-org-roam-forward-links)
  ("C-c n r" . consult-org-roam-search))

4.18.2. Note templates

The templates used by bmp/org-roam-templates for book notes, authors, podcasts, RSS feeds and tea notes. They are tangled into ~/Notes/Templates/, so this file is their only source; the copies that used to live in this repository's Templates/ folder are gone. ${...} fields are filled in by org-roam when a note is captured. TeaNotesTemplate.org is an older form of the tea note: the "Tea Note" capture (bmp/tea-note) asks for the same details interactively, so the file is kept here but is not offered in the capture menu.

  1. AuthorTemplate
    
    ◉ Notes
    %?
    ◉ Books
    **
    ◉ Link:
    
  2. BookNotesTemplate
    
    ◉ Book Information
    *◉ Title: ${title}
    *◉ Author: %Author
    *◉ Year: %Year
    *◉ Genre:
    ◉ Notes
    %?
    
  3. PodcastTemplate
    
    ◉ Podcast Details
    *◉ Name: ${Name}
    *◉ RSS Feed: %Rssfeed
    *◉ Website: %Website
    *◉ Genre:
    ◉ Notes
    %?
    
  4. RSSFeedTemplate
    
    ◉ Feed Details
    *◉ Name: ${Name}
    *◉ Type: ${Type}
    *◉ RSS Feed: %Rssfeed
    *◉ Website: %Website
    *◉ Description:
    ◉ Notes
    %?
    
  5. TeaNotesTemplate
    
    ◉ Tea Details
    *◉ Name: ${Name}
    *◉ Type: ${Type}
    *◉ Vendor: ${Vendor}
    *◉ Origin-Estate: ${Origin}
    *◉ Country: ${Country}
    *◉ Aroma: ${Aroma}
    *◉ Taste:
    **◉ Primary: ${Primary Note}
    **◉ Secondary: ${Secondary Note}
    **◉ Tail: ${Tail Note}
    *◉ Appearance:
    **◉ Leaves:
    ***◉ Colour: ${Leaf Colour}
    ***◉ Shape: ${Leaf Shape}
    ***◉ Size: ${Leaf Size}
    **◉ Liquor: ${Liquor Appearance}
    **◉ Infusion: ${Infusion Appearance}
    *◉ Mouthfeel: ${Mouthfeel}
    *◉ Preparation:
    **◉ Temperature: ${Temperature}
    **◉ Steeping Time: ${Time}
    **◉ No. of Infusions: ${No. of Infusions}
    **◉ Per cup: ${Weight per cup}
    *◉ Acquired/Tasted:
    **◉ Date:
    **◉ Price:
    ◉ Comments:
     %?
    

4.18.3. Capturing from the browser

org-roam-protocol (loaded with org-roam) lets the browser send the current page to Emacs, which files it as a note in ~/Notes/Web/ using org-roam-capture-ref-templates. The page address is stored in the note's ROAM_REFS, so visiting a note shows which other notes link to the same page.

Firefox (a Flatpak here) hands org-protocol:// links to the desktop, which needs a handler that passes them to emacsclient. This file is tangled into ~/.local/share/applications/; register it once as the handler after tangling:

update-desktop-database ~/.local/share/applications
xdg-mime default org-protocol.desktop x-scheme-handler/org-protocol
[Desktop Entry]
Name=Org Protocol
Comment=Send links from the browser to Emacs (org-protocol)
Exec=emacsclient %u
Icon=emacs
Type=Application
Terminal=false
NoDisplay=true
MimeType=x-scheme-handler/org-protocol;

In Firefox, create a bookmark named "Org-roam capture" with this as its address. Clicking it captures the current page, with any selected text as the body. The first time, Firefox asks which application should open the link; choose "Org Protocol" and tick "Always".

javascript:location.href='org-protocol://roam-ref?template=r&ref='+encodeURIComponent(location.href)+'&title='+encodeURIComponent(document.title)+'&body='+encodeURIComponent(window.getSelection())

Setting up the bookmark:

  1. Open the Library with Ctrl+Shift+O and choose Bookmarks Toolbar (show the toolbar with Ctrl+Shift+B).
  2. Right-click, Add Bookmark…, name it "Org-roam capture" and paste the address above as a single line.

Using it:

  • Select some text on a page (optional) and click the bookmark. The capture buffer shows the page title, the selected text as the body, and files the note in ~/Notes/Web/; C-c C-c saves it and C-c C-k cancels.
  • Clicking it again on a page that already has a note opens that note instead of creating a duplicate.
  • The template=r part of the address picks the "Web page" template in org-roam-capture-ref-templates; another template can be added there with its own key and a second bookmark.

If it stops working:

  • Nothing happens: some sites block javascript: bookmarks; try another page.
  • Firefox does not know the protocol: xdg-mime query default x-scheme-handler/org-protocol should print org-protocol.desktop; then restart Firefox.
  • The wrong application opens: change it under Firefox's Settings → General → Applications (org-protocol).
  • On KDE, xdg-mime prints "qtpaths: command not found" (Aurora only has the Qt 6 qtpaths6); this is harmless and the handler is still registered.

4.18.4. DONE Agenda and scheduling and habit tracking with PKM

  • I currently use code from https://gist.github.com/d12frosted/a60e8ccb9aceba031af243dff0d19b2e I have now moved to ~org-mem~ based on the comment at the Vulpea gist. org-mem-updater-mode is turned on a few seconds after start-up, which scans all files and keeps the cache current, so the agenda is complete from the first launch. M-x bmp/setup-org-mem forces a full rescan if needed.
  • In addition I am using org-ql to be able to query agenda quickly
  • The drawback of this approach is that the first time Emacs is launched, the agenda will not load, I need to find some org-roam node before the agenda loads properly Fixed: the scan now starts by itself after start-up
  • org-mem also scans the folder of every recently opened file and every file Org knows by ID. A deleted file whose folder was gone stopped the whole scan ("Directory not readable"), so deleted files are dropped from those lists first (bmp/org-mem-forget-missing)
  • When a task in a file outside org-roam is marked DONE, bmp/log-done-task-to-daily adds a line with a link back to it under "Completed" in today's daily. Tasks inside org-roam notes stay where they are, so nothing is recorded twice.
  • I have customized colours for priorities
  • I have removed org-habit for now
  • Add org-super-agenda to customize the view
  • Based on the snippet at https://systemcrafters.net/build-a-second-brain-in-emacs/5-org-roam-hacks/#automatically-copy-or-move-completed-tasks-to-dailies, I have used an LLM to generate a function to capture all tasks into a daily.
(setq org-todo-keywords
      '((sequence
         "TODO(t)"
         "STARTED(s!)"
         "WAITING(w@/!)"
         "SOMEDAY(.)" "BLOCKED(k@/!)" "|" "CANCELLED(c!)" "DONE(d!)")
        (sequence "RESEARCH(r)" "|" "POSTED-SM(q!)" "BOOKMARKED(b!)" "ZETTELED(z!)" "COMPLETE(x!)")
        (sequence "TOLEARN(-)" "|" "LEARNING(l!)")))

;; My tags are personal (see [[*Personal and local settings]])
(add-hook 'bmp/personal-loaded-hook
          (lambda () (setq org-tag-alist bmp/org-tags)))

(defun bmp/org-faces-from-theme (&rest _)
  "Take Org's TODO-keyword and priority colours from the current ef theme.
Runs whenever a theme is enabled, so the colours follow theme changes."
  (when (seq-some (lambda (theme) (string-prefix-p "ef-" (symbol-name theme)))
                  custom-enabled-themes)
    (ef-themes-with-colors
      (setq org-todo-keyword-faces
            `(("TODO" . (:foreground ,red :weight bold))
              ("STARTED" . (:foreground ,blue :weight bold))
              ("DONE" . (:foreground ,green :weight bold))
              ("CANCELLED" . (:foreground ,green :weight bold))
              ;; cyan-cooler (teal): plain cyan is almost the same as blue
              ("WAITING" . (:foreground ,cyan-cooler :weight bold))
              ("BLOCKED" . (:foreground ,yellow-warmer :weight bold))
              ("SOMEDAY" . (:foreground ,magenta :weight bold))))
      (setq org-priority-faces
            `((?A . (:background ,green :foreground ,bg-main))
              (?B . (:background ,blue :foreground ,bg-main))
              (?C . (:background ,cyan-cooler :foreground ,bg-main))))
      ;; org-modern draws keywords and priorities as labels with faces of
      ;; its own, so give it the same colours (as label backgrounds)
      (setq org-modern-todo-faces
            (mapcar (lambda (entry)
                      (cons (car entry)
                            (list :background (plist-get (cdr entry) :foreground)
                                  :foreground bg-main :weight 'bold)))
                    org-todo-keyword-faces)
            org-modern-priority-faces org-priority-faces))))

(add-hook 'enable-theme-functions #'bmp/org-faces-from-theme)
(bmp/org-faces-from-theme)

(setq org-log-done 'time)
(setq org-enforce-todo-dependencies t)
(setq org-track-ordered-property-with-tag t)

(setq org-agenda-dim-blocked-tasks t
      org-agenda-window-setup 'current-window
      org-agenda-current-time-string ""
      org-agenda-time-grid '((daily) () "" "")
      org-agenda-hide-tags-regexp ".*"
      org-agenda-block-separator nil
      org-agenda-compact-blocks t)

(setq org-agenda-prefix-format '((agenda . "  %?-2i %t ")
                 (todo . " %i %-12:c")
                                 (tags . " %i %-12:c")
                     (search . " %i %-12:c")))


(use-package org-mem
  :ensure t
  :after org org-roam
  :config
  ;; Scan all Org files a few seconds after start-up and keep the cache
  ;; current on save, so the agenda is complete from the first launch.
  (run-with-idle-timer 3 nil #'bmp/org-mem-start))

(defun bmp/org-mem-forget-missing ()
  "Drop deleted files from the lists org-mem takes folders from.
org-mem scans the folder of every recent file and every file Org knows by
ID; a deleted folder there stopped the scan with \"Directory not readable\"."
  (when (boundp 'recentf-list)
    (setq recentf-list (seq-filter (lambda (f) (or (file-remote-p f) (file-exists-p f)))
                                   recentf-list)))
  (when (and (boundp 'org-id-locations) (hash-table-p org-id-locations))
    (let (gone)
      (maphash (lambda (id file) (unless (file-exists-p file) (push id gone)))
               org-id-locations)
      (dolist (id gone) (remhash id org-id-locations))
      (setq org-id-files (delete-dups (hash-table-values org-id-locations))))))

(defun bmp/org-mem-start ()
  "Start org-mem's scan and updates, after forgetting deleted files."
  (bmp/org-mem-forget-missing)
  (org-mem-updater-mode 1))

(defun my-set-agenda-files (&rest _)
  (setq org-agenda-files
      (cl-loop
       for file in (org-mem-all-files)
       unless (string-search "archive" file)
       when (seq-find (lambda (entry)
                        (or (org-mem-entry-active-timestamps entry)
                            (org-mem-entry-todo-state entry)
                            (org-mem-entry-scheduled entry)
                            (org-mem-entry-deadline entry)))
                      (org-mem-entries-in file))
       collect file)))

(add-hook 'org-mem-post-full-scan-functions #'my-set-agenda-files)

(setq org-mem-do-sync-with-org-id t)

(defun bmp/setup-org-mem ()
  "Turn on `org-mem-updater-mode' and rescan all Org files."
  (interactive)
  (org-mem-updater-mode)
  (org-mem-reset))

(defun bmp/log-done-task-to-daily ()
  "Note a task finished outside org-roam under \"Completed\" in today's daily.
Tasks in org-roam notes are left where they are, so a task is never
recorded in two places."
  (when (and (member org-state org-done-keywords)
             buffer-file-name
             (not (string-prefix-p (file-truename org-roam-directory)
                                   (file-truename buffer-file-name))))
    (condition-case err
        (let* ((heading (org-get-heading t t t t))
               (org-capture-initial
                (format "%s %s"
                        (org-link-make-string
                         (concat "file:" (abbreviate-file-name buffer-file-name)
                                 "::*" heading)
                         heading)
                        (format-time-string "[%H:%M]"))))
          (require 'org-roam-dailies)
          (org-roam-capture-
           :node (org-roam-node-create)
           :templates
           `(("c" "Completed task" item "%i"
              :target (file+head+olp
                       ,(concat org-roam-dailies-directory "%<%Y-%m-%d>.org")
                       "#+title: %<%Y-%m-%d>\n" ("Completed"))
              :immediate-finish t :kill-buffer t))
           :keys "c"))
      (error (message "Could not log the task to today's daily: %S" err)))))

(add-hook 'org-after-todo-state-change-hook #'bmp/log-done-task-to-daily)

(use-package org-ql
  :ensure t)

(use-package org-super-agenda
  :ensure t
  :config
  (org-super-agenda-mode)
  (setq org-super-agenda-groups
        '((:name " Today"
                 :deadline today
                 :date today
                 :scheduled today
                 :order 1
                 :face 'warning)
          (:name " Upcoming Deadlines"
                 :deadline future
                 :order 2)
          (:name " Work tasks"
                 :tag nil  ; filled in from `bmp/work-tags', see below
                 :order 4
                 :scheduled (nil today future))
          )))

;; My work tags are personal (see [[*Personal and local settings]])
(add-hook 'bmp/personal-loaded-hook
          (lambda ()
            (with-eval-after-load 'org-super-agenda
              (setq org-super-agenda-groups
                    (mapcar (lambda (group)
                              (if (plist-member group :tag)
                                  (plist-put (copy-sequence group) :tag bmp/work-tags)
                                group))
                            org-super-agenda-groups)))))

4.18.5. Read later

I self-host Wallabag as a service to store articles to read later from https://github.com/chenyanming/wallabag.el.


(use-package request
  :ensure t)

(use-package wallabag
  :defer t
  :ensure (:host github
                 :repo "chenyanming/wallabag.el"
                 :files ("*.el" "*.alist" "*.css"))
  :config
  (auth-source-forget-all-cached)
  (setq wallabag-host     (or (bmp/lookup-password :host "wallabag-host-pop") "")
        wallabag-username bmp/wallabag-user
        wallabag-password (bmp/lookup-password :host "wallabag-pwd")
        wallabag-clientid (or (bmp/lookup-password :host "wallabag-clientid") "")
        wallabag-secret   (or (bmp/lookup-password :host "wallabag-secret") ""))
  (add-hook 'wallabag-after-render-hook 'wallabag-search-update-and-clear-filter))

4.18.6. Bookmarks (Shaarli)

I keep bookmarks in a self-hosted Shaarli. M-x bmp/shaarli-add (C-c s in elfeed) bookmarks what I am looking at through Shaarli's REST API:

  • in an elfeed post, or on the post at point in the elfeed list: that post;
  • in eww: the page;
  • anywhere else: the link at point (an Org link, a web address, a link in a rendered page), otherwise it asks for an address.

It then asks for the title (pre-filled), a description (the selected text, otherwise the first paragraph of the post or page, up to 500 characters) and tags (elfeed's tags for the post, including the FreshRSS category, with completion; separate them with commas). Bookmarks are public; with C-u first, private. If the link is already in Shaarli, it says so instead of adding it again.

The API has to be enabled in Shaarli (Tools → Configure your Shaarli → REST API), and its address and API secret are in authinfo:

machine shaarli-url password https://my-shaarli.example
machine shaarli-api-secret password <API secret from Shaarli's settings>

Each request is signed with a short-lived token (a JWT with the current time, signed with HMAC-SHA512 using the API secret); the secret is only read when a bookmark is saved. Requests to Shaarli use IPv4 only (bmp/shaarli-force-ipv4): the server's host has an IPv6 address on which HTTPS does not answer, and Emacs tries IPv6 first, so they hung. Only Shaarli's connections are affected; set the variable to nil once HTTPS works over IPv6.

(defvar bmp/shaarli-description-length 500
  "Longest description taken from a post or page when nothing is selected.")

(defun bmp/hmac-sha512 (key message)
  "Return the HMAC-SHA512 of MESSAGE with KEY, as raw bytes."
  (let* ((block 128)
         (key (encode-coding-string key 'utf-8))
         (message (encode-coding-string message 'utf-8))
         (key (if (> (length key) block) (secure-hash 'sha512 key nil nil t) key))
         (key (concat key (apply #'unibyte-string (make-list (- block (length key)) 0))))
         (pad (lambda (byte)
                (apply #'unibyte-string (mapcar (lambda (c) (logxor c byte)) key)))))
    (secure-hash 'sha512
                 (concat (funcall pad #x5c)
                         (secure-hash 'sha512 (concat (funcall pad #x36) message)
                                      nil nil t))
                 nil nil t)))

(defun bmp/shaarli--token (secret)
  "Return a JWT for Shaarli's API, signed with SECRET."
  (let* ((encode (lambda (s) (base64url-encode-string
                              (encode-coding-string s 'utf-8) t)))
         (unsigned (concat (funcall encode "{\"typ\":\"JWT\",\"alg\":\"HS512\"}") "."
                           (funcall encode (format "{\"iat\":%d}" (floor (float-time)))))))
    (concat unsigned "." (base64url-encode-string (bmp/hmac-sha512 secret unsigned) t))))

(defun bmp/shaarli--first-paragraph (text)
  "Return TEXT's first non-empty paragraph, cut to the description length."
  (let ((para (seq-find (lambda (p) (not (string-blank-p p)))
                        (split-string (or text "") "\n[ \t]*\n"))))
    (truncate-string-to-width
     (string-trim (replace-regexp-in-string "[ \t\n]+" " " (or para "")))
     bmp/shaarli-description-length nil nil "…")))

(defun bmp/shaarli--html-text (html)
  "Return HTML rendered as plain text."
  (with-temp-buffer
    (insert html)
    (let ((shr-use-fonts nil))
      (ignore-errors (shr-render-region (point-min) (point-max))))
    (buffer-substring-no-properties (point-min) (point-max))))

(defun bmp/shaarli--elfeed-entry ()
  "Return the elfeed entry being viewed, or at point in the list."
  (cond ((derived-mode-p 'elfeed-show-mode) elfeed-show-entry)
        ((derived-mode-p 'elfeed-search-mode)
         (car (elfeed-search-selected)))))

(defun bmp/shaarli--context ()
  "Return (URL TITLE TEXT TAGS) for what I am looking at."
  (let ((selection (and (use-region-p)
                        (buffer-substring-no-properties (region-beginning) (region-end))))
        (entry (and (featurep 'elfeed) (bmp/shaarli--elfeed-entry))))
    (cond
     (entry
      (list (elfeed-entry-link entry)
            (elfeed-entry-title entry)
            (or selection
                (let ((content (elfeed-deref (elfeed-entry-content entry))))
                  (and content (if (eq (elfeed-entry-content-type entry) 'html)
                                   (bmp/shaarli--html-text content)
                                 content))))
            (mapcar #'symbol-name
                    (cl-set-difference (elfeed-entry-tags entry) '(unread star)))))
     ((derived-mode-p 'eww-mode)
      (list (plist-get eww-data :url) (plist-get eww-data :title)
            (or selection (buffer-substring-no-properties (point-min) (point-max)))
            nil))
     (t
      (let* ((link (and (derived-mode-p 'org-mode)
                        (let ((ctx (org-element-context)))
                          (and (eq (org-element-type ctx) 'link) ctx))))
             (url (or (get-text-property (point) 'shr-url)
                      (and link (org-element-property :raw-link link))
                      (thing-at-point 'url t)))
             ;; An Org link's description makes a good title
             (title (and link (org-element-contents-begin link)
                         (buffer-substring-no-properties
                          (org-element-contents-begin link)
                          (org-element-contents-end link)))))
        (list url title selection nil))))))

(defvar bmp/shaarli-force-ipv4 t
  "Connect to Shaarli over IPv4 only.
My Shaarli's host publishes an IPv6 address on which HTTPS does not answer
(October 2026), and Emacs tries IPv6 first, so requests hung.  Over IPv4
the API answers at once.  Set this to nil once HTTPS works over IPv6
(`curl -6 https://<host>/shaarli/api/v1/info' gives 401 quickly).")

(defun bmp/shaarli--ipv4-only (orig &rest args)
  "Call `make-network-process' (ORIG with ARGS) asking for IPv4."
  (apply orig (plist-put args :family 'ipv4)))

(defun bmp/shaarli--request (method path secret base &optional body)
  "Send METHOD to Shaarli's API PATH at BASE; return (STATUS . JSON).
Uses IPv4 only while `bmp/shaarli-force-ipv4' is set (see there why)."
  (require 'url)
  (when bmp/shaarli-force-ipv4
    (advice-add 'make-network-process :around #'bmp/shaarli--ipv4-only))
  (unwind-protect
      (bmp/shaarli--request-1 method path secret base body)
    (advice-remove 'make-network-process #'bmp/shaarli--ipv4-only)))

(defun bmp/shaarli--request-1 (method path secret base body)
  "Send the request for `bmp/shaarli--request'."
  (let* ((url-request-method method)
         (url-request-extra-headers
          `(("Authorization" . ,(concat "Bearer " (bmp/shaarli--token secret)))
            ("Content-Type" . "application/json")))
         (url-request-data (and body (encode-coding-string (json-encode body) 'utf-8)))
         (buffer (url-retrieve-synchronously
                  (concat (string-remove-suffix "/" base) "/api/v1/" path) t nil 30)))
    (unless buffer (user-error "Shaarli did not answer at %s" base))
    (with-current-buffer buffer
      (prog1 (cons (bound-and-true-p url-http-response-status)
                   (and (bound-and-true-p url-http-end-of-headers)
                        (ignore-errors
                          (json-parse-string
                           (decode-coding-string
                            (buffer-substring-no-properties url-http-end-of-headers
                                                            (point-max))
                            'utf-8)
                           :object-type 'alist :array-type 'list))))
        (kill-buffer)))))

(defun bmp/shaarli-add (&optional private)
  "Bookmark what I am looking at in Shaarli; public unless PRIVATE (C-u)."
  (interactive "P")
  (pcase-let* ((`(,url ,title ,text ,tags) (bmp/shaarli--context))
               (url (read-string "Bookmark URL: " url))
               (_ (when (string-blank-p url) (user-error "No address to bookmark")))
               (title (read-string "Title: " (or title url)))
               (description (read-string "Description: "
                                         (bmp/shaarli--first-paragraph text)))
               (tags (completing-read-multiple "Tags (comma-separated): " tags
                                               nil nil (string-join tags ",")))
               (base (bmp/lookup-password :host "shaarli-url"))
               (secret (bmp/lookup-password :host "shaarli-api-secret")))
    (unless (and base secret)
      (user-error "Add shaarli-url and shaarli-api-secret to authinfo"))
    (pcase-let ((`(,status . ,json)
                 (bmp/shaarli--request
                  "POST" "links" secret base
                  `((url . ,url) (title . ,title) (description . ,description)
                    (tags . ,(vconcat tags)) (private . ,(if private t :json-false))))))
      (pcase status
        (201 (message "Saved to Shaarli (%s): %s" (if private "private" "public") title))
        (409 (message "Already in Shaarli: %s" (or (alist-get 'title json) url)))
        (401 (user-error "Shaarli refused the token; check the API secret and that the REST API is on"))
        (_ (user-error "Shaarli answered %s" status))))))

(with-eval-after-load 'elfeed-search
  (keymap-set elfeed-search-mode-map "C-c s" #'bmp/shaarli-add))
(with-eval-after-load 'elfeed-show
  (keymap-set elfeed-show-mode-map "C-c s" #'bmp/shaarli-add))

4.18.7. RSS Feeds

elfeed is the best tool for reading RSS feeds within Emacs, and when I combine it with elfeed-protocol, it ticks all the boxes for my use-case.

  • Fix error about avl-tree: https://github.com/skeeto/elfeed/issues/509
  • Function to add entry into Wallabag from Elfeed
  • Since version 1.0.0, elfeed-protocol reads the FreshRSS account from elfeed-feeds again rather than elfeed-protocol-feeds. The feed ID has the form fever+https://user@host (stored in authinfo as freshrss-user-url). The API key is passed as a function, so it is only looked up from authinfo when a fetch runs and is never kept in a variable. Update all feeds with G (elfeed-search-fetch) in the search buffer, or M-x elfeed-update
  • I have added elfeed-score to ensure posts of interest surface
  • Added cuckoo-search to help with searching within elfeed from https://github.com/rtrppl/cuckoo-search
  • TLS verification is left on. I used to pass --insecure to curl, which meant the FreshRSS API key could be sent to anyone able to intercept the connection. If the self-hosted certificate is not trusted, add its CA with (setq elfeed-curl-extra-arguments '("--cacert" "/path/to/ca.pem")) rather than turning verification off. Debug logging is off too, as it records request URLs.

(use-package elfeed
  :ensure t
  :after compat
  :bind
  ("C-c e" . elfeed))

(elpaca-wait)

(define-advice elfeed-search--header (:around (oldfun &rest args))
  (if elfeed-db
      (apply oldfun args)
    "No database loaded yet"))


(use-package elfeed-protocol
  :ensure t
  :demand t
  :after elfeed
  :config
  (auth-source-forget-all-cached)
  (defun elfeed-search-format-date (date)
    (format-time-string "%H:%M %d-%m-%Y" (seconds-to-time date)))
  (defalias 'elfeed-toggle-star
    (elfeed-expose #'elfeed-search-toggle-all 'star))
  (eval-after-load 'elfeed-search
    '(define-key elfeed-search-mode-map (kbd "m") 'elfeed-toggle-star))
  (defface elfeed-search-star-title-face
    '((t :background "#333" :foreground "#ffcc00"))
    "Dark background with gold text for starred entries.")
  (push '(star elfeed-search-star-title-face) elfeed-search-face-alist)
  (setq elfeed-use-curl t)
  (elfeed-set-timeout 36000)
  (setq elfeed-protocol-fever-update-unread-only t)
  (setq elfeed-protocol-fever-fetch-category-as-tag t)
  (defun bmp/freshrss-api-key ()
    "Return the FreshRSS API key from authinfo, looked up on each fetch."
    (bmp/lookup-password :host "freshrss-api-pwd"))
  (let* ((user-id (bmp/lookup-password :host "freshrss-user-url"))
         (base-url (bmp/lookup-password :host "freshrss-url")))
    (if (and (stringp user-id) (stringp base-url))
        (progn
          (setq elfeed-feeds
                `((,user-id
                   :api-url ,(concat base-url "?api")
                   :password bmp/freshrss-api-key)))
          (message "✅ elfeed-protocol configured successfully."))
      (message "❌ FreshRSS user or URL returned nil or non-string.")))
  (elfeed-protocol-enable))

(defun bmp/wallabag-add-from-elfeed ()
  "Add current Elf Feed entry URL to Wallabag."
  (interactive)
  (let ((entry (elfeed-search-selected :single)))
    (if entry
        (let ((url (elfeed-entry-link entry)))
          (if url
              (progn
                (unless (get-buffer "*wallabag*")
                  (wallabag))
                (wallabag-add-entry url ""))
            (message "No URL found in current Elf Feed entry.")))
      (message "No Elf Feed entry selected."))))

(eval-after-load 'elfeed
  '(define-key elfeed-search-mode-map (kbd "C-c w") #'bmp/wallabag-add-from-elfeed))

(use-package elfeed-score
  :ensure t
  :after elfeed
  :config
  (progn
    (elfeed-score-enable)
    (define-key elfeed-search-mode-map "=" elfeed-score-map))
  ;; My scoring rules (my interests) are encrypted, see
  ;; [[*Personal and local settings]]
  (when (file-exists-p bmp/elfeed-score-file)
    (elfeed-score-load-score-file bmp/elfeed-score-file))
  (setq elfeed-search-print-entry-function #'elfeed-score-print-entry))

(use-package cuckoo-search
  :ensure t
  :after elfeed
  :config
  (setq cuckoo-search-cmd "rg")
  (cuckoo-search-global-mode 1)
  :bind
  (:map elfeed-search-mode-map
        ("C" . cuckoo-search)
        ("x" . cuckoo-search-saved-searches)))
  • My scoring rules (the topics and names I want to rank higher) are personal, so they are kept in the encrypted secrets/elfeed.score.gpg (see 3.3), in the format described at https://github.com/sp1ff/elfeed-score. Its first line tells Emacs which key to encrypt to when I edit it. elfeed-score keeps its hit statistics in a separate file under data/, which is never part of this repository.

4.19. DONE Provide Emacs externally

This is based the code provided by Prot and helps to access Emacs anywhere in the system. Each command opens a small frame for one capture and closes it when the capture is finished or cancelled. Bind these to KDE shortcuts (System Settings → Shortcuts → Add Command):

  • emacsclient -e '(prot-window-popup-org-capture)'
  • emacsclient -e '(prot-window-popup-org-roam-capture)'
  • emacsclient -e '(prot-window-popup-org-roam-dailies-capture-today)'
(defun prot-window-delete-popup-frame (&rest _)
  "Kill selected selected frame if it has parameter `prot-window-popup-frame'.
Use this function via a hook."
  (when (frame-parameter nil 'prot-window-popup-frame)
    (delete-frame)))

(defmacro prot-window-define-with-popup-frame (command)
  "Define interactive function which calls COMMAND in a new frame.
Make the new frame have the `prot-window-popup-frame' parameter."
  `(defun ,(intern (format "prot-window-popup-%s" command)) ()
     ,(format "Run `%s' in a popup frame with `prot-window-popup-frame' parameter.
Also see `prot-window-delete-popup-frame'." command)
     (interactive)
     (let ((frame (make-frame '((prot-window-popup-frame . t)
                                (width . 100) (height . 30)))))
       (select-frame frame)
       (switch-to-buffer " prot-window-hidden-buffer-for-popup-frame")
       (condition-case nil
           (call-interactively ',command)
         ((quit error user-error)
          (delete-frame frame))))))

(declare-function org-capture "org-capture" (&optional goto keys))
(defvar org-capture-after-finalize-hook)

;;;###autoload (autoload 'prot-window-popup-org-capture "prot-window")
(prot-window-define-with-popup-frame org-capture)
(prot-window-define-with-popup-frame org-roam-capture)
(prot-window-define-with-popup-frame org-roam-dailies-capture-today)

;; org-roam captures through org-capture, so this closes their frames too
(add-hook 'org-capture-after-finalize-hook #'prot-window-delete-popup-frame)

4.20. After-init hooks

Since I use elpaca, there are some after-init hooks that can be run based on https://github.com/progfolio/elpaca/wiki/after-init-hook%3F-emacs-startup-hook%3F,

  • This ensures that elpaca-after-init-hook only runs once
  • Enable vertico for jinx
  • Enable repeat-mode
  • Enable dwim-shell-commands
;;END OF INIT FILE
(setq elpaca-after-init-time (or elpaca-after-init-time (current-time)))
(elpaca-wait)

(require 'dwim-shell-commands)

(vertico-multiform-mode)
(add-to-list 'vertico-multiform-categories
             '(jinx grid (vertico-grid-annotate . 20)))

(repeat-mode)

(elpaca-wait)

Footnotes:

1

The first time I had appreciated the power of such a paradigm was when I used IPython (which later became Jupyter), and when using Sweave to integrate R code into LaTeX.

9

This time around, I have finally figured out a system that hopefully will make it sticky. A big part of this reason is Org Mode, and the recent switch back to Linux with a Framework laptop.

10

It has sometimes been far more useful than Google Fu.

11

In October 2026 the whole configuration was reviewed with Anthropic's Claude, through Claude Code running in Emacs (agent-shell): privacy, start-up and daemon correctness, outdated packages, the Org and org-roam set-up, and grammar and spell checking. Every change was tested in a separate Emacs before I applied it, and I made the decisions and the commits.

12

Recoll is a cross-platform full text search application.

13

As always run a local server from https://dev.languagetool.org/http-server

14

Build fastText from https://fasttext.cc/docs/en/support.html and then download the language identification model from https://fasttext.cc/docs/en/language-identification.html

16

This has lead to proliferation of a number of starter kits, etc.

17

Ensures that file handling through different devices/applications does not corrupt the file.

19

Charles Choi's casual package at https://github.com/kickingvegas/casual is a nifty package that makes Emacs "user-friendly", and I have moved to casual-suite ass recommended in the README.

20

The origins of the term 'porcelain' in git that is aimed at exposing some high level information to users. The origin of the term is sometime attributed to a mail thread with Mike Taht and Linus Torvalds. There is a lot of material available on-line describing the differences between porcelain and plumbing.

22

As always mailing-lists are a treasure trove, I encountered this discussion at https://lists.gnu.org/archive/html/emacs-orgmode/2009-07/msg00769.html

23

There a number of wonderful ways to use these frameworks, one stand-out guide is by Karthink at https://karthinks.com/software/fifteen-ways-to-use-embark/

24

It has a guide for LaTeX users on their site at https://typst.app/docs/guides/guide-for-latex-users/

25

The online demo of Pandoc at https://pandoc.org/try/ is a good place to try it.

Author: Bharath M. Palavalli

Modified: 01:32AM, Mon 19 Oct 2026

Questions