#+title: Emacs Configuration
#+author: Preston Pan
#+description: my personal emacs configuration for NixOS
#+PROPERTY: header-args :tangle yes :comments link
* Introduction
This is my Vanilla Emacs configuration, made to work with my NixOS configuration. For that
reason, you will not see :ensure t inside any use-package declaration, for emacs packages
are all compiled natively and reproducibly on the NixOS side. This configuration uses the
emacs-lisp language only to configure variables for said packages, for the most part.
* Initialization
This must be the very first line in the tangled file to enable lexical binding.
#+begin_src emacs-lisp :tangle ../nix/init.el
;; -*- lexical-binding: t; -*-
#+end_src
** State
This is my imperative state. Note that a lot of the state is just in the form of various macros that I use in order to write in declarative
syntax elsewhere. Generally, however, these are all unordered and not dependent on each other loading first.
#+begin_src emacs-lisp :tangle ../nix/init.el
;; pure, well okay it prints but whatever
(defmacro try (expr)
`(condition-case err
,expr
(error
(princ (format "BLOCK FAILED: %s\n" (error-message-string err))))))
;; pure
(defmacro declare-irc-server (name server port)
`(defun ,name ()
(interactive)
(erc-tls :server ,server
:port ,port)))
;; pure, well imperative when evaluated but they're all just bindings that don't depend on each other
(defmacro create-irc-servers (&rest server-list)
`(progn
,@(mapcar (lambda (n) `(declare-irc-server ,@n)) server-list)))
;; pure
(defun org-html-latex-environment-pandoc-fix (orig-fun latex-environment contents info)
"Force `ox-html' to use the convert command for LaTeX environments when set to 'html."
(let ((processing-type (plist-get info :with-latex)))
(if (eq processing-type 'html)
(let* ((latex-frag (org-remove-indentation (org-element-property :value latex-environment)))
(converted (org-format-latex-as-html latex-frag)))
(format "
\n\n%s\n\n
" converted))
(funcall orig-fun latex-environment contents info))))
;; imperative
(defun insert-urandom-password (&optional length)
(interactive "P")
(let ((length (or length 32))
(chars "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{};:,.<>?"))
(insert
(with-temp-buffer
(call-process "head" nil t nil "-c" (number-to-string length) "/dev/urandom")
(let ((bytes (buffer-string)))
(mapconcat (lambda (c)
(string (elt chars (mod (string-to-char (char-to-string c)) (length chars)))))
bytes ""))))))
;; imperative
(defun create-htmlize-css ()
(org-html-htmlize-generate-css)
(with-current-buffer "*html*"
(buffer-string)))
;; imperative
(defun minify-css (css)
"A functional wrapper around the external 'minify' binary."
(with-temp-buffer
(insert css)
(call-process-region (point-min) (point-max) "minify" t t nil "--type=css")
(buffer-string)))
;; imperative
(defun emacs-config ()
(unless noninteractive (server-start))
;; start with sane defaults
(pixel-scroll-precision-mode 1)
;; (display-battery-mode 1)
(display-time-mode 1)
(menu-bar-mode -1)
(scroll-bar-mode -1)
(tool-bar-mode -1)
(global-auto-revert-mode 1)
;; load theme, fonts, and transparency. Prettify symbols.
(unless noninteractive (when (display-graphic-p)
(set-face-attribute 'default nil :font "Iosevka Nerd Font" :height 130)
(set-face-attribute 'variable-pitch nil :font "Lora" :height 1.1)
(set-fontset-font t 'han (font-spec :family "Noto Sans CJK SC"))
(set-fontset-font t 'kana (font-spec :family "Noto Sans CJK JP"))
(set-fontset-font t 'emoji (font-spec :family "Noto Color Emoji") nil 'prepend)
(set-fontset-font t 'symbol (font-spec :family "Noto Color Emoji") nil 'append)
(set-fontset-font t '(#x1f300 . #x1f5ff) (font-spec :family "Noto Color Emoji") nil 'prepend)
(set-fontset-font t '(#xe000 . #xf8ff) (font-spec :family "Symbols Nerd Font Mono") nil 'append))))
;; imperative
(defun evil-config ()
(evil-mode 1)
(evil-set-undo-system 'undo-redo)
(evil-set-initial-state 'pdf-view-mode 'normal))
;; imperative
(defun doom-themes-config ()
(load-theme 'doom-rouge t)
(doom-themes-visual-bell-config)
(doom-themes-treemacs-config)
(doom-themes-org-config))
;; imperative
(defun org-roam-config ()
(org-roam-db-autosync-mode)
(org-roam-update-org-id-locations))
;; same as above
(defun org-electric-pair ()
(setq-local electric-pair-inhibit-predicate
(lambda (c) (if (eq c ?<) t (electric-pair-default-inhibit c)))))
;; same as above
(defun org-yasnippet-latex () (yas-activate-extra-mode 'latex-mode))
;; same as above
(defun remove-annoying-pairing () (remove-hook 'post-self-insert-hook #'yaml-electric-bar-and-angle t))
;; taken from blog https://writepermission.com/org-blogging-rss-feed.html
;; (defun rp/org-rss-publish-to-rss (plist filename pub-dir)
;; "Publish RSS with PLIST, only when FILENAME is 'rss.org'.
;; PUB-DIR is when the output will be placed."
;; (if (equal "rss.org" (file-name-nondirectory filename))
;; (org-rss-publish-to-rss plist filename pub-dir)))
;; (defun rp/org-rss-publish-to-rss (plist filename pub-dir)
;; "Publish an Org file to RSS without creating Org IDs."
;; (let* ((ext (concat "." (or (plist-get plist :rss-extension)
;; org-rss-extension
;; "xml")))
;; (visiting (find-buffer-visiting filename))
;; (buf (or visiting
;; (let ((org-inhibit-startup t))
;; (find-file-noselect filename)))))
;; (unwind-protect
;; (with-current-buffer buf
;; (let ((org-inhibit-startup t))
;; ;; Keep PUBDATE generation.
;; (org-rss-add-pubdate-property)
;; (save-buffer))
;; (org-publish-org-to 'rss filename ext plist pub-dir))
;; (unless visiting
;; (kill-buffer buf)))))
(defun rp/org-rss-publish-to-rss (plist filename pub-dir)
(org-publish-org-to
'rss
filename
(concat "." (or (plist-get plist :rss-extension)
org-rss-extension
"xml"))
plist
pub-dir))
;; (defun rp/org-rss-publish-to-rss (plist filename pub-dir)
;; "Use stock RSS publishing for normal posts, but bypass UID/PUBDATE
;; mutation for the generated rss.org sitemap."
;; (if (string-equal (file-name-nondirectory filename) "rss.org")
;; (org-publish-org-to
;; 'rss
;; filename
;; (concat "." (or (plist-get plist :rss-extension)
;; org-rss-extension
;; "xml"))
;; plist
;; pub-dir)
;; (org-rss-publish-to-rss plist filename pub-dir)))
(defun format-rss-feed-entry (entry style project)
"Format ENTRY for the RSS feed.
ENTRY is a file name. STYLE is either 'list' or 'tree'.
PROJECT is the current project."
(cond ((not (directory-name-p entry))
(let* ((file (org-publish--expand-file-name entry project))
(title (org-publish-find-title entry project))
(date (format-time-string "%Y-%m-%d" (org-publish-find-date entry project)))
(link (concat (file-name-sans-extension entry) ".html")))
(with-temp-buffer
(org-mode)
(insert (format "* %s\n" title))
(org-set-property "RSS_PERMALINK" link)
(org-set-property "PUBDATE" date)
(insert-file-contents file)
(buffer-string))))
((eq style 'tree)
;; Return only last subdir.
(file-name-nondirectory (directory-file-name entry)))
(t entry)))
(defun format-rss-feed (title list)
"Generate the rss.org file from the formatted list."
(with-temp-buffer
(org-mode)
(insert "#+TITLE: " title "\n\n")
(insert (org-list-to-generic list '(:istart "" :icount "" :isep "\n")))
(buffer-substring-no-properties (point-min) (point-max))))
(try (require 'url-util))
(defun org-sitemap-format-xml (title list)
"Format the sitemap list as a standard XML file."
(concat "\n"
"\n"
(org-list-to-generic list '(:splice t :item-bullet "" :item-wrap nil))
"\n\n"))
(defun org-sitemap-format-entry-xml (entry style project)
"Format ENTRY in PROJECT for XML sitemap."
(let* ((file (file-name-sans-extension entry))
(link (url-encode-url (concat "https://ret2pop.net/" file ".html")))
(date (org-publish-find-date entry project))
(lastmod (format-time-string "%Y-%m-%d" date)))
(format "\n %s\n %s\n" link lastmod)))
(defun rp/org-sitemap-publish-function (plist filename pub-dir)
(when (string-equal (file-name-nondirectory filename) "sitemap.xml")
(org-publish-attachment plist filename pub-dir)))
(defvar my-mu4e-search-history nil)
(defun my-mu4e-search-with-ivy ()
"Search mu4e using the native prompt wrapped in Ivy."
(interactive)
;; We use mu4e's own query reader if possible, otherwise fallback
(let ((query (completing-read "mu4e search: "
my-mu4e-search-history nil nil nil
'my-mu4e-search-history)))
(unless (string-blank-p query)
(mu4e-search query))))
(defun my-mu4e-narrow-with-ivy ()
"Live-preview matches in the current buffer using Ivy,
then append the typed input to the mu4e database query."
(interactive)
(let* ((current-query (or (mu4e-last-query) ""))
;; 1. Collect all lines in the current headers buffer for the preview
(lines (save-excursion
(goto-char (point-min))
(let (res)
(while (not (eobp))
(push (buffer-substring-no-properties
(line-beginning-position)
(line-end-position))
res)
(forward-line 1))
(reverse res)))))
;; 2. Launch Ivy with the collected lines
(ivy-read (format "Narrow '%s' with: " current-query)
lines
:action (lambda (_)
;; 3. IGNORE the selected line (_).
;; Instead, grab exactly what you typed into the prompt.
(let ((input ivy-text))
(if (string-blank-p input)
(message "No narrowing term provided. Canceled.")
;; 4. Combine the old query with your new input and query Xapian
(mu4e-search (format "(%s) AND (%s)" current-query input))))))))
(defvar my-current-weather "Fetching weather..."
"Stores the latest fetched weather string.")
;; 2. The asynchronous fetch function
(defun my-fetch-weather-async ()
"Fetch weather asynchronously without blocking Emacs."
(interactive)
(let ((buf (get-buffer-create " *wttr-output*")))
(with-current-buffer buf (erase-buffer))
(make-process
:name "wttr-fetch"
:buffer buf
:command '("curl" "-s" "--max-time" "2" "wttr.in/?format=3")
;; The sentinel runs when the process finishes (success or fail)
:sentinel (lambda (process event)
(when (string-match-p "finished" event)
(let ((output (with-current-buffer (process-buffer process)
(string-trim (buffer-string)))))
;; Validate output just like your old try macro did
(if (or (string-blank-p output)
(string-match-p "BLOCK FAILED\\|Unknown location\\|" output))
(setq my-current-weather "Weather currently unavailable.")
(setq my-current-weather output)))
;; If the dashboard is visible, refresh it so the new text appears!
(when (get-buffer-window "*dashboard*" 'visible)
(with-current-buffer "*dashboard*"
(dashboard-refresh-buffer))))))))
;; 3. Your new, simplified (and fast!) dashboard widget
(defun my-dashboard-insert-weather-clock (list-size)
"Insert a styled clock and the pre-fetched weather variable."
(let ((clock (format-time-string "%I:%M %p • %A, %B %d")))
(insert "\n")
(insert (propertize clock 'face '(:height 1.5 :weight bold :foreground "#51afef")))
(insert "\n\n")
;; Just insert the variable. No network calls happen here.
(insert (propertize my-current-weather 'face '(:foreground "#a9a1e1" :weight semi-bold)))
(insert "\n\n")))
(defun my-refresh-dashboard-if-visible ()
"Refresh the dashboard buffer, but only if it's currently visible in a window."
(when (get-buffer-window "*dashboard*" 'visible)
(with-current-buffer "*dashboard*"
(dashboard-refresh-buffer))))
(defun my-setup-dashboard-timer ()
"Start a timer to refresh the dashboard every 60 seconds."
;; Cancel any existing timer first to avoid creating multiple timers
(when (timerp my-dashboard-refresh-timer)
(cancel-timer my-dashboard-refresh-timer))
;; Run the refresh function every 60 seconds
(setq my-dashboard-refresh-timer (run-with-timer 60 60 #'my-refresh-dashboard-if-visible)))
(defun my-fix-htmlize-invalid-face-bug (orig-fn face attribute &optional frame inherit)
(if (facep face)
(funcall orig-fn face attribute frame inherit)
'unspecified))
(defun rp/vterm-cleanup-on-exit (buffer _event)
"Close windows showing BUFFER after vterm exits, then kill BUFFER."
(let ((buf buffer))
(run-at-time
0 nil
(lambda ()
(when (buffer-live-p buf)
(let ((wins (get-buffer-window-list buf nil t)))
(dolist (win wins)
(when (window-live-p win)
(if (one-window-p t win)
;; Can't delete the last window in a frame.
;; Switch it away from the vterm buffer instead.
(with-selected-window win
(switch-to-prev-buffer win 'kill))
(delete-window win)))))
(when (buffer-live-p buf)
(kill-buffer buf)))))))
#+end_src
** Random Packages
These are packages that I require in order to write some scripts in emacs-lisp.
#+begin_src emacs-lisp :tangle ../nix/init.el
(use-package tex-site)
(use-package subr-x)
(use-package dash)
(use-package s)
(use-package f)
(use-package yaml-mode
:demand t)
#+end_src
** Emacs
These are all the options that need to be set at the start of the program. Because use-package
is largely declarative, the order of many of these options should not matter. However, there
is some imperative programming that must be done. Hooks are also largely declarative in this
configuration as they are also defined using the use-package macros. Some of these options will
have documentation strings attached, so it is easy to follow what the individual options do.
Emacs is self documenting, after all!
#+begin_src emacs-lisp :tangle ../nix/init.el
(use-package emacs
:custom
;; global defaults
(enable-local-variables :all "don't emit local variable warnings when publishing")
(indent-tabs-mode nil "no real tabs, only spaces")
(tab-width 2 "tab show as 2 spaces")
(standard-indent 2 "base indentation")
(custom-safe-themes t "I already manage my themes with nix")
(custom-file null-device "Don't save custom configs")
(jit-lock-chunk-size 16384 "actually load code blocks")
(jit-lock-stealth-time 1.25 "fontify in the background after 1.25s of idle time")
(jit-lock-stealth-nice 0.1 "don't freeze Emacs while stealth fontifying")
;; ---------------------------------------------------------------------------
;; UTF-8 Everywhere
;; ---------------------------------------------------------------------------
(set-language-environment "UTF-8")
(set-default-coding-systems 'utf-8)
(prefer-coding-system 'utf-8)
(set-terminal-coding-system 'utf-8)
(set-keyboard-coding-system 'utf-8)
(set-selection-coding-system 'utf-8)
(locale-coding-system 'utf-8)
(use-default-font-for-symbols nil)
;; Startup errors
(warning-minimum-level :emergency "Supress emacs warnings")
(confirm-kill-processes nil "Don't ask to quit")
(debug-ignored-errors (cons 'remote-file-error debug-ignored-errors) "Remove annoying error from debug errors")
(browse-url-generic-program "qutebrowser" "set browser to librewolf")
(browse-url-secondary-browser-function 'browse-url-generic "set browser")
(browse-url-browser-function 'browse-url-generic "set browser")
(default-frame-alist '((alpha-background . 100)
(vertical-scroll-bars)
(internal-border-width . 24)
(left-fringe . 8)
(right-fringe . 8)))
;; Mouse wheel
(mouse-wheel-scroll-amount '(1 ((shift) . 1)) "Nicer scrolling")
(mouse-wheel-progressive-speed nil "Make scrolling non laggy")
(mouse-wheel-follow-mouse 't "Scroll correct window")
(scroll-conservatively 101 "Sort of smooth scrolling")
(scroll-step 1 "Scroll one line at a time")
(debug-on-error nil "Don't make the annoying popups")
(display-time-24hr-format t "Use 24 hour format to read the time")
(display-line-numbers-type 'relative "Relative line numbers for easy vim jumping")
(use-short-answers t "Use y instead of yes")
(make-backup-files nil "Don't make backups")
(line-spacing 2 "Default line spacing")
(c-doc-comment-style '((c-mode . doxygen)
(c++-mode . doxygen)))
(fill-column 150)
:hook ((prog-mode . display-line-numbers-mode)
(org-mode . auto-fill-mode)
(org-mode . display-line-numbers-mode))
:config (emacs-config))
#+end_src
As you can see, the config (and sometimes the init section) of most of these use-package blocks
contain most of the imperative commands. In fact, most of the configurations are completely
declarative without any imperative programming at all (i.e. hooks and custom options). Note
that Emacs lambdas contain imperative state, unlike in [[file:nix.org][NixOS]] where lambdas can contain function
applications but they themselves are mainly declarative. Usually, however, the lambdas or
functions do little to nothing and are mainly wrappers for executing two commands or for giving
a variable an option. Often you will see a config section of a use-package declaration have
only one or two entries, which is intentional, as I've designed this configuration to put as
little in config as possible. I hardly consider most of this configuration to be imperative, but
of course Emacs was not designed to be fully imperative.
** Network
#+begin_src emacs-lisp :tangle ../nix/init.el
(use-package enwc :custom (enwc-default-backend 'nm "use networkmanager backend"))
#+end_src
** System Monitor
#+begin_src emacs-lisp :tangle ../nix/init.el
(use-package proced
:custom (proced-enable-color-flag t "use colors in proced"))
#+end_src
** Org Mode
This is my org mode configuration, which also configures latex.
#+begin_src emacs-lisp :tangle ../nix/init.el
(use-package org
:demand t
:after (f s dash nix-mode)
:hook
((org-mode . remove-annoying-pairing))
:custom
(org-log-into-drawer t)
(org-export-allow-bind-keywords t "don't emit warnings")
(org-confirm-babel-evaluate nil "I want to evaluate stuff when publishing")
;; Fix terrible indentation issues
(org-edit-src-content-indentation 0)
(org-src-tab-acts-natively t)
(org-src-preserve-indentation t)
(org-hide-drawer-startup t)
(org-startup-folded 'showall)
(TeX-PDF-mode t)
(org-confirm-babel-evaluate nil "Don't ask to evaluate code block")
(org-export-with-broken-links t "publish website even with broken links")
(org-src-fontify-natively t "Colors!")
;; org-latex
(org-format-latex-header "\\documentclass{article} \
\\usepackage[usenames]{color} \
[DEFAULT-PACKAGES] \
[PACKAGES] \
\\pagestyle{empty} % do not remove \
% The settings below are copied from fullpage.sty \
\\setlength{\\textwidth}{\\paperwidth} \
\\addtolength{\\textwidth}{-3cm} \
\\setlength{\\oddsidemargin}{1.5cm} \
\\addtolength{\\oddsidemargin}{-2.54cm} \
\\setlength{\\evensidemargin}{\\oddsidemargin} \
\\setlength{\\textheight}{\\paperheight} \
\\addtolength{\\textheight}{-\\headheight} \
\\addtolength{\\textheight}{-\\headsep} \
\\addtolength{\\textheight}{-\\footskip} \
\\addtolength{\\textheight}{-3cm} \
\\setlength{\\topmargin}{1.5cm} \
\\addtolength{\\topmargin}{-2.54cm} \
\\usepackage{amsmath} \
\\usepackage{tikz-cd} \
")
(org-preview-latex-image-directory (expand-file-name "~/.cache/ltximg/") "don't use weird cache location")
(org-latex-preview-ltxpng-directory (expand-file-name "~/.cache/ltximg/") "don't use weird cache location")
(org-latex-to-html-convert-command "printf '%%s' %i | pandoc -f latex -t html --mathml | tr -d '\\n' | sed -e 's/^
//' -e 's/<\\/p>$//'" "latex to MathML with special character handling")
(org-latex-to-mathml-convert-command "printf '%%s' %i | pandoc -f latex -t html --mathml | tr -d '\\n' | sed -e 's/^