doom.d/config.org

46 KiB

Nathan's Doom Emacs Configuration

My doom emacs configuration

Basic Doom stuff

The provided default config.el

;;; $DOOMDIR/config.el -*- lexical-binding: t; -*-

;; Place your private configuration here! Remember, you do not need to run 'doom
;; sync' after modifying this file!


;; Some functionality uses this to identify you, e.g. GPG configuration, email
;; clients, file templates and snippets. It is optional.
(setq user-full-name "Nathan McCarty"
      user-mail-address "nathan@mccarty.io")

;; Doom exposes five (optional) variables for controlling fonts in Doom:
;;
;; - `doom-font' -- the primary font to use
;; - `doom-variable-pitch-font' -- a non-monospace font (where applicable)
;; - `doom-big-font' -- used for `doom-big-font-mode'; use this for
;;   presentations or streaming.
;; - `doom-unicode-font' -- for unicode glyphs
;; - `doom-serif-font' -- for the `fixed-pitch-serif' face
;;
;; See 'C-h v doom-font' for documentation and more examples of what they
;; accept. For example:

;; Figure out what the name and size of the font are going to be based on the system type and
;; hostname
(setq nm/font-name (cond ((or IS-MAC IS-WINDOWS) "Iosevka Nerd Font")
                          (t "Iosevka Nerd Font"))
      nm/font-size (cond (IS-MAC 11)
                          ((or IS-WINDOWS (string= (system-name) "wsl")) 16)
                          (t 13))
      nm/variable-font-size (cond (IS-MAC 13)
                                   ((or IS-WINDOWS (string= (system-name) "wsl")) 19)
                                   (t 15)))


