Forgemacs' Literate Emacs Configuration
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. Since 2023, I have been using Emacs again, 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-tanglein 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. ~/.emacs.d is a symbolic link to ~/.config/emacs.
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,
- An initial shell-script that creates the required folder structure
- A bog-standard
early-init.elwith some custom configuration - An
init.elfile with the configuration - This file is also exported to a single web page,
index.html, with my own theme in the colours of my website.bmp/export-tangle-init(see 4.6.6) exports and tangles in one step.
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-08-28
The system details are as follows,
Linux aurora 7.2.7-200.fc44.x86_64 #1 SMP PREEMPT_DYNAMIC Mon Sep 21 18:25:57 UTC 2026 x86_64 GNU/Linux
- Website: https://forgemacs.bharathpalavalli.com
- Repository: https://codeberg.org/bmp/forgemacs
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 files well yet. This configuration is released under The Unlicense.
• Website: https://forgemacs.bharathpalavalli.com
• Repository: https://codeberg.org/bmp/forgemacs
Instructions
1. All details are available in the Forgemacs Org Mode file. The file is self-contained, if necessary, you can view the 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 places1.
3. The Forgemacs Org Mode 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
├── config/ (yasnippet snippets, settings written by packages)
├── 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. 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
- Package(s)
fortuneripgrepgnupgtypstlatexmpvpdf2svgpopplerpandoc(Markdown preview and export, ox-pandoc)latexmk(TeX Live; live PDF for LaTeX)podman(local LibreTranslate)ffmpegthumbnailer
Tools
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 in Podman does the translating for bmp/translate (4.7.10), so text never leaves this machine. The Quadlet below becomes a systemd user service; Emacs starts it on demand and stops it after ten idle minutes (it uses about 1.9 GB with five languages). Models stay in a named volume.
Languages (LT_LOAD_ONLY) are read from ~/.config/libretranslate/languages.env, which Emacs creates from bmp/translate-default-languages; M-x bmp/translate-add-language adds more (about 0.25 GB each). 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 aswritegood-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 withM-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
emacsclientcan 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-alistis 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.- Native compilation (JIT) is on: packages are compiled in the background on first load. Its warnings, mostly about optional dependencies, go to
*Async-native-compile-log*, not*Warnings*. - 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
Lexical binding15 is the modern scoping for Emacs Lisp; the init file turns it on in its first line.
;; -*- 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. :ensureinuse-packageinstalls through Elpaca;(elpaca-wait)waits for the queued packages where later code needs them.- 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.
eldocis Emacs's built-in version.- Newer
compatandtransientthan Emacs's own (magit needs the newer transient). consultandcsv-modeare declared here, beforeconsult-dirandcasualpull them in as dependencies: declared again later, Elpaca warns, which abortsinit.elin the daemon. Their own forms use: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)
;; Built into Emacs 30 at the version other packages ask for, so Elpaca
;; does not install its own copy when one of them depends on it
(add-to-list 'elpaca-ignored-dependencies 'which-key)
(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
:config
(global-eldoc-mode 1)
:custom
(eldoc-echo-area-use-multiline-p nil))
(use-package compat
:ensure t)
(use-package transient
:ensure t)
;; Declared here so they are not first pulled in as dependencies (of
;; consult-dir and casual); configured in the Consult and CSV sections
(elpaca consult)
(elpaca csv-mode)
(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; Emacs native-compiles packages in the background (see 2).
3.3. Adding a package
Before a package goes into this file, it has to pass this list:
- Need: what does it do that I do not have already? Does Emacs (30, or 31 soon) already do it, or a package I already use?
- Health: is it maintained (recent commits, issues answered)? Is its licence compatible with the GPL?
- Privacy: does it contact any server, and what does it send?
- Weight: how many other packages does it bring with it (
M-x elpaca-dependencies)? - Loading: it loads only when needed (
:hook,:bind,:commands,:modeor:after), unless I need it at start-up. - Cost: measured in a separate test Emacs before it goes in: start-up time before and after, and anything it runs on every key press or buffer change (
post-command-hook,after-change-functions, timers). - Documented: a paragraph here on why I use it. It then appears in 5 by itself.
When a package goes, it is also removed with M-x elpaca-delete: Elpaca does not uninstall packages that are no longer in this file.
3.4. 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: my folders (notes, Calibre, projects). Not encrypted; loaded at start-up, since org-roam needs the notes folder.secrets/personal.el.gpg: everything else (tags, bibliography, IRC, Mastodon and Wallabag accounts, blog folder), encrypted to my GPG key. Loaded with the first frame, as decrypting may ask for my passphrase.secrets/elfeed.score.gpg: my feed-scoring rules, encrypted; see 4.18.7.
Each setting below has a neutral default and a description. bmp/personal-check warns about any still empty after loading; sections apply them in bmp/personal-loaded-hook.
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.5. 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
fortunecommand for use indashboard - 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")
- Use System Default Browser via Systemd (Flatpak/Host Friendly)
Links open in Flatpak Firefox, started through the systemd user manager: the daemon's environment lacks the display variables a browser needs.
(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.5.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. Homebrew's programs
(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-ess-r-program "/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")))
Homebrew's lib is not added to LD_LIBRARY_PATH for all of Emacs, since every program Emacs starts would inherit it; only epdfinfo needs it, and gets it in 4.11.1.
Remove any LD_LIBRARY_PATH lines from the daemon's systemd unit too (systemctl --user edit --full emacs).
3.6. 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
- 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 haddelete-old-versionsset to 0, which also deletes without asking, and numbered backups were never turned on, so only one backup was ever kept savehist-modeandrecentf-modeare enabled after start-up in their own sections, onceno-litteringhas set their filestrusted-contentcovers~/.config/emacs/and this repository, so flymake can byte-compile Emacs Lisp there- Backup files that are under version control too
- Make suitable changes to the file name
- Keep numbered backups (
- 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 the search rings, but not the
kill-ringorregister-alist(copied passwords would end up in plain text indata/savehist.el) - 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
- Accept short answers (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
Prefer a newer
.elover its.elconly in batch runs (load-prefer-newer)(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.7. 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. Loopback pinentry needs allow-loopback-pinentry in ~/.gnupg/gpg-agent.conf. auth-source-debug stays off, since it logs every lookup to *Messages*.
(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.8. 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)
Finished web requests leave hidden *http …* buffers (some holding large images) that Emacs never closes; after ten idle minutes, those whose connection has finished are closed.
(defun bmp/close-finished-http-buffers ()
"Close the hidden *http* buffers of web requests that have finished."
(dolist (buffer (buffer-list))
(when (and (string-prefix-p " *http " (buffer-name buffer))
(not (process-live-p (get-buffer-process buffer))))
(kill-buffer buffer))))
(run-with-idle-timer (* 10 60) t #'bmp/close-finished-http-buffers)
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 which-key is built into Emacs 30 (see 3.2).
(use-package which-key
:ensure nil
: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))
Mode maps exist only once their package loads, hence with-eval-after-load.
4.1.1. Command usage
Every command I run is counted, with the key used (or M-x), to show which commands, keys and packages I actually use. Counts are saved in data/command-usage.el every ten idle minutes and on exit; typing is not counted.
M-x bmp/command-usage-report shows:
- commands I run with
M-xthat 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 forC-a/C-e, Page Up / Down forM-v/C-vand so on) - my keys that replace a stock Emacs binding, with what
emacs -Qhas 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 sections, TAB fold, g refresh, q quit). Keys typed in the minibuffer count as themselves; commands called by other commands are counted but left out of the key sections. Keys that guru-mode wraps (arrows, Home, End, Page Up/Down…) count as the command they stand for. M-x bmp/command-usage-reset starts again.
(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 use ef-themes from Prot: well tested, and they suit KDE's dark theme; other themes are disabled to avoid clashes. In the daemon the theme loads once, with the first client frame.
(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)))
- Make sure the background is set
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.
- 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-modelineas it works out of the box for all my needs. Doom Modeline is from https://github.com/seagle0128/doom-modeline/; only settings that differ from its defaults are set below.(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-unicode-fallback t) (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-total-line-number t) (setq doom-modeline-vcs-max-length 12) (setq doom-modeline-gnus-timer 0) (setq doom-modeline-gnus-excluded-groups '("dummy.group")) (setq doom-modeline-irc-stylize 'identity) (setq doom-modeline-battery nil) (setq doom-modeline-time nil) (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) (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)))
- No title bar (KDE window rule)
Under KDE on Wayland, KWin draws the title bar and Emacs cannot remove it (
undecoratedhas no effect). A window rule removes it for every Emacs frame; move with Meta+left-drag, resize with Meta+right-drag. 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 classemacs; 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 - Description:
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 Emojifor emoji, which Emacs 29+ draws in colour without any package, andSymbolafor other symbols. I used to useemojifyfor this; it is no longer needed. - One icon set,
nerd-icons(from FiraCode Nerd Font), in ibuffer, Corfu, 4.8.10 and 4.2.11; it replacedall-the-iconsandkind-icon.
(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
Emacs's built-in tab-bar-mode keeps one window layout per tab. The keys are Emacs's own on C-x t (2 new, 0 close, o / O next / previous, RET switch, r rename, u reopen, f / b / d file / buffer / directory in a new tab, p p project); C-<tab> also cycles, outside Org. Tabs are not numbered (Super belongs to KDE, and ~M-1~… are prefix arguments).
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, whendisplay-time-modeis on).
The name follows one rule per kind of buffer, so it works for any mode, including ones I install later:
- an Org file with a
#+title: the title (org-roam notes would otherwise show their timestamped file name); - any other file: the file name (indirect buffers use their base file);
- a directory (Dired and modes built on it, recognised by
dired-directory): the directory name and/; - anything else: the buffer name without surrounding
*and spaces, and without the./buffer-name-relativeadds.
Names are cut to bmp/tab-bar-name-width characters, so C-x t RET lists the same short names.
The icon comes from the mode, or its nearest parent mode that nerd-icons knows, or a generic one (folder, code, terminal, document, list, window). Inactive tabs show it dimmed.
activities.el keeps tab layouts across restarts. An activity is a named tab with a default state (as defined) and a last state (as left), saved on suspend, when idle and on exit. Activity tabs show a briefcase.
| 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 are restored through bookmarks: files, Org notes (by ID, via org-bookmark-heading), Dired, Magit, Help, Info and eshell come back; the dashboard, *Messages* 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 for a different background in non-file buffers; the ef-themes do not support it, and it is no longer installed.
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 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.
- A new client frame opens the dashboard with a fresh fortune (
initial-buffer-choicecallsbmp/dashboard-buffer);dashboard-setup-startup-hookdoes nothing in the daemon. - 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
I use nerd-icons-dired (it looks better than all-the-icons-dired). In 4.4.6 its first drawing is skipped until the listing exists, which otherwise fails with "No subdir-alist".
(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,
- Emacs's VC stays on for Git:
diff-hlandproject.eldepend on it - 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)))
(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))))
(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
(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 a click on a change mark) shows the hunk in a popup, to revert or stage it. diff-hl and org-tidy use the left fringe; flymake, treesit-fold and bm the right one.
(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)
(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)
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 treemacs as a folder sidebar; 4.4.6 replaced it, with all my Dired set-up, and it is no longer installed.
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 replaced projectile with project.el, which eglot, xref and consult already used. bmp/project-remember-search-paths adds the repositories inside bmp/project-search-paths (3.4) shortly after start-up. Switching project opens Dired.
(use-package project
:ensure nil
:custom
(project-vc-extra-root-markers '(".project"))
(project-switch-commands #'project-dired))
4.6. Org mode
4.6.1. Org configuration
Ensure that Org is loaded now as required and then add org-latex exporter along with org-latex exporter customization.
- Code blocks are in the fixed-pitch font through the theme (
ef-themes-mixed-fonts). - Export with
org-latex - Activate a bunch of languages for
org-babel - Install
org-babeldependencies
(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))
(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.2. 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))
4.6.3. Improve org list handling and provision
(use-package org-autolist
:ensure t
:hook (org-mode . org-autolist-mode))
4.6.4. 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.5. 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.8), 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.6. 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. - The site has its own theme in
assets/forgemacs/(it replaced ReadTheOrg, jQuery and Bootstrap): my website's colours with a dark mode, ET Book for text and Atkinson Hyperlegible Mono for code (licences alongside), footnotes in the margin, search in the contents (/) and copy buttons on code. bmp/export-sitebinds the export settings the theme needs (HTML5, code coloured by CSS class, no Org table borders);bmp/export-tangle-initexports and then tangles.- Added
corgto 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 (throughgoogle-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/packages--forms (form)
"Return every `use-package' and `elpaca' form inside FORM."
(when (and (consp form)
;; Quoted lists are data (such as Elpaca's own recipe), not code
(not (memq (car form) '(quote \` function))))
(if (and (memq (car form) '(use-package elpaca)) (cdr form))
(list form)
(let (found)
(while (consp form)
(setq found (nconc found (bmp/packages--forms (car form)))
form (cdr form)))
found))))
(defun bmp/packages--declared ()
"Packages this Org file sets up, in order: (NAME KEYWORDS HEADING).
Only blocks that are tangled count; code kept for reference is skipped."
(let (packages)
(org-babel-map-src-blocks nil
(let* ((info (org-babel-get-src-block-info 'no-eval))
(tangle (cdr (assq :tangle (nth 2 info)))))
(when (and (equal lang "emacs-lisp") tangle (not (equal tangle "no")))
(let ((heading (save-excursion (org-back-to-heading t)
(org-get-heading t t t t))))
(with-temp-buffer
(insert body)
(goto-char (point-min))
(condition-case nil
(while t
(dolist (form (bmp/packages--forms (read (current-buffer))))
(let ((name (if (consp (cadr form)) (car (cadr form)) (cadr form))))
;; (use-package emacs ...) holds Emacs's own settings
(when (and (symbolp name) (not (keywordp name))
(not (memq name '(emacs \`)))
(not (assq name packages)))
(push (list name (if (eq (car form) 'use-package) (cddr form) nil)
heading)
packages)))))
(error nil)))))))
(nreverse packages)))
(defun bmp/packages--loads (keywords)
"When a package with use-package KEYWORDS is loaded, in a few words."
(cond ((null keywords) "at start-up")
((plist-get keywords :demand) "at start-up")
((let ((how (seq-filter (lambda (k) (plist-member keywords k))
'(:hook :bind :bind* :commands :mode :interpreter :defer))))
(and how (concat "when needed ("
(mapconcat (lambda (k) (substring (symbol-name k) 1)) how ", ")
")"))))
((plist-get keywords :after)
(format "after %s" (mapconcat (lambda (p) (format "%s" p))
(ensure-list (plist-get keywords :after)) ", ")))
(t "at start-up")))
(defun bmp/packages--url (e)
"Web address of Elpaca package E's repository, if known."
(let* ((recipe (elpaca<-recipe e))
;; GNU ELPA recipes give (ADDRESS . NAME); MELPA ones say :fetcher, not :host
(repo (let ((r (plist-get recipe :repo))) (if (consp r) (car r) r)))
(repo (or repo (plist-get recipe :url)))
(host (or (plist-get recipe :host) (plist-get recipe :fetcher))))
(cond ((and (stringp repo) (string-match-p "\\`https?://" repo))
(string-remove-suffix ".git" repo))
((and (stringp repo) (memq host '(github gitlab codeberg sourcehut)))
(format "https://%s/%s"
(pcase host
('github "github.com") ('gitlab "gitlab.com")
('codeberg "codeberg.org") ('sourcehut "git.sr.ht")
(_ (format "%s" host)))
(if (eq host 'sourcehut) (concat "~" repo) repo))))))
(defun bmp/packages--summary (file)
"The one-line description at the top of the Emacs Lisp FILE."
(when (and file (file-exists-p file))
(require 'lisp-mnt)
(ignore-errors (lm-summary file))))
(defun bmp/packages--owner (name)
"Where library NAME comes from: (elpaca . E), (builtin . FILE) or nil.
Extensions such as vertico-directory belong to the package that ships them."
(let ((e (ignore-errors (elpaca-get name)))
(lib (locate-library (symbol-name name))))
(cond (e (cons 'elpaca e))
((null lib) nil)
((string-match "/elpaca/builds/\\([^/]+\\)/" lib)
(let ((owner (ignore-errors (elpaca-get (intern (match-string 1 lib))))))
(and owner (cons 'elpaca owner))))
((file-in-directory-p lib (expand-file-name "../lisp" data-directory))
(cons 'builtin lib)))))
(defun bmp/packages--source-file (lib)
"The .el (or .el.gz) source next to the compiled library LIB."
(let ((el (concat (file-name-sans-extension lib) ".el")))
(cond ((file-exists-p el) el)
((file-exists-p (concat el ".gz")) (concat el ".gz")))))
(defun bmp/packages--cell (string)
"STRING made safe for an Org table cell."
(string-trim (replace-regexp-in-string "|" "/" (or string ""))))
(defun bmp/packages-write (&optional dir)
"Write packages.org next to this Org file (or in DIR): every package it sets up.
The list comes from this file's `use-package' and `elpaca' forms; the
description, version and dependencies from the package itself and Elpaca."
(interactive)
(let* ((declared (bmp/packages--declared))
(names (mapcar #'car declared))
(needed nil)
(rows
(mapcar
(lambda (p)
(pcase-let* ((`(,name ,keywords ,heading) p)
(owner (bmp/packages--owner name))
(e (and (eq (car owner) 'elpaca) (cdr owner)))
(own (and e (eq (elpaca<-id e) name))) ; its own package
(url (and e (bmp/packages--url e)))
(lib (locate-library (symbol-name name)))
(main (or (and lib (bmp/packages--source-file lib))
(and own (ignore-errors
(expand-file-name (elpaca--main-file e)
(elpaca<-source-dir e))))))
(commit (and e (ignore-errors
(car (process-lines "git" "-C" (elpaca<-source-dir e)
"log" "-1" "--format=%h (%cs)")))))
(version (cond ((and e own) commit)
(e (format "part of %s, %s" (elpaca<-id e) commit))
((eq (car owner) 'builtin)
(format "built-in (Emacs %s)" emacs-version))
(t "not found")))
(deps (and own (seq-remove (lambda (d) (eq d 'emacs))
(mapcar #'car (ignore-errors (elpaca--dependencies e)))))))
;; Packages Elpaca installed only because something here needs them
(dolist (d (and own (ignore-errors (elpaca-dependencies name '(emacs)))))
(unless (or (memq d names) (not (ignore-errors (elpaca-get d))))
(cl-pushnew d needed)))
(format "| %s | %s | [[*%s][%s]] | %s | %s | %s |"
(if url (format "[[%s][%s]]" url name) name)
(bmp/packages--cell (bmp/packages--summary main))
heading (bmp/packages--cell heading)
(bmp/packages--loads keywords)
version
(bmp/packages--cell (mapconcat #'symbol-name deps ", ")))))
declared))
(file (expand-file-name "packages.org"
(or dir (file-name-directory (buffer-file-name (buffer-base-buffer)))))))
(with-temp-file file
(insert "# Generated by bmp/packages-write when the site is exported; do not edit.\n\n"
(format "%d packages are set up in this file; %d more are installed only because they need them%s.\n\n"
(length rows) (length needed)
(if needed
(concat ": " (mapconcat #'symbol-name (sort needed #'string<) ", "))
""))
"| Package | What it is | Configured in | Loads | Version | Depends on |\n|-\n"
(string-join rows "\n") "\n")
(delay-mode-hooks (org-mode))
(goto-char (point-min))
(when (re-search-forward "^|" nil t)
(org-table-align)))
(message "Wrote %s (%d packages)" file (length rows))
file))
(defun bmp/export-site ()
"Write packages.org (see `bmp/packages-write'), then export this file
to index.html with the site theme in assets/forgemacs/."
(interactive)
(bmp/packages-write)
(let ((org-html-doctype "html5")
(org-html-html5-fancy t)
;; Code is coloured by class (one per Emacs face), so the site's
;; light and dark modes can colour it, instead of this Emacs
;; theme's colours written into every span
(org-html-htmlize-output-type 'css)
;; The theme draws the table rules; Org's default attributes
;; (border, rules, frame) add borders of their own
(org-html-table-default-attributes nil)
(org-html-head-include-scripts nil)
(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
(org-html-postamble t)
(org-html-postamble-format
'(("en" "<p class=\"author\">Author: %a </p>\n<p class=\"date\">Modified: %C</p><p class=\"author\"><a href=\"mailto:mail@bharathpalavalli.com\" target=\"_blank\" rel=\"noopener\">Questions</a></p>"))))
(org-export-to-file 'html "index.html")))
(defun bmp/export-tangle-init ()
"Export this file to index.html (see `bmp/export-site'), then tangle it."
(interactive)
(bmp/export-site)
(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.7. 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.8. 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.9. 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.10. DONE Create notes for files in Org mode
org-noter keeps notes on a document outside the document, in org-roam. The maintained org-noter (1.6+) handles PDFs through pdf-tools itself, replacing org-pdftools and org-noter-pdftools. M-x org-noter in a PDF; i / M-i 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.11. 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
;; Loaded at start-up: it notes where copied text came from only once
;; it is loaded, so C-M-y can wrap it in the right source block
:demand t
:bind (:map org-mode-map
("C-M-y" . org-rich-yank)))
4.6.12. 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.13. Enable auto-tangle
(use-package org-auto-tangle
:ensure t
:after org
:hook (org-mode . org-auto-tangle-mode))
4.6.14. Embed org-files
org-transclusion embeds parts of other Org files, and marks transcluded text in the fringe itself (1.4+).
(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.15. Exporters
Multiple export options for org-mode
- 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: gfmas the format in the file(use-package org-autoexport :ensure t :after org :hook (org-mode . org-autoexport-mode)) - Export to Markdown
(use-package ox-gfm :ensure t :commands (org-gfm-export-as-markdown org-gfm-export-to-markdown) :after org ) - Pandoc Integration
I have included the use of
ox-pandoc, to ensure that all default options are used, I preferxelatexoverpdflatexand have set-upgithuboptions 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-modein 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) ) - Presentation in HTML mode for org with org-re-reveal
org-re-reveal adds reveal.js presentations to the export menu (
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") ) - Ox-hugo
(use-package ox-hugo :ensure t :after ox)
4.6.16. 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 kept in
data/orgfold/, not in a.foldfile next to each note. - Folds are detected with
invisible-pand restored withorg-fold(Org 9.6+ folds with text properties). - Files without saved state follow
#+startup:and:VISIBILITY:;M-x bmp/orgfold-forgetdrops a file's saved state. - Only buffers visiting a file are handled.
(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.17. Add bookmark support
Bookmarks for Org headings, from org-bookmark-heading. Org's own automatic bookmarks after capture and refile (org-capture-last-stored) are turned off; C-x r m still works.
(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.18. 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.
It used to run on every Space in Org buffers, sending call-sign-like words to QRZ.com and blocking typing; now it only runs on request.
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: Converts the HAM radio call sign at point
;; to a QRZ.com link, on request
;; ============================================================
(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
The authinfo entries langtool-jar, langtool-java-bin and langtool-prop are no longer used. For automatic language detection (auto), add fasttextModel=/path/to/lid.176.bin to /home/linuxbrew/.linuxbrew/etc/languagetool/server.properties (model).
Fixes and additions to langtool.el:
- Encoding: text travels to the server percent-encoded, but Emacs's
url-build-query-stringleaves%and+as they are. Any%in a note (CSS such asmax-width: 100%;) made the server reject the whole request with "Could not decode query", and every+arrived as a space.bmp/langtool--query-stringencodes every value fully. No rule is involved. - Only prose is checked: code, results, example and export blocks, drawers, keyword lines, LaTeX and link addresses are blanked out (positions stay the same), so even notes full of embedded HTML can be checked.
- Size: a check stops with a message when the prose is over
bmp/langtool-max-chars;M-x bmp/langtool-check-sectionchecks the heading at point (with its subheadings) instead. A selected region is checked on its own withM-x langtool-checktoo. - Rules:
WHITESPACE_RULE,MORFOLOGIK_RULE_EN_GB(jinx does spelling) andOXFORD_SPELLING_Z_NOT_S(I write -ise) are off.M-x bmp/langtool-ruleturns off the rule at point (for the buffer, the session, or for good indata/langtool-disabled-rules.el);M-x bmp/langtool-ruleslists and restores them.
(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))
;; One flash (blue) where the cursor is when the minibuffer opens
(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)
;; Smartparens highlights matching pairs (Org markup like =…= too), so
;; Emacs's own bracket highlighting, on by default, is turned off
(show-smartparens-global-mode t)
(show-paren-mode -1)
(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
Built-in electric-pair-mode pairs brackets everywhere; 4.7.3 takes over in Org, CSS and Python.
(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. Its colours come from the current ef theme. Emacs's own C-/ undoes and C-? redoes; undo-fu-session keeps a file's undo history across restarts (not for Org files).
(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-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
Markdown, often from other people or tools, looks and reads like my Org buffers:
- Markup is hidden;
C-c C-x C-mtoggles it (per buffer),C-c C-x C-ltoggles link addresses. - Org's fonts and colours (via
ef-themes-mixed-fonts), wrapped lines, and tables drawn byvalign. - Code blocks are coloured in their own language, list bullets are drawn as symbols and
$…$maths is highlighted. - Images are off when a file opens;
C-c C-x C-ishows local ones. Remote images are never fetched (they can be tracking pixels). - Preview (
C-c C-c l) and export (C-c C-c p,C-c C-c e) use pandoc.
This applies to gfm-mode (READMEs) too.
(use-package markdown-mode
:ensure t
:mode ("README\\.md\\.njk\\'" . gfm-mode)
:hook ((markdown-mode . variable-pitch-mode)
(markdown-mode . visual-line-mode)
(markdown-mode . valign-mode))
:custom
(markdown-command '("pandoc" "--from=gfm" "--to=html5"))
;; Markup hidden; C-c C-x C-m and C-c C-x C-l show it in this buffer
(markdown-hide-markup t)
(markdown-hide-urls t)
(markdown-fontify-code-blocks-natively t)
(markdown-list-item-bullets '("•" "◦" "–"))
(markdown-enable-math t)
;; Images: local ones on request (C-c C-x C-i), never from the web
(markdown-display-remote-images nil)
(markdown-max-image-size '(300 . nil)))
(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
;; Installed in [[*Using Elpaca.el]], before casual pulls it in
: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-httpdfor 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 uses the local LibreTranslate server (1.6), so text never leaves this machine. Select a region and run M-x bmp/translate, then pick the source (auto guesses, but takes Esperanto for Spanish; use eo) and target languages. The result goes to the echo area and the kill ring; with C-u it replaces the region. The server starts on demand and stops after bmp/translate-idle-minutes idle; M-x bmp/translate-add-language adds a language.
(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
: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
- 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 - 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 - 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 - 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 - 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 - Org Reveal New Slide
# -*- mode: snippet -*- # name: Reveal split slide # key: !!rebr # -- #+REVEAL: split $0 - Org Reveal do not add to ToC
# -*- mode: snippet -*- # name: Reveal no ToC # key: !!rentoc # -- :PROPERTIES: :UNNUMBERED: notoc :END: $0 - 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 - Org Reveal Title Slide
# -*- mode: snippet -*- # name: Reveal Title # key: !!ret # -- #+REVEAL_TITLE_SLIDE: ${1:HTMLTitle} $0 - 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")`/ - 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
- 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 gwhen 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)) - Distraction free writing
After considering both Darkroom and Olivetti, I have settled on using
writeroommode 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)) - Thesaurus
emacs-powerthesaurusis 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) - Search and replace
ripgrepcan be handled in a number of ways in Emacs, I already haveconsult-ripgrepworking, 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)recollis 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.6).
(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))
- 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-ein 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-yasnippetto help with expandyasnippet- Consult 3.x previews candidates in
*Completions*automatically, and removedconsult-preview-at-point-mode. I had it hooked tocompletion-list-modeandembark-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-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-directoryto navigate directories in a manner similar toIdo
(use-package vertico
:ensure t
: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 matches input in any order. The configuration is minad's, with SPC and ? freed in the minibuffer. Completion styles are set only here.
(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.5.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 Eshellis customized to start from the starting of the pageShell commands can now be executed using https://github.com/xenodium/dwim-shell-command
;; Applied when eshell is first used, rather than loading it at start-up (with-eval-after-load '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
Zotero with Better BibTeX manages my references: Better BibTeX keeps .bib exports current (listed in bmp/bibliography, see 3.4) and citar reads them (4.18.1). zotxt and zotfile are gone (zotfile does not work with Zotero 7).
C-c C-x @in Org inserts a citation (Org's built-in[cite:@key], exported throughorg-cite), with completion from both bibliographies.C-c b(citar-open) opens a reference's PDF (from thefilefield 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 throughcitar-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 this 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
cdlatexto deal with AUCTeX and Emacs - Use
pdf-toolsto 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))))))))
;; AUCTeX's preview library is `preview'; this used to say `preview-latex',
;; which does not exist, so opening a LaTeX file reported an error. The
;; factor was 1.0, which left previews at AUCTeX's own size.
(defvar bmp/latex-preview-scale 1.25
"How much larger LaTeX previews are than the text around them.")
(use-package preview
:ensure nil
:after latex
:custom
;; AUCTeX speeds up previews by saving the preamble as a TeX format;
;; XeTeX (my engine) cannot save one, the attempt failed and the
;; previews that followed broke ("End of Preview snippet 1
;; unexpected"). Without the cache each preview reads the preamble
;; again, which is slower but works.
(preview-auto-cache-preamble nil)
:config
;; AUCTeX sizes previews to match the text (and follows zooming);
;; keep that, a little larger
(setq preview-scale-function
(lambda ()
(let ((match-text (preview-scale-from-face)))
(lambda () (* bmp/latex-preview-scale (funcall match-text)))))))
(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)
Every LaTeX file gets a live PDF: latexmk rebuilds it on each save (same engine, SyncTeX on) and shows it beside the file. M-x bmp/latex-live-pdf stops or restarts it; set bmp/latex-live-pdf-automatically to nil to start it only by hand. LaTeX's errors are in the *latexmk-live …* buffer.
Typing \ offers LaTeX commands straight away (elsewhere Corfu waits for three characters).
(defvar bmp/latex-live-pdf-automatically t
"Start the live PDF (`bmp/latex-live-pdf') whenever a LaTeX file is opened.")
(defun bmp/latex-live-pdf--name (tex)
"Name of the live-PDF process for the LaTeX file TEX."
(format "latexmk-live %s" (abbreviate-file-name tex)))
(defun bmp/latex-live-pdf--stop (tex)
"Stop rebuilding the PDF of TEX. Return non-nil if it was running."
(let ((proc (get-process (bmp/latex-live-pdf--name tex))))
(when (process-live-p proc)
(interrupt-process proc)
(delete-process proc)
t)))
(defun bmp/latex-live-pdf--start ()
"Rebuild this LaTeX document's PDF on every save and show it beside it.
Does nothing if it is already running."
(let* ((tex (expand-file-name (TeX-master-file "tex")))
(pdf (concat (file-name-sans-extension tex) ".pdf"))
(name (bmp/latex-live-pdf--name tex)))
(unless (process-live-p (get-process name))
(let ((default-directory (file-name-directory tex)))
(make-process
:name name
:buffer (get-buffer-create (format "*%s*" name))
:noquery t
:command (list "latexmk"
(pcase TeX-engine
('xetex "-xelatex") ('luatex "-lualatex") (_ "-pdf"))
"-pvc" "-view=none" "-interaction=nonstopmode"
"-synctex=1" (file-name-nondirectory tex))))
(add-hook 'kill-buffer-hook (lambda () (bmp/latex-live-pdf--stop tex)) nil t)
;; Show the PDF as soon as the first build has made it
(let ((tries 0) timer)
(setq timer
(run-with-timer
1 1 (lambda ()
(setq tries (1+ tries))
(cond ((file-exists-p pdf)
(cancel-timer timer)
(display-buffer (find-file-noselect pdf) t))
((or (> tries 120) (not (get-process name)))
(cancel-timer timer)
(message "Live PDF: no PDF yet; see the *%s* buffer" name)))))))
(message "Live PDF: rebuilding %s on every save" (file-name-nondirectory tex)))))
(defun bmp/latex-live-pdf ()
"Stop the live PDF of this LaTeX document, or start it if it is not running."
(interactive)
(if (bmp/latex-live-pdf--stop (expand-file-name (TeX-master-file "tex")))
(message "Live PDF stopped")
(bmp/latex-live-pdf--start)))
(defun bmp/latex-live-pdf-maybe ()
"Start the live PDF for this file once its local settings are known."
(when (and bmp/latex-live-pdf-automatically buffer-file-name)
;; After file-local variables, so a TeX-master set there is used
(add-hook 'hack-local-variables-hook #'bmp/latex-live-pdf--start nil t)))
(add-hook 'LaTeX-mode-hook #'bmp/latex-live-pdf-maybe)
(defun bmp/latex-complete-after-backslash ()
"Offer LaTeX commands as soon as \\ is typed."
(when (require 'corfu-auto nil t)
(setq-local corfu-auto-trigger "\\")
(setq-local corfu-auto-commands (cons 'TeX-insert-backslash corfu-auto-commands))))
(add-hook 'LaTeX-mode-hook #'bmp/latex-complete-after-backslash)
Minor mode to help with tables in LaTeX with latex-table-wizard
(use-package latex-table-wizard
:ensure t
;; With AUCTeX's LaTeX mode; `:after transient' loaded it at every
;; start-up, since magit and others load transient
:after latex)
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
;; Loaded by its mode in LaTeX buffers, not at start-up
:defer 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-extra. It moves between sections with C-c C-n / C-c C-p, which is why it moves AUCTeX's preview keys from C-c C-p to C-c p (so C-c p C-b previews the whole buffer, C-c p C-p the thing at point, and C-c p C-c C-b / C-c p C-c C-p remove those previews again) and the font keys from C-c C-f to C-c f.
(use-package latex-extra
:ensure t
;; Loaded by its mode in LaTeX buffers. Loaded at start-up, it pulled
;; in all of AUCTeX (and everything set to load after it) every time
:defer 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 is the language server, through eglot. C-c C-p toggles typst-preview, a live preview in the browser that follows the cursor.
(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
Code uses the built-in tree-sitter modes, and eglot for language servers.
4.15.1. Eglot
Emacs Polyglot is mostly preconfigured, here is an elementary configuration. It uses Emacs's built-in eldoc (see 3.2).
- Added bash-language-server
- Added clangd llvm
- Added gopls
- Eglot starts only for the modes listed below (and their
-ts-modeversions).
(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
Built-in flymake checks code and vale checks prose: bmp/flymake-vale-setup turns vale on in Org, Markdown and text files that visit a file. Blog posts (with #+hugo_base_dir:) use the publish configuration; M-x bmp/vale-toggle-config switches. Files with lines longer than bmp/vale-max-line-length are skipped (vale cannot read them). C-c x l lists diagnostics; C-c x n / C-c x p move between them (C-c ! is Org's timestamp key).
(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
eldoc shows the documentation for the thing at point in the echo area, in every buffer. It is Emacs's built-in one, turned on in 3.2, where its documentation is kept to a single line.
4.15.4. DONE Parsing programming languages
Built-in tree-sitter parses code, and treesit-fold folds it (it replaced ts-fold, written for the old external tree-sitter package).
(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
- Emacs ESS
Using Emacs with R using https://ess.r-project.org/Manual/ess.html
(use-package ess :ensure t ;; Loaded by its R file mode and M-x R, not at start-up (it ran a timer ;; every idle second even when I was not using R) :defer 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)))) - Python
I used
elpyfor Python, but it is no longer actively maintained and Emacs now covers the same ground with built-in pieces:python-ts-mode(chosen automatically bytreesit-auto) for editingeglotwithbasedpyrightfor completion, documentation and jumping to definitions (see 4.15.1)flymakewithrufffor checking. Eglot is told to stay out of flymake, so ruff's diagnostics are the ones shown.
Python blocks in Org run in a virtualenv,
~/.local/share/emacs-python, built on Homebrew'spython3with the packages my notes need (it isorg-babel-python-command). When Homebrew moves to a new Python minor version the virtualenv breaks, sobmp/python-venv-checkwarns at start-up; the block below rebuilds it.(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 secondpip installline 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 - GO
Go files open in the built-in
go-ts-mode(chosen bytreesit-auto), withgoplsthrough 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 replacesgo-mode, whosegodefcommands 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) - Rust
Rust files open in the built-in
rust-ts-mode(chosen bytreesit-auto), withrust-analyzerthrough eglot. Files are formatted (rustfmt, through rust-analyzer) when saved, which replacesrust-format-on-savefromrust-mode.(add-hook 'rust-ts-mode-hook #'bmp/eglot-format-on-save) (add-hook 'rust-ts-mode-hook #'prettify-symbols-mode)
4.16. AI assistants
Two complementary tools for working with Claude inside Emacs:
gptelfor in-buffer work, especially Org files (rewriting regions, chats that live in Org buffers). Billed against the Claude API key in.authinfo.gpg.agent-shellfor 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-sendsends the region, or the buffer up to point, to Anthropic.gptel-rewritesends the region. Anything added withgptel-add(files, buffers) is sent with every request until removed. Be careful in~/Notesand anything with personal details.agent-shellruns 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) come from Homebrew, already on the daemon's PATH; install the ACP adapter with Homebrew's npm. Log in once with claude (subscription), then /exit. Never export ANTHROPIC_API_KEY, or Claude Code 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-passwordfrom 3.7. 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-contextmakes 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 setsANTHROPIC_API_KEYto 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.elappendsprocess-environment), so it findsnode,claudeandclaude-agent-acpon the HomebrewPATHwithout 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>/throughagent-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
emojifyis no longer used (mastodon-use-emojifyisnil)
(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))))
- DONE Show per-instance custom emoji in toots
Custom emoji (
:blobcat:and similar) belong to an instance, and mastodon.el only shows my own instance's (throughemojify).bmp/mastodon-emoji-applyshows each post's custom emoji as images, from the list the server sends with every post. Images are cached per instance indata/mastodon-emoji/<instance>/; the instance comes from the author's account, since my instance proxies every image address.(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
;; Loaded when I connect (M-x ement-connect), not at start-up
:defer 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
;; Emacs's own ERC; no package needs a newer one
:ensure nil
;; Loaded when I connect, not at start-up
:defer 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-uifrom https://github.com/org-roam/org-roam-ui/issues/289#issuecomment-1830366138 - Lists subdirectories and current directory
- Custom
org-roam-capturethat 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-roamfor greater flexibility - This is from https://gist.github.com/d12frosted/a60e8ccb9aceba031af243dff0d19b2e
- I was using the functions for
org-roam-bibtex,bibtex-completionandorg-reffrom https://github.com/progfolio/elpaca/issues/263, but have now reverted it to the basic definitions - Added two functions for taking notes on tea
bmp/org-roam-templatesbuilds every capture template, for~/Notesor a subdirectory (whose name becomes a tag).C-c n bfinds or creates a note in a subdirectory,C-c n ncaptures to one andC-c n ainserts a link to one; leave the subdirectory empty for the top.- 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-protocolcaptures 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
;; On the notes prefix, not Super (which belongs to KDE)
(:map org-mode-map
(("C-c n Y" . org-download-screenshot)
("C-c n 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
Templates for book notes, authors, podcasts, RSS feeds and tea notes, tangled into ~/Notes/Templates/; org-roam fills in the ${...} fields. TeaNotesTemplate.org is kept but not offered: bmp/tea-note asks for the same details.
- AuthorTemplate
◉ Notes %? ◉ Books ** ◉ Link: - BookNotesTemplate
◉ Book Information *◉ Title: ${title} *◉ Author: %Author *◉ Year: %Year *◉ Genre: ◉ Notes %? - PodcastTemplate
◉ Podcast Details *◉ Name: ${Name} *◉ RSS Feed: %Rssfeed *◉ Website: %Website *◉ Genre: ◉ Notes %? - RSSFeedTemplate
◉ Feed Details *◉ Name: ${Name} *◉ Type: ${Type} *◉ RSS Feed: %Rssfeed *◉ Website: %Website *◉ Description: ◉ Notes %? - 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:
- Open the Library with
Ctrl+Shift+Oand choose Bookmarks Toolbar (show the toolbar withCtrl+Shift+B). - 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-csaves it andC-c C-kcancels. - Clicking it again on a page that already has a note opens that note instead of creating a duplicate.
- The
template=rpart of the address picks the "Web page" template inorg-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-protocolshould printorg-protocol.desktop; then restart Firefox. - The wrong application opens: change it under Firefox's Settings → General → Applications (
org-protocol). - On KDE,
xdg-mimeprints "qtpaths: command not found" (Aurora only has the Qt 6qtpaths6); 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/a60e8ccb9aceba031af243dff0d19b2eI have now moved to ~org-mem~ based on the comment at the Vulpea gist.org-mem-updater-modeis 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-memforces a full rescan if needed.- In addition I am using
org-qlto 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 someFixed: the scan now starts by itself after start-uporg-roamnode before the agenda loads properly- Deleted files are dropped from recent files and Org's ID list before scanning (
bmp/org-mem-forget-missing); a missing folder stopped the whole scan bmp/truename-cache-dedupremoves the duplicate names org-mem's file-name cache adds on every scan (it grew to 57 MB in four days and slowed every garbage collection)- Saved files are picked up at once; the full rescan, which catches changes from other devices, waits for
bmp/org-mem-rescan-idle-minutes(ten) idle minutes - When a task in a file outside org-roam is marked DONE,
bmp/log-done-task-to-dailyadds 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-habitfor now - Add
org-super-agendato 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)
(defun bmp/truename-cache-dedup (&rest _)
"Remove the duplicate names truename-cache adds on every org-mem scan.
It records each file's names with `push' even when they are already
there, so the cache grew by thousands of entries a scan (57 MB after
four days) and every garbage collection got slower. Each distinct name
is kept, so the cache stays valid."
(when (boundp 'truename-cache--true<>wilds)
(maphash (lambda (truename names)
(puthash truename (delete-dups names) truename-cache--true<>wilds))
truename-cache--true<>wilds)))
(add-hook 'org-mem-post-full-scan-functions #'bmp/truename-cache-dedup)
(defvar bmp/org-mem-rescan-idle-minutes 10
"Minutes of idle time before org-mem rescans all my notes.")
(defun bmp/org-mem-rescan-timer (&rest _)
"Rescan all notes after `bmp/org-mem-rescan-idle-minutes' idle minutes.
org-mem's own delay is ten times the last scan's duration, which for my
notes meant a full rescan every 20 idle seconds. Saved files are picked
up at once anyway; the full rescan only catches files changed outside
Emacs (Syncthing), so it does not need to run that often."
(unless (member org-mem-updater--reset-timer timer-idle-list)
(setq org-mem-updater--reset-timer
(run-with-idle-timer (* 60 bmp/org-mem-rescan-idle-minutes) t
#'org-mem--scan-full))))
(with-eval-after-load 'org-mem-updater
(advice-add 'org-mem-updater-adjust-reset-timer :override #'bmp/org-mem-rescan-timer))
(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-protocolreads the FreshRSS account fromelfeed-feedsagain rather thanelfeed-protocol-feeds. The feed ID has the formfever+https://user@host(stored in authinfo asfreshrss-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 withG(elfeed-search-fetch) in the search buffer, orM-x elfeed-update - I have added
elfeed-scoreto ensure posts of interest surface - Added
cuckoo-searchto help with searching withinelfeedfrom https://github.com/rtrppl/cuckoo-search - TLS verification is left on. I used to pass
--insecureto 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.4), 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 underdata/, 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-hookonly runs once - Enable
verticoforjinx - 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)
5. Packages
Every package this file sets up, regenerated by bmp/packages-write (4.6.6) on each export: its description, version, dependencies and when it loads. It lives in packages.org, so git shows the package changes in each version. New packages pass 3.3 first.
143 packages are set up in this file; 47 more are installed only because they need them: acp, async, avy, burly, casual, casual-avy, casual-symbol-overlay, citeproc, cond-let, dash, el-job, elisp-refs, elpaca, emacsql, esxml, ht, htmlize, hydra, jeison, llama, lv, modus-themes, ov, parsebib, persist, plz, posframe, pretty-hydra, qrencode, queue, s, shell-maker, shrink-path, spinner, string-inflection, svg-lib, symbol-overlay, tablist, taxy, taxy-magit-section, tomelr, tp, truename-cache, ts, visual-fill-column, websocket, with-editor.
| Package | What it is | Configured in | Loads | Version | Depends on |
|---|---|---|---|---|---|
| elpaca-use-package | Elpaca use-package support | Using Elpaca.el | at start-up | 5b0cbb1 (2026-08-27) | elpaca, use-package |
| project | Operations on the current project | Using Elpaca.el | at start-up | eade0abc31 (2026-09-10) | xref |
| org | Outline-based notes management and organizer | Using Elpaca.el | at start-up | c8286c97c (2026-09-05) | |
| eldoc | Show function arglist or variable docstring in echo area | Using Elpaca.el | at start-up | built-in (Emacs 30.2) | |
| compat | Emacs Lisp Compatibility Library | Using Elpaca.el | at start-up | 90880f8 (2026-09-13) | |
| transient | Transient commands | Using Elpaca.el | at start-up | 03c8ccc (2026-09-09) | compat, cond-let, llama, seq |
| consult | Search and navigate via completing-read | Using Elpaca.el | at start-up | 1b217ca (2026-09-07) | compat |
| csv-mode | Major mode for editing comma/char separated values | Using Elpaca.el | at start-up | a693259 (2026-09-07) | cl-lib |
| no-littering | Help keeping ~/.config/emacs clean | Setting up some basic parameters | at start-up | 9bb4669 (2026-09-01) | compat |
| which-key | Display available keybindings in popup | Help with Emacs commands | at start-up | built-in (Emacs 30.2) | |
| which-key-posframe | Using posframe to show which-key | Help with Emacs commands | at start-up | e4a9ce9 (2023-03-13) | posframe, which-key |
| helpful | A better help buffer | Help with Emacs commands | at start-up | 03756fa (2025-04-07) | dash, s, f, elisp-refs |
| guru-mode | Become an Emacs guru | Help with Emacs commands | at start-up | a3370e5 (2021-10-25) | |
| casual-suite | A suite of opinionated Transient UIs | Help with Emacs commands | at start-up | ea305a1 (2026-08-31) | casual-avy, casual-symbol-overlay |
| ef-themes | Colorful and legible themes | Theme | at start-up | e1f6176 (2026-08-14) | modus-themes |
| rainbow-mode | Colorize color names in buffers | Display background colour for strings with the colour value | at start-up | 9d333d3 (2026-09-05) | |
| spacious-padding | Increase the padding/spacing of frames and windows | Padding between elements | when needed (hook) | 03d4758 (2026-07-24) | |
| doom-modeline | A minimal and modern mode-line | Doom modeline | at start-up | adbd632 (2026-09-02) | compat, nerd-icons, shrink-path |
| nerd-icons | Emacs Nerd Font Icons Library | Fonts | at start-up | 17faac7 (2026-08-22) | |
| nerd-icons-ibuffer | Display nerd icons in ibuffer | Fonts | when needed (hook) | 19b28bc (2026-10-11) | nerd-icons |
| nerd-icons-corfu | Icons for Corfu via nerd-icons | Fonts | after corfu | 9b089b3 (2026-09-04) | nerd-icons |
| activities | Save/restore sets of windows, tabs/frames, and their buffers | Tabs | when needed (bind) | 5025962 (2025-11-18) | persist |
| dashboard | A startup screen extracted from Spacemacs | Dashboard | at start-up | a2c49ba (2026-08-08) | |
| nerd-icons-dired | Shows icons for each file in dired mode | Nerd Icons in dired |
when needed (hook) | 104acd8 (2026-04-15) | nerd-icons |
| focus | Dim the font color of text in surrounding sections | Focus mode | at start-up | a58e29e (2025-03-11) | cl-lib |
| magit | A Git porcelain inside Emacs | Magit | when needed (bind) | 9cb07d82 (2026-09-07) | compat, cond-let, llama, magit-section, seq, transient, with-editor |
| diff-hl | Highlight uncommitted changes using VC | Diff-hl | at start-up | 0e1d464 (2026-09-09) | cl-lib |
| winner | Restore old window configurations | Winner mode | when needed (defer) | built-in (Emacs 30.2) | |
| buffer-name-relative | Relative buffer names | Relative buffer names | at start-up | a2e902c (2025-12-14) | |
| bufler | Group buffers into workspaces with programmable rules | Buffer management | at start-up | b96822d (2025-03-27) | burly, dash, f, pretty-hydra, magit-section, map |
| ibuffer | operate on buffers like dired | Buffer management | when needed (hook, defer) | built-in (Emacs 30.2) | |
| consult-dir | Insert paths into the minibuffer prompt | Quick navigation in the mini-buffer | after vertico | 1497b46 (2025-10-19) | project, consult |
| dired-quick-sort | Persistent quick sorting of Dired buffers in various ways. | Dired sorting | after dired | 3c9b417 (2026-03-31) | |
| dired-sidebar | Tree browser leveraging dired | Dired sidebar | when needed (bind, commands) | af43b81 (2026-08-06) | compat |
| ready-player | Open media files in ready-player major mode | Media player preview | at start-up | e7737e5 (2026-02-24) | |
| dired-preview | Automatically preview file at point in Dired | Dired preview | after ready-player | 090eded (2026-06-10) | |
| bookmark | set bookmarks, maybe annotate them, jump to them later | Handling bookmarks | when needed (defer) | built-in (Emacs 30.2) | |
| bm | Visible bookmarks in buffer. | Handling bookmarks | at start-up | ea958b9 (2026-03-29) | |
| ob-http | http request in org-mode babel | Org configuration | after org | b1428ea (2018-07-07) | s, cl-lib |
| org-appear | Auto-toggle Org elements | Toggle appearance of elements | when needed (hook) | 77d23ef (2026-07-17) | org |
| org-link-beautify | Beautify Org Links | Beautify links in org | after org | 1e88f09 (2026-08-27) | nerd-icons, qrencode |
| org-inline-pdf | Inline PDF previewing for Org | Enable in-line preview of PDFs | after pdf-tools | f4c2bac (2023-08-26) | org |
| org-autolist | Improved list management in org-mode | Improve org list handling and provision | when needed (hook) | 0f5dc4e (2022-07-10) | |
| org-tidy | A minor mode to tidy org-mode buffers | Clean up org property drawers | when needed (hook) | 0bea3a2 (2024-12-12) | dash |
| org-modern | Modern looks for Org | Bullets customization | when needed (hook) | d106d02 (2026-08-18) | org, compat |
| org-modern-indent | Indent blocks like org-modern | Bullets customization | at start-up | 86bd83e (2026-07-21) | org, compat |
| corg | Header completion for org-mode | Org-babel | at start-up | 0eeb425 (2026-04-06) | s, compat |
| ob-compile | Run compile by org-babel | Org-babel | at start-up | d9c3e44 (2024-03-24) | |
| org-fragtog | Auto-toggle Org LaTeX fragments | LaTeX previews with Org | when needed (hook) | 562f659 (2023-08-14) | |
| valign | Visually align tables | Customizing tables | at start-up | c63face (2026-04-16) | |
| djvu | Edit and view Djvu files via djvused | Create notes for files in Org mode | at start-up | 1251c94 (2022-12-21) | |
| org-noter | A synchronized, Org-mode, document annotator | Create notes for files in Org mode | after pdf-tools | feae91c (2026-07-29) | cl-lib, org |
| org-rich-yank | Paste with org-mode markup and link to source | Rich paste | at start-up | fe2ba1c (2025-09-23) | |
| toc-org | add table of contents to org-mode files (formerly, org-toc) | Table of contents | after org | 6916501 (2026-05-14) | |
| org-auto-tangle | Automatically and Asynchronously tangles org files on save | Enable auto-tangle |
when needed (hook) | b4e7abc (2025-12-30) | async |
| org-transclusion | Transclude text content via links | Embed org-files | when needed (bind) | feda2f0 (2026-04-17) | org |
| org-autoexport | Auto-export org file on save | Auto export as and when needed | when needed (hook) | 90b8646 (2025-05-02) | org |
| ox-gfm | Github Flavored Markdown Back-End for Org Export Engine | Export to Markdown | when needed (commands) | 4f774f1 (2023-12-15) | |
| ox-pandoc | An Org-mode exporter using pandoc | Pandoc Integration | after org | 1caeb56 (2026-01-13) | org, dash, ht |
| pandoc-mode | Minor mode for interacting with Pandoc | Pandoc Integration | after org | 8f46da9 (2026-01-14) | |
| org-re-reveal | Org export to reveal.js presentations | Presentation in HTML mode for org with org-re-reveal | after org | 9f09f21 (2026-03-27) | org, htmlize |
| ox-hugo | Hugo Markdown Back-End for Org Export Engine | Ox-hugo | after ox | b7dc44d (2025-12-06) | tomelr |
| org-bookmark-heading | Emacs bookmark support for Org mode | Add bookmark support | after org | bcab006 (2024-09-06) | compat |
| jinx | Enchanted Spell Checker | Spell-checking | when needed (hook, bind) | 23f6d02 (2026-08-13) | compat |
| langtool | Grammar check utility using LanguageTool | Spell-checking | at start-up | 6605f26 (2024-12-06) | |
| pulsar | Pulse highlight on demand or after select functions | Highlighting line | at start-up | b82c595 (2026-09-04) | |
| smartparens | Automatic insertion, wrapping and paredit-like navigation with user defined pairs. | Smart Parentheses | at start-up | 82d2cf0 (2026-01-29) | dash |
| recentf | keep track of recently opened files | Recent files | when needed (hook) | built-in (Emacs 30.2) | |
| vundo | Visual undo tree | Undo | when needed (bind) | 04caf7e (2026-09-05) | |
| undo-fu-session | Persistent undo, available between sessions | Undo | at start-up | 92d733a (2026-02-09) | |
| markdown-mode | Major mode for Markdown-formatted text | Markdown | when needed (hook, mode) | 76cb4ff (2026-08-27) | |
| simple-httpd | Pure Elisp HTTP server | HTML | at start-up | 0c17124 (2026-09-04) | compat |
| impatient-mode | Serve buffers live over HTTP | HTML | at start-up | 4bb8009 (2026-04-26) | simple-httpd, htmlize |
| yasnippet | Yet another snippet extension for Emacs | Using templates | at start-up | c1e6ff2 (2025-06-04) | cl-lib |
| yasnippet-snippets | Collection of yasnippet snippets | Using templates | after yasnippet | 606ee92 (2025-12-15) | yasnippet |
| writegood-mode | Polish up poor writing on the fly | Grammar | at start-up | d54eade (2022-05-11) | |
| writeroom-mode | Minor mode for distraction-free writing | Distraction free writing | at start-up | cca2b4b (2025-02-05) | visual-fill-column |
| powerthesaurus | Powerthesaurus integration | Thesaurus | at start-up | 4b97797 (2023-04-26) | jeison, s |
| deadgrep | fast, friendly searching with ripgrep | Search and replace | at start-up | 8525723 (2026-06-29) | dash, s, spinner |
| consult-recoll | Recoll queries using consult | Search and replace | at start-up | 4f9865d (2025-02-23) | consult |
| calc | the GNU Emacs calculator | Using calculator | when needed (defer) | built-in (Emacs 30.2) | |
| savehist | Save minibuffer history | Saving minibuffer history | when needed (hook) | built-in (Emacs 30.2) | |
| dabbrev | dynamic abbreviation package | Dynamic abbreviation | when needed (commands) | built-in (Emacs 30.2) | |
| minibuffer | Minibuffer and completion functions | Minibuffer | at start-up | built-in (Emacs 30.2) | |
| miniedit | Enhanced editing for minibuffer fields. | Editing mini-buffer | when needed (commands) | e12bf65 (2010-04-19) | |
| corfu | COmpletion in Region FUnction | Corfu | when needed (bind) | 5869254 (2026-09-05) | compat |
| cape | Completion At Point Extensions | Corfu | when needed (bind) | 1c543fc (2026-09-05) | compat |
| consult-yasnippet | A consulting-read interface for yasnippet | Consult | at start-up | 89e3988 (2026-06-13) | yasnippet, consult |
| embark | Conveniently act on minibuffer completions | Embark | when needed (bind) | 87e5382 (2026-08-12) | compat |
| embark-consult | Consult integration for Embark | Embark | after consult | 87e5382 (2026-08-12) | compat, embark, consult |
| vertico | VERTical Interactive COmpletion | Vertico | at start-up | 8581ed1 (2026-09-07) | compat |
| vertico-directory | Ido-like directory navigation for Vertico | Vertico | when needed (hook, bind) | part of vertico, 8581ed1 (2026-09-07) | |
| orderless | Completion style for matching regexps in any order | Orderless | at start-up | 5806e3f (2026-09-09) | compat |
| marginalia | Enrich existing commands with completion annotations | Marginalia | when needed (bind, defer) | c5d0139 (2026-09-07) | compat |
| nerd-icons-completion | Add icons to completion candidates | Marginalia | after marginalia | 45b585d (2026-04-11) | nerd-icons, compat |
| dwim-shell-command | Shell commands with DWIM behaviour | Shell customization | when needed (bind) | 3566651 (2026-07-08) | |
| pdf-tools | Support library for PDF documents | PDF Handling | when needed (hook, mode) | 365f882 (2026-01-08) | tablist, let-alist |
| nov | Featureful EPUB reader mode | EPUBs | at start-up | 874daf5 (2025-12-13) | esxml |
| calibredb | Yet another calibre client | Calibre | when needed (defer) | 4fefffd (2026-08-21) | org, transient, s, dash, request, esxml |
| auctex | Integrated environment for TeX | LaTeX | when needed (mode, defer) | 7a460dcf (2026-09-09) | |
| latex | Support for LaTeX documents. | LaTeX | when needed (hook, bind) | part of auctex, 7a460dcf (2026-09-09) | |
| preview | embed preview LaTeX images in source buffer | LaTeX | after latex | part of auctex, 7a460dcf (2026-09-09) | |
| cdlatex | Fast input methods for LaTeX environments and math | LaTeX | when needed (hook, bind) | fac070f (2024-10-07) | |
| latex-table-wizard | Magic editing of LaTeX tables | LaTeX | after latex | 0ccd242 (2026-05-03) | auctex, transient |
| math-delimiters | Insert math delimiters in TeX, LaTeX and Org buffers | LaTeX | at start-up | a2ea2c6 (2026-01-22) | |
| latex-change-env | Change in and out of LaTeX environments | LaTeX | when needed (bind) | c39f8fb (2025-02-10) | auctex |
| latex-labeler | Simplify equation labeling in LaTeX | LaTeX | when needed (defer) | afb5b51 (2026-09-03) | |
| latex-extra | Adds several useful functionalities to LaTeX-mode. | LaTeX | when needed (defer) | 81507c1 (2024-09-09) | auctex, cl-lib |
| typst-ts-mode | Tree Sitter support for Typst | Typst | at start-up | 1367003 (2024-12-07) | |
| typst-preview | Live preview of typst | Typst | when needed (bind) | f2903a1 (2026-06-17) | websocket, compat |
| mpv | control mpv for easy note-taking | Handling audio & video | at start-up | 62cb882 (2024-11-22) | |
| emacstv | Index Emacs-related videos | Watching Emacs videos | at start-up | e0b21e2 (2026-09-07) | |
| eglot | The Emacs Client for LSP servers | Eglot | when needed (hook, bind) | built-in (Emacs 30.2) | |
| flymake | A universal on-the-fly syntax checker | Syntax checking | when needed (hook, bind) | eade0abc31 (2026-09-10) | eldoc, project |
| flymake-vale | flymake integration for vale | Syntax checking | at start-up | 28d4a67 (2024-06-15) | flymake, let-alist, compat |
| treesit-auto | Automatically use tree-sitter enhanced major modes | Parsing programming languages | at start-up | 3106c73 (2026-08-31) | |
| treesit-fold | Code folding using treesit | Parsing programming languages | at start-up | cc1003b (2026-06-19) | |
| ess | Emacs Speaks Statistics | Emacs ESS | when needed (defer) | 254d3297 (2026-09-08) | |
| gptel | Interact with ChatGPT or other LLMs | gptel | when needed (commands) | b907507 (2026-09-08) | transient, compat |
| agent-shell | Native agentic integrations for Claude Code, Gemini CLI, etc | agent-shell | when needed (commands) | 956a0be (2026-10-13) | shell-maker, acp |
| mastodon | Client for fediverse services using the Mastodon API | Mastodon | when needed (commands) | 020cdc4 (2026-09-11) | compat, persist, tp |
| ement | Matrix client | Matrix | when needed (defer) | 790aee7 (2025-11-17) | map, persist, plz, taxy, taxy-magit-section, svg-lib, transient |
| erc | An Emacs Internet Relay Chat client | IRC | when needed (defer) | nil | compat |
| erc-hl-nicks | ERC nick highlighter that ignores uniquifying chars when colorizing | IRC | after erc | fd2759b (2024-06-15) | |
| erc-image | Show received image urls in the ERC buffer | IRC | after erc | 883084f (2021-06-04) | |
| f | Modern API for working with files and directories | Note-taking | at start-up | 931b6d0 (2024-10-03) | s, dash |
| magit-section | Sections for read-only buffers | Note-taking | at start-up | 9cb07d82 (2026-09-07) | compat, cond-let, llama, seq |
| org-roam | A database abstraction layer for Org-mode | Note-taking | at start-up | 903bd4e (2026-04-25) | compat, org, emacsql, magit-section |
| org-download | Image drag-and-drop for Org-mode. | Note-taking | when needed (bind) | c8be261 (2024-11-18) | async |
| citar | Citation-related commands for org, latex, markdown | Note-taking | when needed (hook, bind) | 347469e (2026-10-14) | parsebib, org, citeproc |
| citar-embark | Citar/Embark integration | Note-taking | after citar, embark | 347469e (2026-10-14) | embark, citar |
| citar-org-roam | Citar/org-roam integration | Note-taking | after citar, org-roam | 9750cfb (2025-04-24) | org-roam, citar |
| org-roam-ui | User Interface for Org-roam | Note-taking | after org-roam | 2894dcb (2026-01-06) | org-roam, simple-httpd, websocket |
| consult-org-roam | Consult integration for org-roam | Note-taking | after org-roam | 781d9c1 (2026-02-09) | org-roam, consult |
| org-mem | Fast info from a large number of Org file contents | Agenda and scheduling |
after org | 07094da (2026-03-26) | el-job, llama, truename-cache |
| org-ql | Org Query Language, search command, and agenda-like view | Agenda and scheduling |
at start-up | 4b8330a (2025-04-20) | compat, dash, f, map, org, org-super-agenda, ov, peg, s, transient, ts |
| org-super-agenda | Supercharge your agenda | Agenda and scheduling |
at start-up | fb20ad9 (2025-04-20) | compat, s, dash, org, ht, ts |
| request | Compatible layer for URL request | Read later | at start-up | c22e3c2 (2025-02-19) | |
| wallabag | Save and manage articles with wallabag | Read later | when needed (defer) | ff44e10 (2025-12-26) | request, s, emacsql, gptel |
| elfeed | An Atom/RSS feed reader | RSS Feeds | when needed (bind) | 48dc501 (2026-09-09) | compat |
| elfeed-protocol | Provide fever/newsblur/owncloud/ttrss protocols for elfeed | RSS Feeds | at start-up | 5893659 (2026-05-23) | elfeed, cl-lib |
| elfeed-score | Gnus-style scoring for Elfeed | RSS Feeds | after elfeed | f6d25f6 (2026-04-18) | elfeed |
| cuckoo-search | Content-based search and saved-searches for Elfeed | RSS Feeds | after elfeed | 3c9840c (2025-10-04) | elfeed |
Footnotes:
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.
It has sometimes been far more useful than Google Fu.
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.
As always run a local server from https://dev.languagetool.org/http-server
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
This has lead to proliferation of a number of starter kits, etc.
Ensures that file handling through different devices/applications does not corrupt the file.
A bunch of these customisation come from https://protesilaos.com/codelog/2023-06-28-emacs-mark-register-basics/
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.
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.
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
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/
It has a guide for LaTeX users on their site at https://typst.app/docs/guides/guide-for-latex-users/
The online demo of Pandoc at https://pandoc.org/try/ is a good place to try it.