General Emacs Configuration

Table of Contents

A literate programming file for configuring Emacs.

Basic Configuration

I begin with configuration of Emacs that isn’t package-specific. For instance, I hate a fat-finger that stop Emacs:

(setq confirm-kill-emacs 'yes-or-no-p)

New way to display line-numbers. I set mine to relative so that I can jump up and down by that value. Set this to nil to turn off, or t to be absolute.

(setq display-line-numbers t
      display-line-numbers-type 'relative)

I like the rendering to curved quotes using text-quoting-style, because it improves the readability of documentation strings in the ∗Help∗ buffer and whatnot.

(setq text-quoting-style 'curve)

Changes and settings I like introduced in Emacs 28:

(setq use-short-answers t
      describe-bindings-outline t
      completions-detailed t)

In Emacs version 28, we can hide commands in M-x which do not apply to the current mode.

(setq read-extended-command-predicate
      #'command-completion-default-include-p)

Unicode ellipsis are nicer than three dots:

(setq truncate-string-ellipsis "…")

When I get an error, I need a stack trace to figure out the problem. Yeah, when I stop fiddling with Emacs, this should go off:

(setq debug-on-error t)

And some Mac-specific settings:

(when (ha-running-on-macos?)
  (setq mac-option-modifier 'meta
        mac-command-modifier 'super)
  (add-to-list 'default-frame-alist '(ns-transparent-titlebar . t))
  (add-to-list 'default-frame-alist '(ns-appearance . dark)))

Basic Configuration

Initial Settings and UI

Let's turn off the menu and other settings:

(when (display-graphic-p)
  (context-menu-mode 1)
  (tool-bar-mode -1)
  (scroll-bar-mode -1)
  (horizontal-scroll-bar-mode -1)
  (setq visible-bell 1
        frame-inhibit-implied-resize t))

I like being able to enable local variables in .dir-local.el files:

(setq enable-local-variables t)

File Access

Remote Files

To speed up TRAMP access, let’s disabled lock files, you know, the ones that have the # surrounding characters:

(setq remote-file-name-inhibit-locks t)

What do I think about remote-file-name-inhibit-auto-save-visited?

During remote access, TRAMP can slow down performing Git operations. Let’s turn that off as well:

(defun turn-off-vc-for-remote-files ()
  "Disable"
  (when (file-remote-p (buffer-file-name))
    (setq-local vc-handled-backends nil)))

(add-hook 'find-file-hook 'turn-off-vc-for-remote-files)

Changes on Save

Always spaces and never tabs. Note that we use setq-default since indent-tabs-mode is a buffer-local variable, meaning using setq, sets it for that buffer file. We want this globally the default:

(setq-default indent-tabs-mode nil)

When I push changes to my files to Gerrit and other code review, I don’t want trailing spaces or any tabs to appear, so let’s fix all files when I save them:

(defun ha-cleanup-buffer-file ()
  "Cleanup a file, often done before a file save."
  (interactive)
  (ignore-errors
    (unless (or (equal major-mode 'makefile-mode)
                (equal major-mode 'makefile-bsdmake-mode))
      (untabify (point-min) (point-max)))
    (delete-trailing-whitespace)))

(add-hook 'before-save-hook #'ha-cleanup-buffer-file)

Recent Files

The recentf feature has been in Emacs for a long time, but it has a problem with Tramp, as we need to turn off the cleanup feature that attempts to stat all the files and remove them from the recent accessed list if they are readable. The requires recentf to open up a remote files which blocks Emacs at the most inopportune times… like when trying to reboot the machine.

(use-package recentf
  :straight (:type built-in)
  :config
  (setq recentf-auto-cleanup 'never) ;; disable before we start recentf!
  (recentf-mode 1))

File Backups

While I use git as much as I can, sometimes Emacs’ built-in file backup and versioning feature has saved me for files that aren’t.

As Phil Jackson mentioned, Emacs has a lot of variations to its file backup strategy, and either change the backup-directory-alist to put individual file backups elsewhere, e.g.

(setq backup-directory-alist `(("." . ,(concat user-emacs-directory "backups"))))

Or leave them in the current directory, but create an alias so ls doesn’t display them, e.g.

alias ls="ls --color=auto --hide='*~'"

I'm leaving them side-by-side, but I am keeping some extra copies:

(setq create-lockfiles nil   ; Having .# files around ain't helpful
      auto-save-default t
      delete-old-versions t
      kept-new-versions 6
      kept-old-versions 2
      version-control t)

The version-control variable affect backups (not some sort of global VC setting), this makes numeric backups.

Auto Save of Files

Save the file whenever I move away from Emacs (see this essay):

(defun save-all-buffers ()
  "Saves all buffers, because, why not?"
  (interactive)
  (save-some-buffers t))

(add-hook 'focus-out-hook 'save-all-buffers)

Download Files via URL

Might be nice to have a url-download function that just grabs a file from a website without fuss (or other dependencies). Easy enough to prototype, but dealing with errors are another thing …

(defun url-download (url dest)
  "Download the file as URL and save in file, DEST.
Note that this doesn't do any error checking ATM."
  (interactive "sURL: \nDDestination: ")
  (let* ((url-parts (url-generic-parse-url url))
         (url-path  (url-filename url-parts))
         (filename  (file-name-nondirectory url-path))
         (target    (if (file-directory-p dest)
                        (file-name-concat dest filename)
                      dest))
         (callback (lambda (status destination)
                     (unwind-protect
                         (pcase status
                           (`(:error . ,_)
                            (message "Error downloading %s: %s" url (plist-get status :error)))
                           (_ (progn
                                ;; (switch-to-buffer (current-buffer))
                                (delete-region (point-min) (1+ url-http-end-of-headers))
                                (write-file destination)
                                (kill-buffer)
                                (when (called-interactively-p 'any)
                                  (kill-new destination)))))))))
    (message "Retrieving %s into %s" url target)
    (url-retrieve url callback (list target))))

This function can be called interactively with a URL and a directory (and it attempts to create the name of the destination file based on the latter-part of the URL), or called programmatically, like:

(url-download "https://www.emacswiki.org/emacs/download/bookmark+.el"
              "~/Downloads/bookmark-plus.el")

Completing Read User Interface

After using Ivy, I am going the route of a completing-read interface that extends the original Emacs API, as opposed to implementing backend-engines or complete replacements.

Vertico

The vertico package puts the completing read in a vertical format, and like Selectrum, it extends Emacs’ built-in functionality, instead of adding a new process. This means all these projects work together.

(use-package vertico
  :config (vertico-mode))

My issue with Vertico is when calling find-file, the Return key opens dired, instead of inserting the directory at point. This package addresses this:

(use-package vertico-directory
  :straight (el-patch :files ("~/.emacs.d/straight/repos/vertico/extensions/vertico-directory.el"))
  ;; More convenient directory navigation commands
  :bind (:map vertico-map
              ("RET" . vertico-directory-enter)
              ; ("DEL" . vertico-directory-delete-word)
              ("M-RET" . minibuffer-force-complete-and-exit)
              ("M-TAB" . minibuffer-complete))
  ;; Tidy shadowed file names
  :hook (rfn-eshadow-update-overlay . vertico-directory-tidy))

Hotfuzz

This fuzzy completion style is like the built-in flex style, but has a better scoring algorithm, non-greedy and ranks completions that match at word; path component; or camelCase boundaries higher.

(use-package hotfuzz)

While flexible at matching, you have to get the order correct. For instance, alireg matches with align-regexp, but regali does not, so we will use hotfuzz for scoring, and not use this as a completion-project (see the fussy project below).

Orderless

While the space can be use to separate words (acting a bit like a .* regular expression), the orderless project allows those words to be in any order.

(use-package orderless
  :commands (orderless-filter)
  :custom
  (completion-ignore-case t)
  (completion-category-defaults nil)
  (completion-category-overrides '((file (styles partial-completion))))
  :init
  (push 'orderless completion-styles))

Note: Open more than one file at once with find-file with a wildcard. We may also give the initials completion style a try.

Fussy Filtering and Matching

The fussy project is a fuzzy pattern matching extension for the normal completing-read interface. By default, it uses flx, but we can specify other sorting and filtering algorithms.

How does it compare? Once upon a time, I enjoyed typing plp for package-list-packages, and when I switched to orderless, I would need to put a space between the words. While I will continue to play with the different mechanism, I’ll combine hotfuzz and orderless.

(use-package fussy
  ;; :straight (:host github :repo "jojojames/fussy")
  :config
  (push 'fussy completion-styles)
  (setq completion-category-defaults nil
        completion-category-overrides nil
        fussy-filter-fn 'fussy-filter-orderless-flex
        fussy-score-fn 'fussy-hotfuzz-score))

Savehist

Persist history over Emacs restarts using the built-in savehist project. Since both Vertico and Selectrum sorts by history position, this should make the choice smarter with time.

(use-package savehist
  :init
  (savehist-mode))

Marginalia

The marginalia package gives a preview of M-x functions with a one line description, extra information when selecting files, etc. Nice enhancement without learning any new keybindings.

;; Enable richer annotations using the Marginalia package
(use-package marginalia
  :init
  (setq marginalia-annotators-heavy t)
  :config
  (add-to-list 'marginalia-command-categories '(project-find-file . file))
  (marginalia-mode))

Key Bindings

To begin my binding changes, let's turn on which-key:

(use-package which-key
  :init   (setq which-key-popup-type 'minibuffer)
  :config (which-key-mode))

Why would I ever quit Emacs with a simple keybinding? Let’s override it:

(global-set-key (kbd "s-q") 'bury-buffer)

Undo

I mean, I always use C-/ for undo (and C-? for redo), but when I’m on the Mac, I need to cover my bases.

Why use undo-fu instead of the built-in undo functionality? Well, there isn’t much to the project (that’s a good thing), but It basically doesn’t cycle around the redo, which annoying.

(use-package undo-fu
  :config
  (global-set-key [remap undo] 'undo-fu-only-undo)
  (global-set-key [remap undo-redo] 'undo-fu-only-redo)
  (global-unset-key (kbd "s-z"))
  (global-set-key (kbd "s-z")   'undo-fu-only-undo)
  (global-set-key (kbd "s-S-z") 'undo-fu-only-redo))

Leader Sequences

Pressing the SPACE can activate a leader key sequence that I define with general package.

(ha-hamacs-load "ha-general.org")

This extends the use-package to include a :general keybinding section.

Additional Global Packages

The following defines my use of the Emacs completion system. I’ve decided my rules will be:

  • Nothing should automatically appear; that is annoying and distracting.
  • Spelling in org files (abbrev or hippie expander) and code completion are separate, but I’m not sure if I can split them
  • IDEs overuse the TAB binding, and I should re-think the bindings.

Auto Completion

I don’t find the Emacs completion system obvious, with different interfaces, some distinct, some connected. Here’s the summary as I understand:

indent-for-tab-command, which we can call:
  └─ completion-at-point, which calls:
               └─ completion-at-point-functions (capf), which can call:
                             └─ hippie and dabbrev functions

In org-mode, TAB calls org-cycle, which, in the context of typing text, calls the binding for TAB, which is the indent-for-tab-command. If the line is indented, I can complete the word:

(setq tab-always-indent 'complete
      tab-first-completion 'word-or-paren
      completion-cycle-threshold nil)

Note that no matter the setting for tab-first-completion, hitting TAB twice, results in completion.

This calls completion-at-point. This code (from mini-buffer) doubles with the other completing processes (like completing-read) and presents choices based on a series of functions (see this essay for details). This will call into the CAPF function list (see the variable, completion-at-point-functions and the section for details).

Hippie Expand

The venerable hippie-expand function does a better job than the default, dabbrev-expand, so let’s swap it out (see this essay by Mickey Petersen) with its default key of M-/ (easy to type on the laptop) as well as C-Tab (easier on mechanical keyboards):

(global-set-key [remap dabbrev-expand] 'hippie-expand)
(global-set-key (kbd "M-<tab>") 'completion-at-point)

Details on its job? We need to update its list of expanders. I don’t care much for try-expand-line, so that is not on the list.

(setq hippie-expand-try-functions-list
      '(try-complete-file-name-partially   ; complete filenames, start with /
        try-complete-file-name
        yas-hippie-try-expand              ; expand matching snippets
        try-expand-all-abbrevs
        try-expand-list                    ; help when args repeated another's args
        try-expand-dabbrev
        try-expand-dabbrev-all-buffers
        try-expand-whole-kill              ; grab text from the kill ring
        try-expand-dabbrev-from-kill       ; as above
        try-complete-lisp-symbol-partially
        try-complete-lisp-symbol))

In the shell, IDEs and other systems, the key binding is typically TAB. In modes other than org-mode, TAB re-indents the line with indent-for-tab-command, but I find that I want that feature when I’m in Evil’s normal state and hit the = key, so changing this sounds good. But why not have both?

(advice-add #'indent-for-tab-command :after #'hippie-expand)

Corfu

The default completion system either inserts the first option directly in the text (without cycling, so let’s hope it gets it right the first time), or presents choices in another buffer (who wants to hop to it to select an expansion).

After using company for my completion back-end, I switch to corfu as it works with the variable-spaced font of my org files (also see this essay for my initial motivation).

(use-package corfu
  :custom
  (corfu-cycle t)
  (corfu-separator ?\s)
  :init
  (global-corfu-mode))

Yet Another Snippet System (YASnippets)

Using yasnippet to convert templates into text:

(use-package yasnippet
  :config
  (add-to-list 'yas-snippet-dirs
               (expand-file-name "snippets" user-emacs-directory))
  (yas-global-mode +1))

Check out the documentation for writing them.

Since I have troubles installing Doom’s collection of snippets, lets use the yasnippet-snippets package:

(use-package yasnippet-snippets)

Auto Insert Templates

The auto-insert feature is a wee bit complicated. All I want is to associate a filename regular expression with a YASnippet template. I'm stealing some ideas from Henrik Lissner's set-file-template! macro, but simpler?

(use-package autoinsert
  :init
  (setq auto-insert-directory (expand-file-name "templates" user-emacs-directory))
  ;; Don't prompt before insertion:
  (setq auto-insert-query nil)

  (add-hook 'find-file-hook 'auto-insert)
  (auto-insert-mode t))

Since auto insertion requires entering data for particular fields, and for that Yasnippet is better, so in this case, we combine them:

(defun ha-autoinsert-yas-expand()
  "Replace text in yasnippet template."
  (let ((orig-mode major-mode)
        (auto-insert-query nil)
        (yas-indent-line nil))
    (yas/minor-mode 1)
    (when (fboundp 'evil-insert-state)
      (evil-insert-state))
    (yas-expand-snippet (buffer-string) (point-min) (point-max))))

And since I'll be associating snippets with new files all over my configuration, let's make a helper function:

(defun ha-auto-insert-file (filename-re snippet-name)
  "Autofill file buffer matching FILENAME-RE regular expression.
  The contents inserted from the YAS SNIPPET-NAME."
  ;; The define-auto-insert takes a regular expression and an ACTION:
  ;; ACTION may also be a vector containing successive single actions.
  (define-auto-insert filename-re
    (vector snippet-name 'ha-autoinsert-yas-expand)))

As an example of its use, any Org files loaded in this project should insert my config file:

(ha-auto-insert-file (rx "hamacs/" (one-or-more any) ".org" eol) "hamacs-config")
(ha-auto-insert-file (rx ".dir-locals.el") "dir-locals.el")

Visual Replace with Visual Regular Expressions

I appreciated the visual-regexp package to see what you want to change before executing the replace.

(use-package visual-regexp
  :bind (("C-c r" . vr/replace)
         ("C-c q" . vr/query-replace))
  :general (:states 'normal "g r" '("replace" . vr/replace))
  :config (ha-leader
            "r" '("replace" . vr/replace)
            "R" '("query replace" . vr/query-replace)))

For all other functions that use regular expressions, many call the function, read-regexp, and thought it would be helpful if I could type rx:… and allow me to take advantage of the rx macro.

(defun read-regexp-with-rx (input)
  "Advice for `read-regexp' to allow specifying `rx' expressions.
If INPUT starts with rx: then the rest of the input is given to
the `rx' macro, and function returns that regular expression.
Otherwise, return INPUT."
  (if (string-match (rx bos "rx:" (zero-or-more space)
                        (group (one-or-more any)))
                    input)
      (let* ((rx-input (match-string 1 input))
             (rx-expr  (format "(rx %s)" rx-input)))
        (message "%s and %s" rx-input rx-expr)
        (eval (read rx-expr)))
    input))

Let’s right a little test case to make sure it works:

(ert-deftest read-regexp-with-rx-test ()
  (should (equal (read-regexp-with-rx "foo|bar") "foo|bar"))
  (should (equal (read-regexp-with-rx "rx:\"foobar\"") "foobar"))
  (should (equal (read-regexp-with-rx "rx:bol (zero-or-more space) eol") "^[[:space:]]*$")))

Now we just need to filter the results from the built-in Emacs function:

(advice-add 'read-regexp :filter-return 'read-regexp-with-rx)

Jump with Avy

While I grew up on Control S, I am liking the mental model associated with the avy project that allows a jump among matches across all visible windows. I use the F18 key on my keyboard that should be easy to use, but g o seems obvious.

(use-package avy
  :init
  (setq avy-all-windows t
        avy-single-candidate-jump nil   ; May want to yank the candidate
        avy-orders-alist
        '((avy-goto-char . avy-order-closest)
          (avy-goto-word-0 . avy-order-closest)))

  :config (ha-leader "j" '("jump" . avy-goto-char-timer))

  :general
  (:states 'normal "go" '("avy goto" . avy-goto-char-timer)
                   "s"  '("avy word" . avy-goto-subword-1))

  :bind ("<f18>" . avy-goto-char-timer)
        ("s-g"   . avy-goto-char-timer)
        ("s-;"   . avy-next)
        ("s-a"   . avy-prev))

Note: The links should be shorter near the point as opposed to starting from the top of the window.

If you hit the following keys before you select a target, you get special actions (check out this great essay about this understated feature):

n
copies the matching target word, well, from the target to the end of the word, so match at the beginning.
x
kill-word … which puts it in the kill-ring to be pasted later.
X
kill-stay … kills the target, but leaves the cursor in the current place.
t
teleport … bring the word at the target to the current point … great in the shell.
m
mark … select the word at target
y
yank … puts any word on the screen on the clipbard.
Y
yank-line … puts the entire target line on the clipboard.
i
ispell … fix spelling from a distance.
z
zap-to-char … kill from current point to the target

I’m not thinking of ideas of what would be useful, e.g. v to highlight from cursor to target, etc.

Link Hint, the Link Jumper

The Goto Address mode (see this online link) turns URLs into clickable links. Nice feature and built into Emacs, but it requires using the mouse or moving to the URL and hitting Return (if you like this idea, check out Álvaro Ramírez's configuration for this).

I appreciated ace-link’s idea for hyperlinks on Org, EWW and Info pages, as it allowed you to jump to a URL from any location on the screen. The link-hint project does this, but works with more types of files and links:

(use-package link-hint
  :bind
  ("s-o" . link-hint-open-link)
  ("s-y" . link-hint-copy-link)
  :general
  (:states 'normal
           "gl" '("open link" . link-hint-open-link)
           "gL" '("open link→window" . link-hint-open-link-ace-window)
           "gm" '("copy link" . link-hint-copy-link))
  (:states 'normal :keymaps 'eww-mode-map
           "o" 'link-hint-open-link)
  (:states 'normal :keymaps 'Info-mode-map
           "o" 'link-hint-open-link))

Can I open a link in another window? The idea with this is that I can select a link, and with multiple windows open, I can specify where the *eww* window should show the link. If only two windows, then the new EWW buffer shows in the other one.

(defun link-hint-open-link-ace-window ()
  (interactive)
  (link-hint-copy-link)
  (ace-select-window)
  (eww (current-kill 0)))

Expand Region

Magnar Sveen's expand-region project allows me to hit v in visual mode, and have the selection grow by syntactical units.

(use-package expand-region
  :bind ("C-=" . er/expand-region)

  :general
  ;; Use escape to get out of visual mode, but hitting v again expands the selection.
  (:states 'visual
           "v" 'er/expand-region
           "V" 'er/contract-region
           "-" 'er/contract-region))

Working Layout

While editing any file on disk is easy enough, I like the mental context switch associated with a full-screen window frame showing all the buffers of a project task (often a direct link to a repository project, but not always).

Projects

Since I wasn’t using all the features that projectile provides, I have switched to the built-in project functions.

(use-package emacs
  :config
  (ha-leader
    "p"  '(:ignore t :which-key "projects")
    "p W" '("initialize workspace" . ha-workspace-initialize)
    "p n" '("new project space" . ha-project-persp)

    "p !" '("run cmd in project root" . project-shell-command)
    "p &" '("run cmd async" . project-async-shell-command)
    "p a" '("add new project" . project-remember-projects-under)
    "p d" '("remove known project" . project-forget-project)
    "p k" '("kill project buffers" . project-kill-buffers)
    "p p" '("switch project" . project-switch-project)

    "p f" '("find file" . project-find-file)
    "p F" '("find file o/win" . project-find-file-other-window)
    "p b" '("switch to project buffer" . project-switch-to-buffer)

    "p C" '("compile in project" . compile-project)
    "p c" '("recompile" . recompile)

    "p e" '("project shell" . project-eshell)
    "p s" '("project shell" . project-shell)))

Workspaces

A workspace (at least to me) requires a quick jump to a collection of buffer windows organized around a project or task. For this, I'm basing my work on the perspective.el project.

I build a Hydra to dynamically list the current projects as well as select the project. To do this, we need a way to generate a string of the perspectives in alphabetical order:

(defun ha--persp-label (num names)
  "Return string of numbered elements. NUM is the starting
number and NAMES is a list of strings."
  (when names
    (concat
     (format "  %d: %s%s"    ; Shame that the following doesn't work:
             num             ; (propertize (number-to-string num) :foreground "#00a0")
             (car names)     ; Nor does surrounding the number with underbars.

             (if (equal (car names) (persp-name (persp-curr))) "*" ""))
     (ha--persp-label (1+ num) (cdr names)))))

(defun ha-persp-labels ()
  "Return a string of numbered elements from a list of names."
  (ha--persp-label 1 (sort (hash-table-keys (perspectives-hash)) 's-less?)))

Build the hydra as well as configure the perspective project.

(use-package perspective
  :custom
  (persp-modestring-short t)
  (persp-show-modestring t)

  :config
  (setq persp-suppress-no-prefix-key-warning t)

  (persp-mode +1)

  (defhydra hydra-workspace-leader (:color blue :hint nil) "
  Workspaces- %s(ha-persp-labels)
  _n_: new project  _r_: rename    _a_: add buffer     _l_: load worksp
  _]_: next worksp  _d_: delete    _b_: goto buffer    _s_: save worksp
  _[_: previous     _W_: init all  _k_: remove buffer  _`_: to last worksp "
    ("TAB" persp-switch-quick)
    ("RET" persp-switch)
    ("`" persp-switch-last)
    ("1" (persp-switch-by-number 1))
    ("2" (persp-switch-by-number 2))
    ("3" (persp-switch-by-number 3))
    ("4" (persp-switch-by-number 4))
    ("5" (persp-switch-by-number 5))
    ("6" (persp-switch-by-number 6))
    ("7" (persp-switch-by-number 7))
    ("8" (persp-switch-by-number 8))
    ("9" (persp-switch-by-number 9))
    ("0" (persp-switch-by-number 0))
    ("n" ha-project-persp)
    ("N" ha-new-persp)
    ("]" persp-next :color pink)
    ("[" persp-prev :color pink)
    ("d" persp-kill)
    ("W" ha-workspace-initialize)
    ("a" persp-add-buffer)
    ("b" persp-switch-to-buffer)
    ("k" persp-remove-buffer)
    ("K" persp-kill-buffer)
    ("m" persp-merge)
    ("u" persp-unmerge)
    ("i" persp-import)
    ("r" persp-rename)
    ("s" persp-state-save)
    ("l" persp-state-load)
    ("w" ha-switch-to-special)  ; The most special perspective
    ("q" nil)
    ("C-g" nil)))

Let’s give it a binding:

(ha-leader "TAB" '("workspaces" . hydra-workspace-leader/body))

When called, it can look like:

projects-hydra.png

The special perspective is a nice shortcut to the one I use the most:

(defun ha-switch-to-special ()
  "Change to the projects perspective."
  (interactive)
  (persp-switch "projects"))

Predefined Workspaces

Let's describe a list of startup project workspaces. This way, I don't need the clutter of the recent state, but also get back to a state of mental normality. Granted, this list is essentially a list of projects that I'm currently developing, so I expect this to change often.

(defvar ha-workspace-projects-personal nil "List of default projects with a name.")

(add-to-list 'ha-workspace-projects-personal
             '("projects" "~/projects" ("breathe.org" "tasks.org")))
(add-to-list 'ha-workspace-projects-personal
             '("personal" "~/personal" ("general.org")))
(add-to-list 'ha-workspace-projects-personal
             '("technical" "~/technical" ("ansible.org")))
(add-to-list 'ha-workspace-projects-personal
             '("hamacs" "~/other/hamacs" ("README.org" "ha-config.org")))

Given a list of information about project-workspaces, can we create them all?

(defun ha-persp-exists? (name)
  "Return non-nill if a perspective of NAME exists."
  (when (fboundp 'perspectives-hash)
    (seq-contains (hash-table-keys (perspectives-hash)) name)))

(defun ha-workspace-initialize (&optional projects)
  "Precreate workspace projects from a PROJECTS list.
  Each entry in the list is a list containing:
    - name (as a string)
    - project root directory
    - a optional list of files to display"
  (interactive)
  (unless projects
    (setq projects ha-workspace-projects-personal))

  (dolist (project projects)
    (-let (((name root files) project))
      (unless (ha-persp-exists? name)
        (message "Creating workspace: %s (from %s)" name root)
        (ha-project-persp root name files))))
  (persp-switch "main"))

Often, but not always, I want a perspective based on an actual Git repository, e.g. a project. Emacs calls these transients.

(defun ha-project-persp (project &optional name files)
  "Create a new perspective, and then switch to the PROJECT.
If NAME is not given, then figure it out based on the name of the
PROJECT. If FILES aren't specified, then see if there is a
README. Otherwise, pull up Dired."
  (interactive (list (completing-read "Project: "
                                      (project-known-project-roots))))
  (when (f-directory-p project)
    (unless name
      (setq name (f-filename project)))
    (persp-switch name)

    ;; Unclear if the following is actually necessary.
    (ignore-errors
      (project-remember-project root)
      (project-switch-project root))

    (let ((recent-files (thread-last recentf-list
                                     (--filter (s-starts-with? project it))
                                     (-take 3)))
          (readme-org (f-join project "README.org"))
          (readme-org (f-join project "README.md"))
          (readme-md  (f-join project "README.rst")))
      (cond
       (files                  (ha--project-show-files project files))
       (recent-files           (ha--project-show-files project recent-files))
       ((f-exists? readme-org) (find-file readme-org))
       ((f-exists? readme-md)  (find-file readme-md))
       (t                      (dirvish project))))))

When starting a new perspective, and I specify more than one file, this function splits the window horizontally for each file.

(defun ha--project-show-files (root files)
  "Display a list of FILES in a project ROOT directory.
Each file gets its own window (so don't make the list of files
long)."
  (when files
    (let ((default-directory root)
          (file (car files))
          (more (cdr files)))
      (message "Loading files from %s ... %s and %s" root file more)
      (when (f-exists? file)
        (find-file file))
      (when more
        (split-window-horizontally)
        (ha--project-show-files root more)))))

The persp-switch allows me to select or create a new project, but what if we insisted on a new workspace?

(defun ha-new-persp (name)
  (interactive "sNew Workspace: ")
  (persp-switch name)
  (cond
   ((s-ends-with? "mail" name) (notmuch))
   ((s-starts-with? "twit" name) (twit))))

Once we create the new perspective workspace, if it matches a particular name, I pretty much know what function I would like to call.

Pretty Good Encryption

For details on using GnuPG in Emacs, see Mickey Petersen’s GnuPG Essay.

On Linux, GPG is pretty straight-forward, but on the Mac, I often have troubles doing:

brew install gpg

Next, on every reboot, start the agent:

/opt/homebrew/bin/gpg-agent --daemon

Also, as bytedude mentions, I need to use the epa-pineentry-mode to loopback to actually get a prompt for the password, instead of an error. Also let's cache as much as possible, as my home machine is pretty safe, and my laptop is shutdown a lot.

(use-package epa-file
  :straight (:type built-in)
  :custom
  (epg-debug t)
  (auth-source-debug t)
  ;; Since I normally want symmetric encryption, and don't want
  ;; to use the "key selection":
  (epa-file-select-keys 'symmetric-only)
  ;; Make sure we prompt in the minibuffer for the password:
  (epg-pinentry-mode 'loopback)
  ;; I trust my Emacs session, so I don't bother expiring my pass:
  (auth-source-cache-expiry nil))

Need to make sure that Emacs will handle the prompts, and turn it on:

(use-package epa-file
  :config
  (setenv "GPG_AGENT_INFO" nil)
  (epa-file-enable))