(setq doom-font (font-spec :family nm/font-name :size nm/font-size :weight 'semi-light)
      doom-unicode-font (font-spec :family nm/font-name :size nm/font-size :weight 'semi-light)
      doom-variable-pitch-font (font-spec :family "Iosevka Sans Quasi" :size nm/variable-font-size))

;;
;; If you or Emacs can't find your font, use 'M-x describe-font' to look them
;; up, `M-x eval-region' to execute elisp code, and 'M-x doom/reload-font' to
;; refresh your font settings. If Emacs still can't find your font, it likely
;; wasn't installed correctly. Font issues are rarely Doom issues!

;; There are two ways to load a theme. Both assume the theme is installed and
;; available. You can either set `doom-theme' or manually load a theme with the
;; `load-theme' function. This is the default:
;; (setq doom-theme 'doom-solarized-dark)
(use-package! solarized-theme
  :demand t
  :config
  (setq solarized-distinct-fringe-background t
        solarized-distinct-doc-face t
        solarized-scale-markdown-headlines t
        solarized-scale-org-headlines t)
  (advice-add #'enable-theme :after
              (lambda (&rest _)
                (set-face-attribute 'mode-line nil :overline nil :underline nil)
                (set-face-attribute 'mode-line-inactive nil :overline nil :underline nil)))
  (load-theme 'solarized-selenized-black t))

;; This determines the style of line numbers in effect. If set to `nil', line
;; numbers are disabled. For relative line numbers, set this to `relative'.
(setq display-line-numbers-type t)

;; If you use `org' and don't want your org files in the default location below,
;; change `org-directory'. It must be set before org loads!
(setq org-directory "~/Org/")


;; Whenever you reconfigure a package, make sure to wrap your config in an
;; `after!' block, otherwise Doom's defaults may override your settings. E.g.
;;
;;   (after! PACKAGE
;;     (setq x y))
;;
;; The exceptions to this rule:
;;
;;   - Setting file/directory variables (like `org-directory')
;;   - Setting variables which explicitly tell you to set them before their
;;     package is loaded (see 'C-h v VARIABLE' to look up their documentation).
;;   - Setting doom variables (which start with 'doom-' or '+').
;;
;; Here are some additional functions/macros that will help you configure Doom.
;;
;; - `load!' for loading external *.el files relative to this one
;; - `use-package!' for configuring packages
;; - `after!' for running code after a package has loaded
;; - `add-load-path!' for adding directories to the `load-path', relative to
;;   this file. Emacs searches the `load-path' when you load packages with
;;   `require' or `use-package'.
;; - `map!' for binding new keys
;;
;; To get information about any of these functions/macros, move the cursor over
;; the highlighted symbol at press 'K' (non-evil users must press 'C-c c k').
;; This will open documentation for it, including demos of how they are used.
;; Alternatively, use `C-h o' to look up a symbol (functions, variables, faces,
;; etc).
;;
;; You can also try 'gd' (or 'C-c c d') to jump to their definition and see how
;; they are implemented.

Garbage collector configuration

(after! gcmh
  (setq gcmh-high-cons-threshold (* 64 1024 1024)))

Setup my user prefix

Use SPC z as my user prefix for custom commands and what not

(map! :leader
      (:prefix ("z" . "custom")))

Add ~/.authinfo to auth-sources:

(add-to-list 'auth-sources "~/.authinfo")

Appearance, UX, and Fixes

Mixed Pitch Mode

Use mixed pitch mode in prose writing modes, to make the writing experience a bit more pleasant. This tweak applies to:

  • org-mode
  • markdown-mode
(use-package! mixed-pitch
  :hook
  (org-mode . mixed-pitch-mode)
  (markdown-mode . mixed-pitch-mode)
  :config
  (setq mixed-pitch-set-height t))

Setting mixed-pitch-set-height is required to get mixed-pitch-mode to render fonts with the correct size in doom emacs, apparently.

Modeline configuration

Setup our faces, our theme doesn't play nice with doom-modeline, so we've got to change the insert state to make it's color actually unique, I'm going to use blue:

(after! doom-modeline
  (custom-set-faces
   '(doom-modeline-evil-insert-state
     ((t
       (:inherit
        (font-lock-string-face bold)))))))

Configure Icons

(after! doom-modeline
  (setq doom-modeline-icon t
        doom-modeline-major-mode-icon t
        doom-modeline-buffer-state-icon t))

Turn on the hud, see where we are at a glance:

(after! doom-modeline
  (setq doom-modeline-hud t))

Configure file name display

(after! doom-modeline
  (setq doom-modeline-buffer-filename-style 'truncate-with-project))

Display the time in the modeline

(after! doom-modeline
  (setq doom-modeline-time t))

Treemacs

Configure treemacs, doing the following:

  • Set the width of the buffer to 28 characters
  • Bind the select window function to M-0
(after! treemacs
  (setq treemacs-width 28)
  ;; (bind-key "M-0" #'treemacs-select-window)
  )

Alert

Configure notifications that originate from within emacs

(use-package! alert
  :config
  ;; TODO: Make this conditional so we can make the correct choice on macos
  (setq alert-default-style 'libnotify))

Dired

Modify the dired-omit-files regex to exclude the current working directory (.), but not the parent directory(..).

(after! dired
  (setq dired-omit-files "\\`[.]?#\\|\\`[.]?\\'\\|^\\.DS_Store\\'\\|^\\.project\\(?:ile\\)?\\'\\|^\\.\\(?:svn\\|git\\)\\'\\|^\\.ccls-cache\\'\\|\\(?:\\.js\\)?\\.meta\\'\\|\\.\\(?:elc\\|o\\|pyo\\|swp\\|class\\)\\'"))

Setup keychain environment

Load the keychain environment variables into emacs

(require 'keychain-environment)
(keychain-refresh-environment)

Configure indent guides

First disable the automatic colors mode, and turn on responsive mode

(after! highlight-indent-guides
  (setq highlight-indent-guides-auto-enabled nil
        highlight-indent-guides-responsive 'stack))

Now setup our theming

(custom-theme-set-faces! 'solarized-selenized-black
  '(highlight-indent-guides-character-face :foreground "#3b3b3b")
  '(highlight-indent-guides-top-character-face :foreground "#70b433")
  '(highlight-indent-guides-stack-character-face :foreground "#a580e2"))

Uniquify

First, we need to work around an issue with perspective, using some code taken from persp-mode.el #104:

(with-eval-after-load "persp-mode"
  (defun persp--wc-to-writable (wc)
    (cl-labels
        ((wc-to-writable
          (it)
          (cond
           ((and it (listp it))
            (cl-destructuring-bind (head . tail) it
              (cond
               ;; ((listp head)
               ;;  (cons (wc-to-writable head)
               ;;        (wc-to-writable tail)))
               ((eq 'parameters head)
                (let ((rw-params
                       (delq nil
                             (mapcar
                              #'(lambda (pc)
                                  (when
                                      (and
                                       (alist-get (car pc) window-persistent-parameters)
                                       (persp-elisp-object-readable-p (cdr pc)))
                                    pc))
                              tail))))
                  (if rw-params
                      `(parameters
                        ,@rw-params)
                    :delete)))
               (t
                (let ((new-head (wc-to-writable head))
                      (new-tail (wc-to-writable tail)))
                  (when (eq :delete new-tail)
                    (setq new-tail nil))
                  (if (eq :delete new-head)
                      new-tail
                    (cons new-head
                          new-tail)))))))
           ((bufferp it)
            (if (buffer-live-p it)
                (buffer-name it)
              "*Messages*"))
           ((markerp it)
            (marker-position it))
           (t it))))
      (wc-to-writable wc)))

  (setq persp-window-state-get-function
        #'(lambda (&optional frame rwin)
            (when (or rwin (setq rwin (frame-root-window
                                       (or frame (selected-frame)))))
              (window-state-get rwin nil))))

  (add-hook 'persp-before-save-state-to-file-functions
            #'(lambda (_fname phash _rpfp)
                (mapc
                 #'(lambda (persp)
                     (if persp
                         (setf (persp-window-conf persp)
                               (persp--wc-to-writable (persp-window-conf persp)))
                       (setq persp-nil-wconf
                             (persp--wc-to-writable persp-nil-wconf))))
                 (persp-persps phash)))))

Then we can override persp's override of uniquify:

;; doom's `persp-mode' activation disables uniquify, b/c it says it breaks it.
;; It doesn't cause big enough problems for me to worry about it, so we override
;; the override. `persp-mode' is activated in the `doom-init-ui-hook', so we add
;; another hook at the end of the list of hooks to set our uniquify values.
(add-hook! 'doom-init-ui-hook
           :append ;; ensure it gets added to the end.
           #'(lambda () (require 'uniquify) (setq uniquify-buffer-name-style 'forward)))

Include part of the path to uniquify buffer names

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

Transparency

Make the background transparent on linux

(when (and IS-LINUX (version<= "29.0.0" emacs-version))
  (set-frame-parameter nil 'alpha-background 95)
  (add-to-list 'default-frame-alist '(alpha-background . 95)))

Streamer Mode

(defun nm/streamer-mode ()
    (interactive)
  (setq doom-font (font-spec :family nm/font-name :size 17 :weight 'semi-light)
        doom-unicode-font (font-spec :family nm/font-name :size 17 :weight 'semi-light)
        doom-variable-pitch-font (font-spec :family "Iosevka Sans Quasi" :size 20))
  (doom/reload-font))

(map! :leader
      (:prefix ("z m" . "mode")
               "s" #'nm/streamer-mode))

Basic Editing

Evil Mode

Emacs is best vim, fite me

Tune evil mode to be how we like it

(use-package! evil
  :config
  (setq
   ;; Use emacs style undo behavior instead of vim style
   evil-want-fine-undo t
   ;; Make o/O not continue comments
   +evil-want-o/O-to-continue-comments nil)
  ;; Set the local leader key for normal mode, we'll use tab
  (evil-set-leader '(normal) (kbd "<backspace>") t))

Fill Column

Set the default fill column to 100

(setq-default fill-column 100)

Navigation

Avy

More modern ace-jump-mode

Set up our key bindings

(map! :leader
      (:prefix ("j" . "jump")
               "c" #'avy-goto-char
               "x" #'avy-goto-char-2
               "f" #'avy-goto-line
               "w" #'avy-goto-word-1
               "e" #'avy-goto-word-0))

Configure avy to use relative-to-point paths for every command we have bound

(after! avy
  (setq avy-orders-alist
        '((avy-goto-char . avy-order-closest)
          (avy-goto-word-0 . avy-order-closest)
          (avy-goto-word-1 . avy-order-closest)
          (avy-goto-char-2 . avy-order-closest)
          (avy-goto-line . avy-order-closest))))

Swiper

Better isearch

Override old isearch

(after! swiper
  (define-key! "C-s" 'swiper))

Crux

Smarter replacements for emacs built ins, with the following in use:

  • crux-smart-kill-line - Smart C-k replacement
  • crux-top-join-line - C-c ^ Join two lines
(use-package! crux
   ;; :bind (("C-k"   . crux-smart-kill-line))
  )

string-inflection

Automatically cycle case of names

(global-unset-key (kbd "C-q"))
(use-package! string-inflection
  ;; :bind (("C-q" . string-inflection-all-cycle))
  )
(cheatsheet-add-group 'string-inflection
                      '(:key "C-q" :description "Rotate case"))

Smart Hungry Delete

Gobble up whitespace in a smarter way

(use-package! smart-hungry-delete
  ;; :bind (("M-<backspace>" . smart-hungry-delete-backward-char))
  )

Search

Consult

We need to configure the evil collection binds for consult

(with-eval-after-load 'consult (evil-collection-consult-setup))

And bind up the search

(after! evil
  (map! :n "C-s" #'consult-line
        :n "SPC s :" #'consult-line-multi))

Deadgrep

Ripgrep, but from within emacs

(use-package! deadgrep
  ;; :bind ("C-c s r" . deadgrep)
  )

Spell Checking

Add in all of our dictionaries

(after! spell-fu
  (add-hook 'spell-fu-mode-hook
            (lambda ()
              (spell-fu-dictionary-add (spell-fu-get-ispell-dictionary "en"))
              (spell-fu-dictionary-add (spell-fu-get-ispell-dictionary "en-science"))
              (spell-fu-dictionary-add (spell-fu-get-ispell-dictionary "en-computers"))))
  ;; (bind-key "C-." #'+spell/correct)
  )

Auto completion

Plug in hotfuzz

(after! vertico
  (require 'hotfuzz)
  (setq completion-styles '(hotfuzz)))

Configure the fuzzy match

(setq completion-ignore-case t
      read-file-name-completion-ignore-case t)

Org Mode

Improvements to the best mode in emacs

Setup some basic cosmetic improvements

  • Disable showing of emphasis markers
  • Show entities as utf-8 test

    (setq org-hide-emphasis-markers t
        org-pretty-entities t)

Setup org-superstar-mode, to make lists and bullets pretty

(use-package! org-superstar
:hook (org-mode . org-superstar-mode)
:config
(setq org-superstart-special-todo-items t))

Automatically add all files in the org dir to the agenda. This performs some filtering of the files returned from directory-files to exclude some things that would confuse org-agenda. We also setup an idle timer, with a short duration, only 30 seconds, to update the org-agenda-files list, as well as a longer regular timer with a duration of 300 seconds (5 minutes) to keep the agenda up to date even when we are actively using emacs.

(defvar nm/org-agenda-files-timer nil
"Timer for automatically updating the org-agenda files")
(defvar nm/time-at-agenda-update 0
"Time at last agenda update")

(defun nm/update-org-agenda-files ()
"Helper function for updating the org-agenda files."
;; Calcuate time since last update
(let* ((time-seconds  (float-time (current-time)))
       (seconds-since (- time-seconds nm/time-at-agenda-update))
       (idle-time     (current-idle-time))
       (idle-seconds  (if idle-time (float-time idle-time) 0)))
  ;; If it has been more than 10 minutes since our last agenda file update, then go ahead and update
  ;; Additionally update if the idle timer is greater than 30 seconds
  (when (or
         (> seconds-since 600)
         (> idle-seconds 30))
    ;; Update our time variable
    (setq nm/time-at-agenda-update seconds-since)
    ;; Update our agenda files
    (setq org-agenda-files
      (seq-filter (lambda (item)
                    (and
                     ;; Exclude the syncthing folder
                     (not (string-match-p ".*stfolder$" item))
                     ;; Exclude gpg encrypted org files
                     (not (string-match-p ".*\\.gpg" item))
                     ;; Exclude the elfeed data folder
                     (not (string-match-p (concat "^" (regexp-quote org-directory) "elfeed/.*") item))
                     ;; Only accept org files
                     (string-match-p ".*org$" item)
                     ;; Make sure the file actually has in-progress todo markers in it
                     (let ((regex "^\\*+\\s*(TODO|PROJ|LOOP|STRT|WAIT|HOLD|IDEA|\\[ \\]|\\[-\\]|\\[?\\])")
                           (item (expand-file-name item)))
                       ;; Use ripgrep to test for active todos in the file
                       (eq 0 (call-process "rg" nil nil nil "-q" regex item)))))
                  (directory-files-recursively org-directory directory-files-no-dot-files-regexp))))
;; Update the timer, first canceling the old one
(when nm/org-agenda-files-timer
  (cancel-timer nm/org-agenda-files-timer))
(setq nm/org-agenda-files-timer (run-with-timer 60 nil 'nm/update-org-agenda-files))))

(after! org
;; Set the agenda files on first start
;; This also configures the timer for us
(nm/update-org-agenda-files))

Set up two different timers for updating the org-agenda buffer.

  • Idle timer The idle timer simply updates the views unconditionally, and is set with a slightly higher timeout than our idle time that updates the org agenda files. This idle time can safely modify the state of the buffer without any other checks, as if the user is idle, they aren't doing anything in the buffer

    • Timer timer Setup a timer that attempts to update the org-agenda buffer every 5 minutes. This timer is a little bit unsafe, so it could end up annoying the user by updating the state while they are in the middle of doing something, so it cancels out and does nothing if the user is currently focused on the agenda buffer.
    (defvar nm/org-agenda-update-timer nil
    "Timer for automatically updating the org-agenda views")
    
    (defun nm/org-agenda-refresh-conditional ()
    "Helper function to only refresh the org-agenda views if it
    either isn't focused or we have been idle long enough. This
    avoids updating the buffer, and thus annoying the user, while
    they are in the middle of doing something.
    
    This function will run on a 60 second loop, only actually doing
    work if it thinks it needs to."
    ;; Make sure the org-agenda-buffer exists, bail out if it doesnt
    (when (boundp 'org-agenda-buffer-name)
      ;; Attempt to get the org agenda buffer
      (when-let ((buffer (get-buffer org-agenda-buffer-name)))
        ;; Calcuate idle time
        (let* ((idle-time (current-idle-time))
               (idle-seconds (if idle-time (float-time idle-time) 0)))
          ;; Update the org-agenda views if any of the following apply:
          ;; - The agenda buffer is not in focus
          ;; - The idle time is greater than one minute
          (when (or
                 (not (eq (window-buffer (selected-window)) buffer))
                 (> idle-seconds 60))
            ;; Since we are not in the org-agenda-buffer it is safe to rebuild the views
            (with-current-buffer buffer
              (org-agenda-redo-all))))))
      ;; Update the timer, first canceling the old one
      (when nm/org-agenda-update-timer
        (cancel-timer nm/org-agenda-update-timer))
      (setq nm/org-agenda-update-timer (run-with-timer 60 nil 'nm/org-agenda-refresh-conditional)))
    
    (after! org
    ;; This method sets up the timer on its own
    (nm/org-agenda-refresh-conditional))

Log state changes and setup TODO keywords

Configure the logging, we want to log into a drawer and also log refiles, reschedules, and repeats

(after! org
(setq org-log-into-drawer t
      org-log-refile 'time
      org-log-repeat 'time
      org-log-reschedule 'time
      org-log-done 'time))

We'll need to override the doom provided org-todo-keywords to get the state transitions we want logged

(after! org
(setq org-todo-keywords
      '((sequence
         "TODO(t)"  ; A task that needs doing & is ready to do
         "PROJ(p)"  ; A project, which usually contains other tasks
         "LOOP(r)"  ; A recurring task
         "STRT(s!)"  ; A task that is in progress
         "WAIT(w!)"  ; Something external is holding up this task
         "HOLD(h!)"  ; This task is paused/on hold because of me
         "IDEA(i)"  ; An unconfirmed and unapproved task or notion
         "|"
         "DONE(d!)"  ; Task successfully completed
         "KILL(k!)") ; Task was cancelled, aborted or is no longer applicable
        (sequence
         "[ ](T)"   ; A task that needs doing
         "[-](S!)"   ; Task is in progress
         "[?](W!)"   ; Task is being held up or paused
         "|"
         "[X](D!)")  ; Task was completed
        (sequence
         "|"
         "OKAY(o!)"
         "YES(y!)"
         "NO(n!)"))
      org-todo-keyword-faces
      '(("[-]"  . +org-todo-active)
        ("STRT" . +org-todo-active)
        ("[?]"  . +org-todo-onhold)
        ("WAIT" . +org-todo-onhold)
        ("HOLD" . +org-todo-onhold)
        ("PROJ" . +org-todo-project)
        ("NO"   . +org-todo-cancel)
        ("KILL" . +org-todo-cancel))))

org-roam

A second brain in emacs

Here we:

  • Set the roam directory to be a sub-directory of the org directory, which I have in syncthing
  • Use a more informative display template, as we use ivy
  • Turn on db autosync
  • Setup dalies to add the time of the capture to the note
(use-package! org-roam
  :custom
  (org-roam-directory (concat org-directory "roam/"))
  (org-roam-complete-everywhere t)
  ;; :bind (("C-c r l" . org-roam-buffer-toggle)
  ;;        ("C-c r f" . org-roam-node-find)
  ;;        ("C-c r g" . org-roam-graph)
  ;;        ("C-c r i" . org-roam-node-insert)
  ;;        ("C-c r c" . org-roam-capture)
  ;;        ("C-c r T" . org-roam-dailies-capture-today)
  ;;        ("C-c r t" . org-roam-dailies-goto-today)
  ;;        :map org-mode-map
  ;;        ("C-M-i" . completion-at-point))
  :config
  (setq org-roam-node-display-template (concat "${title:*} " (propertize "${tags:10}" 'face 'org-tag)))
  (org-roam-db-autosync-mode)
  (setq org-roam-dailies-capture-templates
      '(("d" "default" entry "* %<%I:%M %p>: %?"
         :if-new (file+head "%<%Y-%m-%d>.org" "#+title: %<%Y-%m-%d>\n")))))

And we want the nice fancy ui, so go ahead and set that up

(use-package! websocket
  :after org-roam)

(use-package! org-roam-ui
  :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 t))

org-protocol-capture-html

Capture webpages really nice like

(use-package! org-protocol-capture-html)

Capture Templates

The default template for org-protocol-capture-html

(after! org
  (push
   '("w" "Web site" entry
     (file "")
     "* %a :website:\n\n%U %?\n\n%:initial")
   org-capture-templates))

anki-editor

Flash cards from within emacs.

(use-package! anki-editor)

org-agenda customization

Empty out the list and define our prefixes first

(after! org
  (setq org-agenda-custom-commands
        '(("p" . "Project Views")
          ("d" . "TODO lists"))))

Random project selection

First some library code

(defun org-compare--get-marker (entry)
  "Return the marker for ENTRY.

This marker points to the location of the headline referenced by
ENTRY."
  (get-text-property 1 'org-marker entry))

(defvar org-compare-random-refresh nil
  "Whether `org-compare-randomly' should refresh its keys.

See the docs for `org-compare-randomly' for more information.")

(defun org-compare-randomly--update-sort-key (entry table generator)
  "Return sort key for ENTRY in TABLE, generating it if necessary.
For internal use by `org-compare-randomly-by'."
  (let* ((marker    (org-compare--get-marker entry))
         (hash-key  `(,(marker-buffer marker) . ,(marker-position marker))))
    (or (gethash hash-key table)
        (puthash hash-key (funcall generator entry) table))))

(defun org-compare-randomly-by (generator)
  "Return a random comparator using GENERATOR.

The comparator returned is like `org-compare-randomly', except
the distribution of random keys is controlled by GENERATOR and
may thus be non-uniform.

The function GENERATOR is called with a single argument, an
agenda entry, when that entry lacks a sort key.  It should return
a number, which is then used for all comparisons until the key
list is cleared; see `org-compare-randomly' for more details on
this.

Subsequent calls to `org-compare-randomly-by' produce comparators
with independent sets of sort keys."
  (let ((table (make-hash-table :test #'equal)))
    (lambda (x y)
      (when org-compare-random-refresh
        (clrhash table)
        (setq org-compare-random-refresh nil))
      (let ((x-val (org-compare-randomly--update-sort-key x table generator))
            (y-val (org-compare-randomly--update-sort-key y table generator)))
        (cond
         ((= x-val y-val)  nil)
         ((< x-val y-val)   -1)
         ((> x-val y-val)   +1))))))

(defun org-compare-randomly ()
  "Return a comparator implementing a random shuffle.

When given distinct agenda entries X and Y, the resulting
comparator has an equal chance of returning +1 and -1 (and a
miniscule chance of returning nil).  Subsequent calls will produce
results consistent with a total ordering.

To accomplish this, a hash table of randomly-generated sort keys
is maintained.  This table will persist until the comparator is
called when the variable `org-compare-random-refresh' is non-nil.
This means that setting this variable as part of a custom agenda
command using this comparator as `org-agenda-cmp-user-defined'
will cause the sort order to change whenever the agenda is
refreshed; otherwise, it will persist until Emacs is restarted.

Note that if you don't want the sort order to change on refresh,
you need to be careful that the comparator is created when the
custom agenda command is defined, not when it's called, e.g.

    (add-to-list
     'org-agenda-custom-commands
     `(\"y\" \"Example Agenda\"
       ((todo
         \"\"
         ((org-agenda-cmp-user-defined ',(org-compare-randomly))
          (org-agenda-sorting-strategy '(user-defined-up)))))))

\(Notice the use of backquote.)

Comparators resulting from different calls to this function have
independent key tables."
  (org-compare-randomly-by (lambda (_) (random))))

Then add our custom command, one section for "TODO"s and another for top level "PROJ"s

(after! org
  (add-to-list 'org-agenda-custom-commands
               '("pr" "Random Project TODOs"
                 ((tags "proj/TODO"
                        ((org-agenda-max-entries 5)
                         (org-agenda-cmp-user-defined (org-compare-randomly))
                         (org-compare-random-refresh t)
                         (org-agenda-sorting-strategy '(user-defined-up))))
                  (tags "proj/STRT"
                        ((org-agenda-max-entries 5)
                         (org-agenda-cmp-user-defined (org-compare-randomly))
                         (org-compare-random-refresh t)
                         (org-agenda-sorting-strategy '(user-defined-up))))
                  (tags "proj/PROJ"
                        ((org-agenda-max-entries 5)
                         (org-agenda-cmp-user-defined (org-compare-randomly))
                         (org-compare-random-refresh t)
                         (org-agenda-sorting-strategy '(user-defined-up))))
                  (todo "IDEA"
                        ((org-agenda-max-entries 5)
                         (org-agenda-cmp-user-defined (org-compare-randomly))
                         (org-compare-random-refresh t)
                         (org-agenda-sorting-strategy '(user-defined-up))))))))

Task fillters

Create a view with the following tags excluded:

  • :calander:
  • :work:
  • :proj:

This serves as a good default "I want to do something" board

(after! org
  (add-to-list 'org-agenda-custom-commands
               '("da" "Main TODO list"
                 tags-todo "-calander-proj-work")))

Create a view containing only work tasks

(after! org
  (add-to-list 'org-agenda-custom-commands
               '("ds" "Work TODO list"
                 tags-todo "+work")))

Create a view containing only project tasks

(after! org
  (add-to-list 'org-agenda-custom-commands
               '("dd" "Project TODO list"
                 tags-todo "+proj")))

Set tags alist

(after! org
  (setq org-tag-alist '(("proj" . ?p)
                        ("complaint" . ?c)
                        ("work" . ?w)
                        ("calander" . ?d))))

Modules configuration

org habits

Enable the module

(after! org
  (add-to-list 'org-modules 'org-habit))

Extend the day until around 3AM, for those days with late bed time

(after! org
  (setq org-extend-today-until 4
        org-use-effective-time t))

org collector

Provides nice tables from the properties of an item

(after! org
  (add-to-list 'org-modules 'org-collector))

Babel config

Ansi colors

First, bring in ansi-color

(require 'ansi-color)

Then, hook into babel and apply those colors

(after! org
  (defun nm/babel-ansi ()
    (when-let ((beg (org-babel-where-is-src-block-result nil nil)))
      (save-excursion
        (goto-char beg)
        (when (looking-at org-babel-result-regexp)
          (let ((end (org-babel-result-end))
                (ansi-color-context-region nil))
            (ansi-color-apply-on-region beg end))))))
  (add-hook 'org-babel-after-execute-hook 'nm/babel-ansi))

Org Chef

(use-package! org-chef
  :config
  (add-to-list 'org-capture-templates
               '("c" "Cookbook" entry (file "~/Org/cookbook.org")
                 "%(org-chef-get-recipe-from-url)"
                 :empty-lines 1))
  (add-to-list 'org-capture-templates
               '("m" "Manual Cookbook" entry (file "~/Org/cookbook.org")
                 "* %^{Recipe title: }\n  :PROPERTIES:\n  :source-url:\n  :servings:\n  :prep-time:\n  :cook-time:\n  :ready-in:\n  :END:\n** Ingredients\n   %?\n** Directions\n\n")))

General Modes

Magit

Further configuration for magit

magit-todos

Count the number of todos in the project in the magit-status buffer

(use-package! magit-todos
  :hook (magit-mode . magit-todos-mode))

magit-delta

Use delta for git diff display

(use-package! magit-delta
  :hook (magit-mode . magit-delta-mode))

magit-wip-mode

Stash autosaves inside of git

(magit-wip-mode)

System integration

Various tools for interacting with the system from within emacs

Terminal

Doom already provides pretty nice vterm support, but lets take us a step further, using multi-vterm to provide ergonomic support for multiple terminals.

Vterm really doesn't like being installed through emacs on nix, so proper support for it in my setup requires installing it through nix like so:

let emacsPackage = (emacsPackagesFor emacs).emacsWithPackages (epgks: with epkgs; [
      vterm
    ]);
in
{
  environment.systemPackages = [
    emacsPackage
  ];
}
multi-vterm

Add ergonomic support for multiple vterm terminals

(when (not IS-WINDOWS)
 (use-package! multi-vterm))

Programming

General Editing

Sepraedit

Edit indirect for comments

Set the default mode to github flavored markdown, turn on smart use of fill column, and bind to the normal edit-indirect keybinding.

(use-package! separedit
  :config
  (setq separedit-default-mode 'gfm-mode
        separedit-continue-fill-column t))

Setup the binding

(map! :leader
      :desc "Separedit"
      "z s" #'separedit)

Rainbow delimiters

Makes pairs of delimiters into pretty colors. Hook this into prog-mode

(use-package! rainbow-delimiters
  :hook (prog-mode . rainbow-delimiters-mode))

YASnippet

Set the snippets directory to inside our org dir, since this gets synced

(after! yasnippet
  (add-to-list 'yas-snippet-dirs "~/Org/snippets")
  (yas-reload-all))

LSP Mode

Custom configuration for lsp-mode

Exclude nix directories from file watchers

(after! lsp-mode
  (add-to-list 'lsp-file-watch-ignored-directories "[/\\\\]\\result\\")
  (add-to-list 'lsp-file-watch-ignored-directories "[/\\\\]\\result-doc\\"))

LSP UI

Turn on the UI features we want

Sideline

Show as much as possible in the sideline

(after! lsp-ui
  (setq lsp-ui-sideline-show-diagnostics t
      lsp-ui-sideline-show-hover t
      lsp-ui-sideline-show-code-actions t))
Peeking

Turn on peeking, and show us the directory as well

(after! lsp-ui
  (setq lsp-ui-peek-enable t
      lsp-ui-peek-show-directory t))
Documentation

Show the documentation in a popup frame in the top right corner

(after! lsp-ui
  (setq lsp-ui-doc-enable t
      lsp-ui-doc-position 'top
      lsp-ui-doc-show-with-cursor t))

Keybindings

Add some additional keybinds:

  • SPC z l r to restart the lsp workspac
(after! lsp-mode
  (map! :leader
       (:prefix ("z l" . "lsp")
               "r" #'lsp-workspace-restart)))

e

Rust

Configuration specific for rust

LSP Tweaks

Most of these are defaults, but I like having them explicit for my sanity

(after! lsp-mode
  (setq lsp-auto-configure t
        lsp-lens-enable t
        lsp-rust-analyzer-cargo-watch-command "clippy"
        lsp-rust-analyzer-cargo-watch-args ["--all-features"]
        lsp-rust-analyzer-experimental-proc-attr-macros t
        lsp-rust-analyzer-proc-macro-enable t
        lsp-rust-analyzer-use-rustc-wrapper-for-build-scripts t
        lsp-rust-analyzer-import-enforce-granularity t
        lsp-rust-analyzer-diagnostics-enable-experimental t
        lsp-rust-analyzer-display-chaining-hints t))

Configure cargo commands

Configure the cargo clippy and cargo check invoked through rustic mode to utilize the --all-targets and --all-features arguments by default.

(setq rustic-cargo-check-arguments "--all-targets --all-features"
      rustic-default-clippy-arguments "--all-targets --all-features")

Nix

Use rnix-lsp

(after! lsp-mode
  (add-to-list 'lsp-language-id-configuration '(nix-mode . "nix"))
  (lsp-register-client
   (make-lsp-client :new-connection (lsp-stdio-connection '("rnix-lsp"))
                    :major-modes '(nix-mode)
                    :server-id 'nix)))

Idris2

Configure idris2-mode, which we do manually since it's not yet in doom.

(use-package! idris2-mode
  :config
  (setq idris2-stay-in-current-window-on-compiler-error t))

Disable company in idris2-mode, it's broken for now https://github.com/idris-community/idris2-mode/issues/36

(after! idris2-mode
  :config
  (setq company-global-modes '(not idris2-mode idris2-repl-mode)))

Quick interactive function to restart the idris2 process when we have problems

(defun nm/idris2-restart ()
  (interactive)
  (idris2-quit)
  (idris2-load-file))

Patch idris2-run via advice to pass through the environment, so that envrc will work properly. We need to patch idris2-ru, idris2-repl-buffer, and the idris2-ipkg-command family

(after! idris2-mode
  (require 'envrc)
  (advice-add 'idris2-run :around #'envrc-propagate-environment)
  (advice-add 'nm/idris2-restart :around #'envrc-propagate-environment)
  (advice-add 'idris2-repl :around #'envrc-propagate-environment)
  (advice-add 'idris2-repl-buffer :around #'envrc-propagate-environment)
  (advice-add 'idris2-load-file :around #'envrc-propagate-environment)
  (advice-add 'idris2-ipkg-command :around #'envrc-propagate-environment)
  (advice-add 'idris2-ipkg-build :around #'envrc-propagate-environment)
  (advice-add 'idris2-ipkg-clean :around #'envrc-propagate-environment))

Vim style bindings

(map! :after idris2-mode
      :map idris2-mode-map
      :localleader
      "a" #'idris2-load-file
      "b" #'idris2-proof-search
      "d" #'idris2-case-dwim
      "f" #'idris2-add-clause
      "g" #'idris2-add-missing
      "h" #'idris2-type-at-point
      "j" #'idris2-jump-to-def-same-window
      "k" #'idris2-docs-at-point
      "l" #'idris2-pop-to-repl
      ";" #'idris2-type-search
      "w" #'idris2-make-with-block
      "e" #'idris2-make-lemma
      "n" #'idris2-previous-error
      "m" #'idris2-next-error
      "z" #'idris2-apropos
      (:prefix ("i" . "ipkg" )
               "b" #'idris2-ipkg-build
               "c" #'idris2-ipkg-clean
               "o" #'idris2-open-package-file)
      (:prefix ("r" . "repl")
               "r" #'nm/idris2-restart
               "c" #'idris2-compile-and-execute)
      (:prefix ("p" . "prover")
               "p" #'idris2-prove-hole))

Haskell

Setup formatting

(after! lsp-haskell
  (setq lsp-haskell-formatting-provider "brittany"))

Composition

Modes for handling plain text and prose

Markdown

Everybody's favorite markup format

Markdown mode

Make the following configuration tweaks to result in a better markdown experience:

  • Use a variable pitch font (this is prose after all)
  • Turn on header scaling
  • Default to gfm mode for readmes
  • Turn on auto-fill mode
  • Hide mark up
  • Fontify code blocks with the language's native mode

The goal here is to create a more pretty and fluid composition environment for prose, closer to what you would get in a word processor, but without the horrors of wysiwyg.

(use-package! markdown-mode
  :mode ("README\\.md" . gfm-mode)
  :hook (markdown-mode . variable-pitch-mode)
        (markdown-mode . auto-fill-mode)
  :config
  (setq markdown-header-scaling t
        markdown-hide-markup t
        markdown-fontify-code-blocks-natively t))

Grip mode

Provide a live, rendered preview when editing markdown readmes using grip.

(use-package! grip-mode
  ;; :bind (:map markdown-mode-command-map
  ;;             ("g" . grip-mode))
  )

Plantuml mode

Dial down the indent

(use-package! plantuml-mode
  :config (setq plantuml-indent-level 4))

Applications

Emacs is good for more than just editing text

RSS

Use elfeed for RSS. Doom provides most of the configuration, but we'll make a few minor tweaks:

  • Automatically update the feed when opening elfeed
  • Set default filter to only show unread posts
  • Put the elfeed directory in the org dir (I have it in syncthing)
  • Create a global keybinding for elfeed (C-x w)
(use-package! elfeed
  :hook (elfeed-search-mode . elfeed-update)
  :hook (elfeed-show-mode . variable-pitch-mode)
  :hook (elfeed-show-mode . visual-line-mode)
  ;; :bind ("C-x w" . elfeed)
  :config
  (setq elfeed-search-filter "@4-weeks-ago +unread"
        elfeed-db-directory (concat org-directory "elfeed/db/")
        elfeed-enclosure-default-dir (concat org-directory "elfeed/enclosures/")
        shr-max-width nil)
  (make-directory elfeed-db-directory t))

Email

Do a lil sneaky to make this linux only

(when IS-LINUX

Use mu4e for email. Most of the bootstrap is provided by doom emacs. First, tell mu4e to use msmtp

(after! mu4e
  (setq sendmail-program (executable-find "msmtp")
        send-mail-function #'smtpmail-send-it
        message-sendmail-f-is-evil t
        message-sendmail-extra-arguments '("--read-envelope-from")
        message-send-mail-function #'message-send-mail-with-sendmail
        mu4e-headers-buffer-name "mail"))

Tell it where our account's stuff is

(after! mu4e
  (set-email-account! "mccarty.io"
                      '((mu4e-sent-folder . "/nathan@mccarty.io/Sent")
                        (mu4e-drafts-folder . "/nathan@mccarty.io/Drafts")
                        (mu4e-trash-folder . "/nathan@mccarty.io/Trash")
                        (mu4e-refile-folder . "/nathan@mccarty.io/Archive")
                        (smtpmail-smtp-user . "nathan@mccarty.io"))
                      t))

Setup our bookmarks, resetting the list of bookmarks first so we can go completely custom

(after! mu4e
  (setq mu4e-bookmarks '())
  (add-to-list 'mu4e-bookmarks
               '(:name "All Mail"
                 :key ?a
                 :query "NOT flag:trashed"))
  (add-to-list 'mu4e-bookmarks
               '(:name "Unread Notifications - nathan@mccarty.io"
                 :key ?n
                 :query "maildir:\"/nathan@mccarty.io/Folders/Notifications/\" AND NOT flag:trashed AND flag:unread"))
  (add-to-list 'mu4e-bookmarks
               '(:name "Unread Mailing Lists - nathan@mccarty.io"
                 :key ?m
                 :query "maildir:\"/nathan@mccarty.io/Folders/Mailing Lists/\" AND NOT flag:trashed AND flag:unread"))
  (add-to-list 'mu4e-bookmarks
               '(:name "Inbox - nathan@mccarty.io"
                 :key ?i
                 :query "maildir:\"/nathan@mccarty.io/Inbox\" AND NOT flag:trashed"))
  (add-to-list 'mu4e-bookmarks
               '(:name "Unread - All"
                 :key ?U
                 :query "flag:unread AND NOT flag:trashed AND NOT maildir:\"/nathan@mccarty.io/Folders/Notifications/\" AND NOT maildir:\"/nathan@mccarty.io/Folders/Mailing Lists/\""))
  (add-to-list 'mu4e-bookmarks
               '(:name "Unread - Non-Inbox"
                 :key ?u
                 :query "flag:unread AND NOT flag:trashed AND NOT maildir:\"/nathan@mccarty.io/Folders/Notifications/\" AND NOT maildir:\"/nathan@mccarty.io/Folders/Mailing Lists/\" AND NOT maildir:\"/nathan@mccarty.io/Inbox\"")))

Setup the maildirs we want to see, we'll show our notifications

(after! mu4e
  (setq mu4e-maildir-shortcuts
        '((:maildir "/nathan@mccarty.io/Folders/Notifications/Github" :key ?h)
          (:maildir "/nathan@mccarty.io/Folders/Notifications/Gitlab" :key ?l)
          (:maildir "/nathan@mccarty.io/Folders/Notifications/SourceHut" :key ?s)
          (:maildir "/nathan@mccarty.io/Folders/Archival/Receipts/2023" :key ?r)
          (:maildir "/nathan@mccarty.io/Folders/Archival/Informed Delivery" :key ?i))))

Tell it not to update the mail itself, we have a systemd unit for that

(setq +mu4e-backend nil)
(after! mu4e
        (setq mu4e-get-mail-command "mbsync -a"
              mu4e-update-interval 300))

We need to tell mu4e to rename files when they are moved, or else mbsync will break, see issue and blog post

(after! mu4e
  (setq mu4e-change-filenames-when-moving t))

Now setup notifications

(use-package! mu4e-alert
  :demand t
  :config
  (mu4e-alert-set-default-style 'libnotify)
  (setq mu4e-alert-interesting-mail-query
        (concat
         "flag:unread"
         " AND NOT flag:trashed"
         " AND maildir:"
         "\"/nathan@mccarty.io/Inbox\""))
  (add-hook 'after-init-hook #'mu4e-alert-enable-notifications)
  (add-hook 'after-init-hook #'mu4e-alert-enable-mode-line-display))
)

Edit with Emacs

(use-package! edit-server
  :commands edit-server-start
  :init (if after-init-time
            (edit-server-start)
          (add-hook 'after-init-hook
                    #'(lambda() (edit-server-start))))
  :config (setq edit-server-new-frame nil))

Emacs calculator

Bind calc to SPC o c

(require 'calc)
(map! :leader
      (:prefix ("z c" . "calc")
               "c" #'calc
               "q" #'quick-calc
               "d" #'calc-dispatch
               "g" #'calc-grab-region))

Group digits by default

(setq calc-group-digits t)