Skip to content

VojtechStep/igloo.el

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Igloo - The Emacs Glue

About

History is scraps of evidence joined by the glue of imagination

​- First image on DuckDuckGo for the query “famous glue quote”

This repo is my literate Emacs configuration. If you are unfamiliar with what literate programming is, basically every code snippet in this file gets yoinked out and put into the init.el file, which then serves as an init script for Emacs. Writing the configuration in an org file allows for (in my opinion) better documentation and organization.

To achieve the best performance, the configuration requires a build step.

Running make will collect all the code into init.el (and early-init.el, more on that later) and byte compile it. Once I get my hands dirty with native compilation, I hope to make use of it too. It will also initialize the package manager and pull all dependencies, so if you add any remote packages, it will require an internet connection.

Running make install will put the compiled and source files into $PREFIX/emacs/ (PREFIX is build by default, but see below). If you want to OVERWRITE your existing configuration, you can use make install PREFIX=$HOME/.config. This does NO BACKUP. Use at your own risk.

Note that because of Emacs’ insufficient support for non-standard user configuration directories, it is explicitly not a supported use case to set PREFIX to anything else than $HOME/.config, even for the build step. It works kinda, but sometimes straight.el and native compilation get confused and still work as if the user config was in $HOME/.config. Also, since early-init.el can’t be loaded from a non-standard location before creating a frame, many of its configurations straight up don’t have any effect.

Here’s a cherry-picked list of some of the packages I use:

Dependencies

Build

Runtime

  • Aspell (not a very hard requirement, Ispell or Hunspell should work just fine)
  • fd (find replacement)
  • rg (grep replacement)
  • direnv (directory-local environment variables)

Personal configuration

Personal configuration is managed in room.org, which is not tracked in version control, and it’s used to configure such things as RSS feeds or email accounts.

The template room.org.tpl is provided to be filled by the user. Running make room.org will either copy the template into room.org, or alternatively, if an existing user configuration is found, it will copy it to room.org.new, and it will prevent the user from building the project until it is filled in and manually renamed to room.org.

Combining igloo.org and room.org is a little hacky. A temporary org file is created, and the two configurations are copied into it. Then, the temporary file is tangled and deleted. AFAIK this is the only way to do it, because 1) #+INCLUDE: directives are only resolved during exporting, not tangling, and 2) exporting to org files using ox-org strips header arguments from source blocks.

Because of this process, an important caveat is introduced: top-level properties, such as #+PROPERTY: header-args :tangle no, don’t work in room.org. Instead, all content should be put under a first-level heading which contains a :PROPERTIES: drawer.

Process

First thing in the file is going to be the file header. It’s useful to add a little description and enable lexical binding (it’s all the rage these days).

;;; Igloo --- The glue connecting my whole operating experience. YMMV -*- lexical-binding: t; -*-

;;; Commentary:
;; Read https://github.com/VojtechStep/igloo.el/blob/master/igloo.org
;;
;; DO NOT CHANGE THIS FILE
;; This file is generated from `igloo.org' and will be overwritten on
;; every invocation of `make'

;;; Code:

(eval-when-compile (require 'cl-macs))
(unless (featurep 'early-init)
  (load (eval-when-compile early-init-file)))

Philosophy

This configuration is built on the simple principle of “do what you can at compile time”. This means that we have to properly differentiate what happens at comptime (via eval-when-compile), what happens at runtime (via plain forms) and what happens during both (via eval-and-compile).

First, the package manager is installed and configured at compile time. At runtime, we only load it via its entry point, the bootstrap file.

The use-package clauses need to be evaluated at both comptime, to clone and build the packages, and at runtime to expose them to the system.

For this reason, we define a macro to substitute the (use-package ...) form with.

(eval-and-compile
  (defmacro igloo-use-package (&rest body)
    "Passes BODY to `use-package' and call it at comptime and runtime."
    (declare (indent defun))
    `(eval-and-compile
       ,(cons 'use-package body))))

Conventions

  • Prefer lazy loading
  • Use igloo-use-package for builtin packages too
  • Follow this keyword order for igloo-use-package clauses:
'(:disabled
  :if
  :after
  :straight
  :load-path
  :demand
  :defer
  :mode
  :commands
  :hook
  :apheleia
  :custom
  :custom-face
  :general
  :preface
  :init
  :config)
  • Declare keybindings to package commands in its igloo-use-package declaration, via :general
  • Outside of :general blocks, use the general-def macro to define keybindings
    • This should only be necessary for cases where evil-collection overrides some bindings in :config, and we need to override them back
  • Use igloo-leader for keybindings that should be always available (such as org-capture or consult-buffer)
  • Use igloo-local-leader for keybindings relevant to a specific major or minor mode (such as consult-outline or flycheck-next-error)
  • Use unquoted state and keymap arguments, without specifying the keywords, like so: (general-def (motion normal) org-mode ...)
    • With two exceptions when using the :general use-package keyword:
      • Only binding in insert mode - insert is also a function, and general’s heuristics incorrectly assume it to be a definer (#491)
        • TODO: this is currently handled with a fork of general.el, so insert can be used just like other modes,
      • Only binding commands where the keys are generated by a macro call (for example igloo-lcag) - general cannot differentiate between a macro call and a list, tripping up general-def with number of positional arguments
    • Always be explicit about the keymap - the default is global
  • Align s-exps in :general blocks as if they were function calls, e.g.
    (mode map
      "binding" command
      "binding" command)
        

    Applies regardless of how many symbols are on the first line

    • Exception being when all the bindings are remap commands, see Helpful setup
  • Put (declare-function)’s and helper function declarations in the :preface
  • Use :custom over (setq ..)
  • Put arguments to :hooks on a new line and always wrap the triggering hooks in a list
  • When adding an igloo--... function to a hook, use-package complains about multiple definitions in the same file. In this case, don’t use the :hook keyword, but instead use add-hook directly in :init
  • When a more thorough documentation of package configuration is necessary, split the section into two headings - Configuration and Installation. Then, set the =noweb-ref= header argument in the Configuration section, and put the igloo-use-package form in the Installation section, with the proper << ... >> reference.
  • Unless a different default is required, set language-specific -offset variables (the ones that handle tab width) to tab-width (using the igloo-link-offset macro in the :init block)

I was torn between specifying the keybindings in the same place as the package or in a separate Keybindings section, which is what I was using in my previous config.

The pros of separating the keybindings is that you can see them all in one place, so finding potential conflicts is easier. However, the cons is that since the keybindings were applied before loading of the specific packages, sometimes the package would override your changes, and there was no easy way of deferring the binding.

In the end, I decided to bundle place the bindings with the relevant package. You can get an overview of the bound keys via general-describe-keybindings, and conflict checking can probably be implemented in the linter.

Aliasing the -offset variables usually produces a warning, because their original value is different from tab-width. For this reason, we introduce a macro to suppress this warning.

(eval-when-compile
  (defmacro igloo-link-offset (offset)
    `(progn
       (require 'warnings)
       (let ((warning-suppress-log-types (list (list 'defvaralias 'losing-value))))
         (defvaralias ,offset 'tab-width)))))

Linter

(eval-when-compile
  (require 'cl-lib)
  (require 'subr-x))

(defconst lint-keyword-order
  <<igloo-keyword-order>>)

(define-error 'unknown-keyword "Unknown keyword encountered")
(define-error 'wrong-order "Keyword in wrong position")
(define-error 'simple-hook "Hook form is too simple")

(defun check-kw-order (form)
  (let ((remaining-kws lint-keyword-order)
        (package-name (cadr form)))
    (cl-loop for kw in form
             when (symbolp kw)
             when (eq (string-to-char (symbol-name kw)) ?:)
             do
             (while (not (eq kw (car remaining-kws)))
               (cond
                ((cdr remaining-kws)
                 (setq remaining-kws (cdr remaining-kws)))
                ((memq kw lint-keyword-order)
                 (signal 'wrong-order
                         (list kw package-name)))
                (t
                 (signal 'unknown-keyword
                         (list kw package-name))))))))

(defun check-hooks (form)
  "Checks conventional :hook usage in FORM.

TODO: Not all checks are covered.

Acceptable:
:hook
((prog-mode) . ace-jump-mode)

:hook
((prog-mode) . (lambda () (ace-jump-mode)))

:hook
((prog-mode text-mode) . ace-jump-mode)

:hook
((prog-mode text-mode) . (lambda () (ace-jump-mode)))

:hook
(((prog-mode) . ace-jump-mode)
 ((text-mode) . ace-jump-mode))

:hook
(((prog-mode) . (lambda () (ace-jump-mode)))
 ((text-mode) . (lambda () (ace-jump-mode))))

Unacceptable:
:hook prog-mode
:hook (prog-mode . ace-jump-mode)
:hook (prog-mode text-mode)
:hook ((prog-mode . ace-jump-mode)
       (text-mode . ace-jump-mode))"
  (let ((package-name (cadr form)))
    (cl-loop for rest on form
             when (eq (car rest) :hook)
             do
             (let ((hooks (cadr rest)))
               (cond
                ((not (and (consp hooks)
                           (consp (car hooks))))
                 (signal 'simple-hook (list package-name)))
                (t))))))

(defun lint-files (files)
  (with-temp-buffer
    (cl-loop for f in files
             when (file-readable-p f)
             do
             (insert-file-contents-literally f nil nil nil 'replace)
             (goto-char (point-min))
             (condition-case err
                 (while t
                   (let ((form (read (current-buffer))))
                     (when (listp form)
                       (cond
                        ((eq (car form) 'use-package)
                         (error "Don't use regular `use-package' for %s" (cadr form)))
                        ((eq (car form) 'igloo-use-package)
                         (check-kw-order form)
                         (check-hooks form))))))
               (end-of-file)
               (unknown-keyword
                (error "Unknown keyword: %s in package %s" (cadr err) (caddr err)))
               (wrong-order
                (error "Keyword in an unexpected position: %s in package %s" (cadr err) (caddr err)))
               (simple-hook
                (error "Hook form is not of expected conventional format in package %s" (cadr err)))))))

(defun lint-from-args ()
  (condition-case err
      (lint-files command-line-args-left)
    (t (message "%s" err)
       (kill-emacs 1))))

Display

Emacs is generally weird with its rendering pipeline, and that’s a can of worms I don’t want to get into here. One big observable quirk it has is that when running in server mode, it does not initialize some variables relating to faces and such.

To interact with faces and fonts, one has to wait until at least the first frame is made, which is what this macro is for. It is also intelligent enough to recognize when no server is running, and in that case it executes the body immediately.

The macro is only available during compilation.

(eval-when-compile
  (defmacro igloo-run-with-frontend (&rest body)
    "Run BODY when there is a frontend.

Emacs is weird when starting as a server, ok?

Some things aren't available (like face definitions),
so you want to hook into `server-after-make-frame-hook'
\(which confusingly refers to terminal clients too),
but that doesn't fire when opening Emacs without a server...

This macro checks if a server is running, and if it is,
it adds BODY to the hook,
and removes it after the first client is created.

If a server is not running, which means that the current instance was launched
as a normal Emacs process, run BODY straight away."
    (let ((funcname (cl-gentemp "igloo--run-with-frontend-")))
      (macroexp-progn
       `((defun ,funcname ()
           ,@body
           (remove-hook 'server-after-make-frame-hook #',funcname))
         (if (daemonp)
             (add-hook 'server-after-make-frame-hook #',funcname)
           (,funcname)))))))

Initialization

Early init

Emacs 27 added early-init.el, which is a file that gets loaded very early in the process (hence the name). All the code blocks in this section are tangled into the early-init.el file, not init.el.

A word of warning: the documentation states that early-init.el should be used for “customizing how the package system is initialized” and “customizations […] that need to be set up before initializing”, because “the early init file is read too early into the startup process”.

From etc/NEWS.27:

** Emacs can now be configured using an early init file.
The file is called "early-init.el", in 'user-emacs-directory'.  It is
loaded very early in the startup process: before graphical elements
such as the tool bar are initialized, and before the package manager
is initialized.  The primary purpose is to allow customizing how the
package system is initialized given that initialization now happens
before loading the regular init file (see below).

We recommend against putting any customizations in this file that
don't need to be set up before initializing installed add-on packages,
because the early init file is read too early into the startup
process, and some important parts of the Emacs session, such as
'window-system' and other GUI features, are not yet set up, which could
make some customization fail to work.

I try not to abuse the early init system, but I do some UI stuff that makes sense to me.

As a good citizen, we start this file with a header.

;;; Igloo --- Early init file -*- lexical-binding: t; -*-

;;; Commentary:
;; Read https://github.com/VojtechStep/igloo.el/blob/master/igloo.org
;;
;; DO NOT CHANGE THIS FILE
;; This file is generated from `igloo.org' and will be overwritten on
;; every invocation of `make'

;;; Code:
(eval-and-compile (setq load-prefer-newer t))
(eval-when-compile (require 'cl-lib))

Startup Optimizations

Many of the startup optimizations were inspired by how Doom does it. I recommend reading it, since not all the tricks are used here, only the ones that noticeably improved the loading times for me.

In order to reduce the startup time of Emacs, we can employ several techniques. The most important part is deferred package loading, to which we will get in Package management.

GC Optimization

Other than that, we can start by looking into the garbage collector. The garbage collector runs when there is garbage to be picked up, that is when objects on the heap are being abandoned. We can influence when the garbage collection runs. If we wanted to have a lower memory footprint, we would want GC to run more frequently. However, in this case, we can live with a little memory spike if it provides us with a snappier experience.

Emacs garbage collector can run once the amount of allocated memory since the last GC run reaches a certain threshold, which is 8MB by default. This number is ridiculously small for most, so we increase it to about 100 megs.

Starting up is where a lot of garbage can be created, so it’s easiest to pretty much disable garbage collection as soon as possible in the initialization, and re-enable it after Emacs starts. Another small-object sensitive workflows are minibuffer operations and company completion, so disable GC during those two too.

Furthermore, when enabling garbage collection, do so in a deferred manner, that is run it only after a second passes since the task (minibuffer action, completion) ends. This way, the thing running just after will still be free of garbage collection.

(defconst igloo--gc-threshold (* 100 1024 1024))
(setq gc-cons-threshold most-positive-fixnum
      gc-cons-percentage 0.6
      read-process-output-max (* 1024 1024))

(defun igloo--startup-cleanup ()
  "Reset settings disabled for faster startup."
  (setq gc-cons-threshold igloo--gc-threshold
        gc-cons-percentage 0.1))
(add-hook 'emacs-startup-hook #'igloo--startup-cleanup)


(defun igloo--gc-disable (&rest _)
  "Disable garbage collection."
  (setq gc-cons-threshold most-positive-fixnum))
(add-hook 'minibuffer-setup-hook #'igloo--gc-disable)
(add-hook 'company-completion-started-hook #'igloo--gc-disable)

(defun igloo--gc-enable ()
  "Enable garbage collection."
  (setq gc-cons-threshold igloo--gc-threshold))
(defun igloo--defer-gc-enable (&rest _)
  "Enable garbage collection, defered."
  (run-at-time 1 nil #'igloo--gc-enable))
(add-hook 'minibuffer-exit-hook #'igloo--defer-gc-enable)
(add-hook 'company-completion-finished-hook #'igloo--defer-gc-enable)

Other optimizations

We can make more optimizations by telling Emacs not to do stuff we manage ourselves.

For starters, the look and feel (things like non-blinking cursor, menu bar, font, …) are configured in this file, not via X resources, so throw the loading of those resources out of the equation.

We also don’t use the vc framework for controlling various version control systems, so pretend it doesn’t exist by never enabling it, ever (from docs: “An empty list disables VC altogether”).

Another important feature we throw out the window is Emacs builtin package management. It would otherwise initialize itself during startup, and that’s just not groovy.

(advice-add #'x-apply-session-resources :override #'ignore)

(setq vc-handled-backends nil)

(setq package-enable-at-startup nil)

Emacs has support for bidirectional text, which I don’t have a use case for, and disabling it can improve redisplay performance.

(setq bidi-inhibit-bpa t)
(setq-default bidi-paragraph-direction 'left-to-right)

Stuff from my previous early-init I either didn’t understand or don’t think I need any more, keeping it here for reference (not tangled):

;; This was in early-init, supposed to help with startup, maybe try and bench it
;; Reference: https://github.com/hlissner/doom-emacs/blob/develop/docs/faq.org#unset-file-name-handler-alist-temporarily
(defvar igloo--file-name-handler-alist file-name-handler-alist)
(setq file-name-handler-alist nil)

;; I keep going back and forth on this
(setq suggest-key-bindings nil)

;; Font rendering performance tips
(setq-default font-lock-support-mode 'jit-lock-mode)
(setq-default font-lock-multiline t)

Early visual stuff

As mentioned, the GNU developers warn against using early init for graphical stuff. In this section, we are using early init for graphical stuff. Most of the settings are here to prevent their initialization, because it would just be wasteful to initialize them and then disable them later.

First of all, we set the default parameters of new frames. Since early init is done so early, they also apply to the first created frame. We hide all the scrollbars, menu bars and tool bars and set the default font and background color.

We also disable the blinking cursor, because who came up with that?

(setq default-frame-alist
      (append
       `((vertical-scroll-bars . nil)
         (horizontal-scroll-bars . nil)
         (font . <<default-font-name>>) ;(ref:default-font)
         (background-color . "#1d1f21") ;(ref:frame-bg)
         (background-mode . dark)
         (mouse-color . "#cc6666"))
       default-frame-alist))

(setq menu-bar-mode nil
      tab-bar-mode nil
      tool-bar-mode nil
      blink-cursor-mode nil
      ring-bell-function #'ignore)

We set the font because it doesn’t make sense to let Emacs load a font that’s going to get replaced later. Depending on the font you choose, it might be necessary to change the default height, which is counted in 0.1pt.

The background color is hardcoded and you should set it to the same background color as your main theme. It’s set here because we want to avoid another color flashing the frame before the theme is loaded[fn:1].

We could also set the menu-bar-lines and tool-bar-lines frame parameters to 0 to disable the menu bar and tool bar. Instead, we disable them by settings the variables menu-bar-mode and tool-bar-mode. The effect is the same, because the modes work by setting the frame parameters, but also Emacs doesn’t think those mode are enabled when they aren’t.

Furthermore, frame-resize-pixelwise makes Emacs not align the window size to character size. This is useful, because otherwise Emacs might not play along with tiling window managers. frame-inhibit-implied-resize set to t disables implicit resizing of the frame by for example enabling the toolbar, the scrollbar or changing fonts. The default setting is to preserve the number of characters shown on screen, not the window size.

We also tell Emacs that we don’t want to compact font caches. This might cause the memory usage to grow, especially if we were using a lot of fonts (which we don’t), but the GC and redisplay can be a little faster (since GC doesn’t compact font caches and redisplay doesn’t have to reopen them again).

(setq-default frame-resize-pixelwise t
              frame-inhibit-implied-resize t
              inhibit-compacting-font-caches t)
(provide 'early-init)
;;; early-init.el ends here

Package management

First, to be able to install packages, bootstrap straight.el and use-package.

Configure straight to run on the bleeding edge and use ssh by default to clone repositories. Those need to be defvar’s, because neither straight or use-package have been loaded at this point.

(eval-and-compile
  (defvar straight-repository-branch "develop")
  (defvar straight-vc-git-default-protocol 'ssh)
  (defvar straight-vc-git-default-clone-depth 1))

Straight can also automatically recompile packages if one chooses to edit the locally checked-out sources. By default, it checks at startup if the source files were changed, which can hurt startup performance. Change it to only check for edits when a file is saved, and when explicitly checking with straight-check-package or straight-check-all.

(defvar straight-check-for-modifications '(find-when-checking check-on-save))

Also, configure use-package to defer by default (without having to specify :defer t) and capture statistics (which can be shown by calling use-package-report). Verbosity is useful for debugging, but generally not necessary.

Deferring package loading is useful to improve startup time, because it means that the packages themselves aren’t loaded and initialized on startup, only their autoloads are (autoloads are a mechanism for a file to export functions that trigger a loading of the file they are defined in when called).

The use-package-verbose is useful when you want to diagnose a problem with package loading - for example to see which packages take a long time to load, or when are packages loaded and configured.

We instruct use-package to not emit error handling code when expanding macros. They are unnecessary when one knows their config works, which is the case here, and it helps with debugging macro-expanded code. It also allows the byte-compiler to properly process the =:functions= block of use-package Apparently, when byte-compiling, use-package still emits some comptime-only error checking code that trips up the byte compiler, so the :functions clause doesn’t really work as intended, and it is still necessary to add declare-function’s to the :preface.

(eval-and-compile
  (defvar use-package-compute-statistics t)
  (defvar use-package-always-defer t)
  (defvar use-package-verbose nil)
  (defvar use-package-expand-minimally t)
  (defvar use-package-use-theme nil))

The bootstrap file location has to be accessible at both comptime and runtime.

(eval-when-compile
  (defconst igloo-straight-bootstrap
    (eval-when-compile
      (expand-file-name "straight/repos/straight.el/bootstrap.el" user-emacs-directory))))

Install the package manager if it can’t be found. This only happens at compile time. At runtime, it is assumed that the package manager, and all other packages for that matter, are installed.

(eval-when-compile
  (unless (file-exists-p igloo-straight-bootstrap)
    (with-current-buffer
        (url-retrieve-synchronously
         "https://raw.githubusercontent.com/raxod502/straight.el/develop/install.el"
         'silent 'inhibit-cookies)
      (goto-char (point-max))
      (eval-print-last-sexp))))

Once the package manager is guaranteed to be present at comptime and runtime, load it and setup use-package.

First, download and compile it. Once it’s present on the system, require it. The call to straight-use-package-mode is necessary, because it adds keywords to use-package-keyword, but the variable hasn’t been defined when loading use-package.

(eval-and-compile
  (load (eval-when-compile igloo-straight-bootstrap))
  (require 'straight)
  (straight-use-package 'use-package)

  (require 'use-package)
  (straight-use-package-mode t))

Silencing the byte compiler

Magit-todos requires pcre2el, which lazily defines an advice on find-tag-regexp from etags, but etags is only loaded when requiring haskell-mode, which happens after setting up magit, so the definition of find-tag-regexp changes after advising it, resulting in the output ad-handle-definition: ‘find-tag-regexp’ got redefined. I would prefer a workaround disabling it only for the particular instances, with something like (let ((ad-redefinition-action 'accept)) (require 'etags)), but that’s not good responsibility assignment practice (some kind of action should be taken by pcre2el, not etags). The default behavior is to accept the redefinition and emit a message, so the following is not breaking anything, only silencing the output.

(eval-and-compile
  (setq ad-redefinition-action 'accept))

Managing globally installed packages

I still haven’t figured out how to make straight cooperate properly with local non-vcs directories. For example, mu4e and mozc require other system components, like native binaries, to be built, so they are often included in the distro’s package manager. It would be nice if one didn’t have to have two copies of the repo on the disk - one from the system package manager and one from straight. I use :load-path for those packages for now, but I loose on the nice automatic builds and such.

TODO: don’t assume FHS everywhere

Execution environment

I struggled a little with figuring out how to keep the environment variables used in Emacs the same as in the rest of the system. I even wrote a script that was valid fish and elisp at the same time! Check it out!

In the end I resigned, and now I spawn a shell process to extract the environment from. Turns out it’s not that big of a deal, since my fish shell starts in about 8ms.

We already changed the default shell here, so we need to tell the package that we want the environment extracted from the user’s default shell ($SHELL).

The list of environment variables is something you probably want to modify for yourself.

(igloo-use-package exec-path-from-shell
  :straight t
  :demand t
  :custom
  (exec-path-from-shell-shell-name (getenv "SHELL"))
  (exec-path-from-shell-arguments nil)
  (exec-path-from-shell-variables
   '("PATH"
     "MANPATH"
     "DISPLAY"
     "CXX"
     "CC"
     "XDG_CONFIG_HOME"
     "XDG_CACHE_HOME"
     "XDG_DATA_HOME"
     "XAUTHORITY"
     "GNUPGHOME"
     "DOTFILES_HOME"
     "CARGO_HOME"
     "RUSTUP_HOME"
     "STACK_ROOT"
     "DOCKER_CONFIG"
     "CABAL_CONFIG"
     "CABAL_DIR"
     "ASPELL_CONF"
     "TERMINFO"
     "SCREENSHOT_DIR"
     "PYTHONSTARTUP"
     "NPM_CONFIG_USERCONFIG"
     "BROWSER"
     "FZF_DEFAULT_COMMAND"
     "FZF_DEFAULT_OPTS"
     "LESSHISTFILE"
     "LESS"
     "DOTNET_CLI_TELEMETRY_OPTOUT"
     "EMAIL"
     "NIX_PATH"))
  :config
  (exec-path-from-shell-initialize)
  (unless (getenv "MANPATH")
    (setenv "MANPATH" (string-trim (shell-command-to-string "manpath")))))

Secret management

TODO: docs, at least a little

(igloo-use-package auth-source-pass
  :after auth-source
  :demand t
  :custom
  (auth-sources '(password-store)))
(igloo-use-package epg-config
  :custom
  (epg-pinentry-mode 'loopback))

Keybinding groundwork

Use which-key for showing available keybindings when a chord is not complete.

(igloo-use-package which-key
  :straight t
  :demand t
  :custom
  (which-key-idle-delay 0.6)
  :config
  (which-key-mode))

The keybindings in Igloo are managed by general.el. It allows one to create a definer to be used in subsequent bindings - in this case the igloo-leader, bound to SPC.

I don’t use it here, but one can specify not only :prefix, but also a :global-prefix keyword for a definition. The difference is that the :global-prefix chord is used for “special states”, like insert or emacs states, where the set of unbound keys might be drastically different (for example you wouldn’t want to bind SPC in insert mode).

(eval-when-compile
  (defmacro igloo-lcag (key)
    "Append LCtrl, Alt and GUI before KEY."
    `(concat "C-M-s-" ,key)))

(igloo-use-package general
  :straight (:fork (:repo "VojtechStep/general.el"))
  :demand t
  :config
  (eval-when-compile
    (general-create-definer igloo-leader
      :states '(motion normal)
      :keymaps 'override
      :prefix "SPC")
    (general-create-definer igloo-local-leader
      :states 'normal
      :keymaps 'override
      :prefix "\\")
    (defmacro igloo-local-leader-with-minor (modes &rest bindings)
      "Defines keybindings for a major/minor mode pair.

MODES specifies a list of modes that have a major and minor version.
Inserts the given keybindings into both `<mode>-mode-map' keymap
and the global keymap for the mode `<mode>-minor-mode'.

An example where this is useful are the `outline' minor/major modes."
      (declare (indent defun))
      (unless (listp modes)
        (setq modes (list modes)))
      (let ((split-modes
             (cl-loop for mode in modes
                      for mode-name = (symbol-name mode)
                      collect
                      (intern (concat mode-name "-mode-map")) into major-modes
                      collect
                      (intern (concat mode-name "-minor-mode")) into minor-modes
                      finally return (cons major-modes minor-modes))))
        `(progn
           (igloo-local-leader ,(car split-modes)
             ,@bindings)
           (igloo-local-leader ,(cdr split-modes)
             :definer 'minor-mode
             ,@bindings)))))
  (igloo-leader
    "" '(nil :wk "major-mode agnostic leader")
    "C-g" '(keyboard-quit :wk "abort")
    "a" '(nil :wk "apps")
    "c" '(nil :wk "close")
    "g" '(nil :wk "buffer control")
    "o" '(nil :wk "org"))
  (igloo-local-leader
    "" '(nil :wk "major/minor-mode specific leader")
    "C-g" '(keyboard-quit :wk "abort")))

Transient seems like a really nice interface, and I want to investigate its usage in the future. For now, just enable the quit-on-q feature.

(igloo-use-package transient
  :straight t
  :preface
  (declare-function transient-bind-q-to-quit "transient")
  :config
  (transient-bind-q-to-quit))

Basic keybindings

This section contains package declarations for built-in functionality that only have associated keybindings.

(igloo-use-package newcomment
  :general
  ((motion insert) global
    "C-;" #'comment-dwim))
(igloo-use-package simple
  :custom
  (read-extended-command-predicate #'command-completion-default-include-p)
  :general
  (insert global
    "C-v" #'yank
    "C-S-v" #'quoted-insert)
  (visual global
    "gl" #'count-words-region))
(igloo-use-package align
  :general
  (visual global
    "<tab>" #'align))
(igloo-use-package files
  :general
  (igloo-leader
    "cc" '(bury-buffer :wk "close buffer")
    "ck" '(kill-current-buffer :wk "kill buffer")
    "cq" '(save-buffers-kill-emacs :wk "kill emacs server")
    "gr" '(revert-buffer :wk "revert buffer")
    "gR" '(rename-buffer :wk "rename buffer")))

UI/UX

Behaviour

As for the appearance, I’m not a huge fan of the startup screen, so disable it.

I also consider myself a 1337 h4x0r, so don’t nag me about advanced-level commands.

I use vertico.el as a completion framework (more on that later), so I don’t care much about shorter ways I can type in commands.

(setq inhibit-startup-screen t
      disabled-command-function nil
      extended-command-suggest-shorter nil)

This adds VIM-like (read: correct) scrolling behavior: only scroll one line at a time, keep a fixed number of visible lines around the cursor and never recenter when the cursor goes off screen (honestly, why is that even a thing).

(setq-default scroll-step 1
              scroll-margin 3
              scroll-conservatively 101
              hscroll-step 1
              hscroll-margin 3)

There is no yes/no question important enough to require up to three key presses more then necessary, so make all yes/no prompts into y/n prompts.

(defalias 'yes-or-no-p 'y-or-n-p)

When an action grows the minibuffer (for example org-drill), I don’t want it to stay enlarged after the action finishes, which is the default behaviour.

(setq-default resize-mini-windows t)

I mostly use the keyboard to navigate my system, and it’s a little frustrating when I have a hidden mouse pointer somewhere on the screen and it causes a part of a buffer to be highlighted. Ergo, only apply the hover overlay when the mouse is moved.

(setq-default mouse-highlight 1)

Do you like polluting your working directory with files like .#totaly-a-file.rs and existing-file.zig~? Yeah, me neither, so disable them pesky lockfiles and backup files.

(setq create-lockfiles nil)
(setq auto-save-default nil)
(setq backup-directory-alist `(("." . ,(locate-user-emacs-file "saves/"))))

The fish shell, which I use, is not POSIX compliant, and has a bunch of bells and whistles that I don’t need when spawning inferior shell processes. Therefore, I prefer to use the standard /bin/sh as the default (which should be symlinked to dash).

(setq shell-file-name "/bin/sh") ;(ref:default-shell)

I prefer seeing code on my screen, not empty space - therefore, reduce the default tab width by a factor of 4.

The reason I prefer spaces over tabs is because when working with LISP-y languages, which look good when properly aligned, you could get a mix of tabs and spaces at the beginning of lines. Enabling truncated lines makes it so that by default, the text overflows off the screen, instead of breaking at the edge of the window.

(setq-default tab-width 2)
(setq-default indent-tabs-mode nil)
(setq-default sentence-end-double-space nil)
(setq-default truncate-lines t)
(igloo-use-package indent
  :init
  (igloo-link-offset 'standard-indent))

Emacs has built-in functionality for treating CamelCase and snake_case labels as consisting of separate “words”, making it more natural to move around. Turn it on.

(igloo-use-package subword
  :demand t
  :config
  (global-subword-mode))

Insert bracket pairs automatically.

(igloo-use-package elec-pair
  :demand t
  :preface
  (defun igloo--enable-standard-pairs ()
    (make-local-variable 'electric-pair-pairs)
    (push '(?\' . ?\') electric-pair-pairs)
    (push '(?\` . ?\`) electric-pair-pairs))
  :config
  (electric-pair-mode))

Window layout

At the moment, I am using bspwm as my window manager, because it does its job of laying out windows well. I find it more intuitive to delegate the feat of managing windows to the window manager, instead of having another set of keybindings and behaviour for just Emacs.

The basic setting that tells Emacs to use multiple X windows (or frames in Emacs lingo) is pop-up-frames. When set to non-nil, it tells Emacs that it’s OK to use multiple frames.

The second setting is frame-auto-hide-function. This is the function called when closing the last window of a frame. The default value of iconify-frame minimizes the frame, but on window managers that don’t support window minimization, it just makes the frame unresponsive and it’s confusing, and the correct behaviour is to get rid of the frame altogether.

(setq pop-up-frames 'graphic-only)
(setq frame-auto-hide-function #'delete-frame)

There is also the variable display-buffer-alist, which I don’t think I fully understand to this day. I did use it in my previous config though, so once I start hitting some window layout issues, I will try to fix them and document them properly.

Now, some packages crap their pants don’t handle frames gracefully, so additional configuration is required.

Helpful

When Emacs is configured to pop up windows, then upon invocation, helpful splits the window, moves focus to the new window, and shows the help there. When looking up symbols from the helpful buffer, it reuses the previously focused window, and further jumps back and forth, closing the piled-on layers of helpful buffers when hitting q.

When Emacs is configured to pop up frames, then upon invocation, helpful correctly pops out a new frame, but then spirals away into spawning many windows, sometimes quitting correctly, sometimes displaying non-help buffers, and it’s very unpredictable.

To fix these issues, I decided on the following behaviour I want from the helpful windows, and implemented the showing and closing functions accordingly:

When showing help, first try to reuse a buffer that already shows a help buffer, otherwise create a new frame. When displaying in an existing frame, push itself on a “help stack”. When closing help, remove the current buffer from the stack. If there are no other buffers in the “help stack”, close the frame. If the stack is not empty, show its head.

A potential improvement could be to only reuse the helpful frame if jumping from a helpful buffer - that is, always create a “helpful frame” when looking up something from code, but reuse the same frame when jumping through the documentation.

One thing to note about the implementation is that helpful needs the new window to be selected when igloo--helpful-show-buffer returns, otherwise you hit some failed assertions.

(defun igloo--helpful-show-buffer (buffer &optional _) ;(ref:helpful-show-buffer)
  (let ((window (display-buffer
                 buffer
                 '(display-buffer-reuse-mode-window
                   . ((mode . helpful-mode)))))
        (old-frame (selected-frame)))
    (if window
        (let ((frame (window-frame window)))
          (unless (eq frame old-frame)
            (select-frame-set-input-focus frame))
          (select-window window))
      (set-buffer buffer))
    buffer))

The quitting of the buffer needs to be reimplemented too, because the default quit-window only closes the frame if it only ever showed one buffer - if I were to navigate to a different helpful buffer (for example by calling helpful-at-point), it breaks the whole thing, and once all the helpful buffers are hidden with pressing q, the frame doesn’t close, but instead shows the buffer from where the original helpful buffer was created.

(defun igloo--helpful-quit-buffer ()
  (interactive)
  (let* ((window (selected-window))
         (buffer (window-buffer window))
         (prev-buffers (window-prev-buffers window)))
    (if (or (not prev-buffers)
            (eq (caar prev-buffers) (current-buffer)))
        (window--delete window)
      (switch-to-prev-buffer window 'bury)
      (bury-buffer-internal buffer))))

Appearance

I do love myself a dark theme. I go with Tomorrow night. Reminder: when changing a theme, we should change the initial background color of frames in frame parameters.

(igloo-use-package color-theme-sanityinc-tomorrow
  :straight t
  :demand t
  :config
  (load-theme 'sanityinc-tomorrow-night t)
  (custom-theme-set-faces
   'sanityinc-tomorrow-night
   '(diff-removed ((t (:background "#3F0001"))))
   '(diff-refine-removed ((t (:background "#9b1011"))))
   '(diff-added ((t (:background "#002800"))))
   '(diff-refine-added ((t (:background "#006000"))))))

Don’t show a help message in every new frame.

(igloo-use-package server
  :custom
  (server-client-instructions nil))

Render a chunky boi when the cursor is over a tab.

(setq x-stretch-cursor t)

Show aggressive trailing whitespace in source files.

(require 'mode-local)
(setq-mode-local prog-mode
                 show-trailing-whitespace t)
(face-spec-set 'trailing-whitespace '((t . (:background "red1"))))

As mentioned previously, I rarely touch the mouse when operating Emacs. Therefore, graphical hover tooltips are useless, not to mention ugly. When tooltip mode is off, the information is printed in the echo-area, which I find preferable.

(igloo-use-package tooltip
  :config
  (tooltip-mode -1))

I try to use relative numbers for moving faster in the file - if you want to move to a line above your cursor, its easy to look at the number next to the line, and the hit <n>k for example.

You will sometimes see the form (setq-default display-line-numbers 'visual) instead. The difference is that when the mode is turned on, it performs some additional actions, such as looking at the number of lines in the file, and then setting the gutter width accordingly (that’s what -width-start is for). Otherwise the text will jump around as you scroll.

(igloo-use-package display-line-numbers
  :demand t
  :hook
  ((prog-mode text-mode) . display-line-numbers-mode)
  :custom
  (display-line-numbers-width-start t)
  (display-line-numbers-type 'visual))

I like seeing TODO and related keywords highlighted in all buffers. hl-todo to the rescue!

(igloo-use-package hl-todo
  :straight t
  :hook
  ((prog-mode text-mode) . hl-todo-mode)
  :general
  (igloo-local-leader hl-todo-mode-map
    "tj" '(hl-todo-next :wk "next TODO" :jump t)
    "tk" '(hl-todo-previous :wk "previous TODO" :jump t)))

When writing a lot of lisp, it makes sense to highlight parentheses. I spent quite some time figuring out how I want the matching parenthesis to look, and I prefer having a box around it.

(igloo-use-package paren
  :demand t
  :custom
  (show-paren-delay 0)
  :preface
  (declare-function color-lighten-name "color")
  (declare-function color-darken-name "color")
  :config
  (igloo-run-with-frontend
   (require 'color)
   (dolist (desc `((show-paren-match
                    :box
                    (:line-width (-1 . -1)
                     :color ,(face-attribute 'cursor :background))
                    :foreground ,(face-attribute 'cursor :background)
                    :background unspecified)
                   (show-paren-mismatch
                    :foreground unspecified)))
     (let ((face (car desc))
           (spec `((t . ,(cdr desc)))))
       (face-spec-set face spec))))
  (show-paren-mode))

Startup

I like seeing a startup message that tells me how long Emacs took to start up (lo and behold the sub-0.5s startup times).

(add-hook 'emacs-startup-hook
  (lambda ()
    (message "Emacs ready in %ss with %d garbage collections taking up %ss"
              (float-time (time-subtract after-init-time before-init-time))
              gcs-done gc-elapsed)))

Font

Note that not all the source blocks are exported as-is. The first three blocks are tangled into the =igloo-run-with-frontend= invocation at the bottom, using noweb syntax.

The font itself is already set during early init.

"Iosevka Term SS14 Extended"

Some functionality relies on ImageMagick to produce images with text (notably PDF overlays). ImageMagick’s font specification seems to be different from fontconfig patterns, so it’s necessary to write the name differently.

"Iosevka Term SS14"

To tell Emacs to use different fonts, one needs to specify on which characters those fonts should be used.

For emojis, I use the Noto Color Emoji font. I’m not very sure about the codepoint ranges, if anyone wants to check then feel free, but this seems to work.

(let ((ranges '((#x1f000 . #x1f64f)
                (#x1f900 . #x1f9ff))))
  (dolist (emojis ranges)
    (set-fontset-font t emojis (font-spec :family "Noto Color Emoji" :size 14))))
(set-fontset-font t '(#x24b6 . #x24cf) (font-spec :family "Noto Sans Symbols" :size 10))
(set-fontset-font t #x26ac (font-spec :family "Noto Sans Symbols2" :size 10))
(set-fontset-font t #x2693 (font-spec :family "Noto Sans Symbols" :size 10))

For symbols, I use Symbols Nerd Font. The codepoint ranges are taken from the project wiki.

(let ((ranges '(;; Seti-UI + Custom
                (#xe5fa . #xe62b)
                ;; Devicons
                (#xe700 . #xe7c5)
                ;; Font Awesome
                (#xf000 . #xf2e0)
                ;; Font Awesome Extension
                (#xe200 . #xe2a9)
                ;; Material Design Icons
                (#xf500 . #xfd46)
                ;; Weather
                (#xe300 . #xe3eb)
                ;; Octicons
                (#xf400 . #xf4a8)
                #x2665 #x26a1 #xf27c
                ;; Powerline Extra Symbols
                (#xe0b4 . #xe0c8)
                (#xe0cc . #xe0d2)
                #xe0a3 #xe0ca #xe0d4
                ;; IEC Power Sybols
                (#x23fb . #x23fe) #x2b58
                ;; Font Logos
                (#xf300 . #xf313)
                ;; Pomicons
                (#xe000 . #xe00d))))
  (dolist (syms ranges)
    (set-fontset-font t syms "Symbols Nerd Font")))
(let ((ranges '(;; Heavy asterisk
                #x2731
                ;; Wide equals sign
                #xff1d)))
  (dolist (syms ranges)
    (set-fontset-font t syms <<default-font-name>>)))

For math letters, I use DejaVu Math TeX Gyre. Some manual fiddling needed, c.f. https://mathstodon.xyz/@VojtechStep/112241396450544301.

(let ((ranges '(mathematical
                #x210a
                #x210b
                #x2110
                #x2112
                #x2113
                #x2118
                #x211b
                #x212c
                #x212f
                #x2130
                #x2131
                #x2133
                #x2134)))
  (dolist (range ranges)
    (set-fontset-font t range "DejaVu Math TeX Gyre")))

Ligatures

Emacs does not do ligatures automatically, so one has to define a bunch of regexes (regexi?) to specify which character sequences have a change of being merged together. The list of ligatures was initially taken from the JetBrains website, then a wiki page was created, but it doesn’t seem up to date, so this is updated on a best-effort basis.

(let ((alist '(;;  -> -- --> ->> -< -<< --- -~ -|
               (?- . ".\\(?:--\\|[->]>?\\|<<?\\|[~|]\\)")

               ;; // /* /** /// //= /= /== />
               ;; /* cannot be conditioned on patterns followed by a whitespace,
               ;; because that would require support for lookaheads in regex.
               ;; We cannot just match on /*\s, because the whitespace would be considered
               ;; as part of the match, but the font only specifies the ligature for /* with
               ;; no trailing characters
               ;;
               (?/ . ".\\(?:/[=/]?\\|==?\\|\\*\\*?\\|[>]\\)")

               ;; */ *>
               ;; Prevent grouping of **/ as *(*/) by actively looking for **/
               ;; which consumes the triple but the font does not define a substitution,
               ;; so it's rendered normally
               (?* . ".\\(?:\\*/\\|[>/]\\)")

               ;; <!-- <<- <- <-- <=> <= <| <|| <||| <|> <: <:< <> <-< <<< <=< <<= <== <==>
               ;; <~> << <-| <=| <~~ <~ <$> <$ <+> <+ <*> <* </ </> <->
               (?< . ".\\(?:==>\\|!--\\|~~\\|-[|<-]\\||>\\||\\{1,3\\}\\|<[=<-]?\\|=[><|=]?\\|[*+$~/-]>?\\|:<?\\|>\\)")

               ;; := ::= :?> :? :: ::: :< :>
               (?: . ".\\(?:\\?>\\|:?=\\|::?\\|[>?<]\\)")

               ;; == =:= === => =!= =/= ==> =>> =:
               (?= . ".\\(?:[=>]?>\\|[:=!/]?=\\|:\\)")

               ;; != !== !!
               (?! . ".\\(?:==?\\|!\\)")

               ;; >= >> >] >: >- >-> >>> >>= >>- >=>
               ;; >=< should not have a ligature
               (?> . ".\\(?:[=-]>\\|>[=>-]\\|[]:>-]\\|=<?\\)")

               ;; && &&&
               (?& . ".&&?")

               ;; || ||| |> ||> |||> |] |} |-> |=> |- ||- |= ||=
               (?| . ".\\(?:||>\\|[-=|]>\\||[-=|]?\\|[]>}=-]\\)")

               ;; ... .. .? .= ..<
               (?. . ".\\(?:\\.[.<]?\\|[.?=]\\)")

               ;; ++ +++ +>
               (?+ . ".\\(?:\\+\\+?\\|>\\)")

               ;; [| [<
               (?\[ . ".[|<]")

               ;; {|
               (?{ . ".|")

               ;; ?: ?. ?? ??? ?=
               (?? . ".\\(?:[:.=]\\|\\?\\??\\)")

               ;; ## ### #### #{ #[ #( #? #_ #_( #: #! #=
               (?# . ".\\(?:#\\{1,3\\}\\|_(?\\|[{[(?:=!]\\)")

               ;; ;; ;;;
               (?\; . ".;;?")

               ;; __ _|_
               (?_ . ".|?_")

               ;; ~~ ~~> ~> ~- ~@
               (?~ . ".\\(?:~>\\|[>@~-]\\)")

               ;; $>
               (?$ . ".>")

               ;; ^=
               (?^ . ".=")

               ;; ]#
               (?\] . ".#")

               ;; @_
               (?@ . "._")
               )))
  (dolist (char-regexp alist)
    (set-char-table-range composition-function-table (car char-regexp)
                          `([,(cdr char-regexp) 0 font-shape-gstring]))))

CJK characters

CJK characters are supposed to be visually 2 characters wide in a monospace font. I mix and match different fonts for CJK and non-CJK characters, so a bit of fiddling with the font size is necessary to keep this assumption. Otherwise, be prepared to look at unaligned tags in org-mode 😱.

The reason why it’s a minor mode and not a constant is that when changing font size in a buffer with text-scale-adjust, only the characters with the default fontspec get resized.

(defcustom igloo-cjk-chars-size 20
  "Font size for rendering CJK characters in `igloo-scaled-cjk-chars'."
  :group 'igloo-cjk
  :type 'number)
(define-minor-mode igloo-scaled-cjk-chars
  "Minor mode for displaying CJK characters in a bigger size
than the surrounding text."
  :global t
  :lighter nil
  :group 'igloo-cjk
  (let ((fontspec (when igloo-scaled-cjk-chars
                    (font-spec :family "Noto Sans JP" :size igloo-cjk-chars-size))))
    (dolist (charset '(cjk-misc kana bopomofo han kanbun))
      (set-fontset-font t charset fontspec))))

Loading with frontend

The above code gets run when the first frame is created, but we also want to account for environments that don’t have a graphical display - for example, I managed to setup Emacs with this config on my phone via Termux, which only provides a terminal interface. In those kinds of environments, Emacs throws an error because some variables and functions are not defined.

To prevent a crash, we only mess with the fonts on displays that can render multiple fonts.

(igloo-run-with-frontend
 (when (display-multi-font-p)
   <<setup-emoji>>
   <<setup-symbols>>
   <<setup-mathfont>>
   <<setup-ligatures>>
   (igloo-scaled-cjk-chars)))

Modeline

This one is pretty easy - I extracted my modeline configuration to a separate repo, so it’s enough to just download it.

(igloo-use-package vs-modeline
  :straight (:type git
             :host github
             :repo "VojtechStep/vs-modeline.el")
  :demand t
  :config
  (vs-modeline-mode))

Selection framework

Selection framework is a framework for selecting things - the things can be commands when running execute-command (M-x), files when running find-file (C-x C-f), symbols when running describe-symbol (C-h o), or anything, really.

From the many options available, helm, Ivy, ido and selectrum and vertico.el seem to be the most popular. I’ve only tried helm, selectrum and vertico, and while all seem capable of being one’s daily driver, I prefer vertico. It works as a drop-in replacement for the built-in completion framework, so many commands use it automatically. It is the successor of selectrum, which I’d been using before. Helm, on the other hand, requires reimplementations of these features, which makes it (in my opinion) a little bloated.

I set minibuffer-follows-selected-frame to nil, which makes minibuffers bound to the frame they were invoked in. Also sprinkle a little evil bindings on the minibuffer operations.

(igloo-use-package vertico
  :straight t
  :custom
  (minibuffer-follows-selected-frame nil)
  (minibuffer-prompt-properties
   '(read-only t
     cursor-intangible t
     face minibuffer-prompt))
  (vertico-resize nil)
  :custom-face
  (vertico-current ((t (:weight bold
                        :background "#373b47"
                        :foreground "#ffffff"))))
  :general
  (vertico-map
    "C-j" #'vertico-next
    "C-k" #'vertico-previous)
  :init
  (vertico-mode)
  (add-hook 'minibuffer-setup-hook #'cursor-intangible-mode))

prescient is a package for sorting and filtering the list of candidates shown by the selection framework. Another popular alternative is orderless, which seems to have better performance at the expense of having less powerful queries (last time I checked).

Prescient also sorts the candidates according to the frequency of their use, so make this information persistent in a file somewhere.

(igloo-use-package prescient
  :straight t
  :custom
  (prescient-filter-method '(literal initialism fuzzy))
  :custom-face
  (prescient-primary-highlight ((t (:foreground "#b5bd68"))))
  (prescient-secondary-highlight ((t (:foreground "#8abeb7"))))
  :preface
  (declare-function prescient-persist-mode "prescient")
  :config
  (prescient-persist-mode))

This package is a glue for integrating prescient into vertico. The :after clause only specifies vertico, because this package will load prescient by itself.

(igloo-use-package vertico-prescient
  :after vertico
  :straight t
  :demand t
  :config
  (vertico-prescient-mode))

Consult

Remember a few paragraphs above, when I complained about Helm being bloated with its own reimplementations of some built-in pickers? Well, turns out it also contains some functionality which is not built-in, and that’s where consult comes in. It implements many of these commands, but on top of the Emacs API, instead of a specific selection system, so it can be used with any system that plugs into the default completing-read. That’s E X T E N S I B I L I T Y baby!

As for the configuration, I bind some keys to actions I use often, and I disable preview of Agda buffers, which break things terribly for some reason (e.g. the prompt becomes writable, the candidates disappear, I can’t C-g out of it, and have to move the cursor out of the minibuffer and do M-x or something to get consult to error out).

The evil integration bit marks some of consult’s commands as “jumping”, so that when invoked, they push to evil’s jump list.

(igloo-use-package consult
  :straight t
  :custom
  (xref-show-xrefs-function #'consult-xref)
  (xref-show-definitions-function #'consult-xref)
  (consult-narrow-key "C-n")
  (consult-preview-excluded-files '("\\.lagda\\.md" "\\.agda"))
  :general
  ((motion insert) override
    "C-f" #'consult-ripgrep)
  (igloo-leader
    "gg" '(consult-buffer :wk "switch to buffer"))
  (igloo-local-leader-with-minor outline
    "g" '(consult-outline :wk "go to heading"))
  :preface
  (declare-function evil-collection-consult-setup "modes/consult/evil-collection-consult")
  :config
  (with-eval-after-load 'evil-collection
    (evil-collection-consult-setup)))

Project management

projectile is a one-stop solution for project management. It keeps track of project directories in your system, distinguishes project types, allows one to specify build, test and run commands, you name it.

Configuration

Let’s start off with the risky one - when compilation-read-command is nil, then Emacs does not ask for user confirmation for running the compilation command for the buffer. I turn it off, because it slows me down when I’m working on my projects, and if some code I downloaded off the internetz wants to change the compilation command with something like a dirlocal variable, then Emacs asks whether it’s ok to change it.

(compilation-read-command nil)

By default, projectile tries to auto-detect the “best” selection framework available for its user-querying operations. Since vertico hooks itself into the most basic level, projectile skips it and tries to use a more “advanced” completion systems that are built-in, so force it to use the default.

(projectile-completion-system 'default)

This is just for convenience - sometimes I might have a buffer open, but not realize to which project it’s bound, and when I want to open a file from the same project, I find it confusing when it doesn’t show up when calling projectile-switch-project. So keep the current project in the candidates.

(projectile-current-project-on-switch 'keep)

When projectile looks for files, it correctly detects that I have fd installed on my system (and you should too), but by default it hides dotfiles, and uses the slower git ls-files in git repositories, so change that.

(projectile-generic-command "fd . -H0 --type f --color=never")
(projectile-git-command "fd . -H0E .git --type f --color=never")

Installation

(igloo-use-package projectile
  :straight t
  :commands projectile-project-root
  :custom
  <<projectile-config>>
  :general
  (igloo-leader
    "p" '(nil :wk "projectile")
    "po" '(projectile-switch-project :wk "open project")
    "pa" '(projectile-command-map :wk "project menu")
    "pka" '(projectile-kill-buffers :wk "close all buffers")
    "pd" '(projectile-dired :wk "project dired")
    "f" '(igloo-ff-dwim :wk "files in project" :no-autoload t))
  :preface
  (declare-function projectile-project-p "projectile")
  (declare-function projectile-register-project-type "projectile")
  (defun igloo-ff-dwim ()
    (interactive)
    (require 'projectile)
    (call-interactively
     (if (projectile-project-p)
         #'projectile-find-file
       #'find-file)))
  :config
  <<projectile-project-types>>
  (projectile-mode))

Project types

projectile already comes with many project types predefined, but Zig projects are not one of them, so add it manually.

Zig

(projectile-register-project-type
 'zig '("build.zig")
 :project-file "build.zig"
 :compile "zig build"
 :run "zig build run")

Direnv

direnv is in concept similar to Emacs’ dirlocals, except instead of setting buffer-local variables, it sets environment variables, and it’s not Emacs specific - you can hook it up to your terminal, so that the environment changes when you cd into it. It’s super handy when working with things like nix-shell or (heaven forbid) Python virtualenvs.

By default, when you open a buffer which belong to a direnv-enabled folder, but the environment is disabled, direnv pops up a warning. Since that is quite annoying, I choose to suppress these warnings.

The advice fixes a race condition where LSP attempts to start before direnv updates the environment, leading to LSP not recognizing the correct server path.

(igloo-use-package direnv
  :straight t
  :demand t
  ; :custom
  ; (direnv-always-show-summary nil)
  :config
  (add-to-list 'warning-suppress-types '(direnv))
  (advice-add 'lsp :before #'direnv-update-environment)
  (direnv-mode))

Input

Unicode

This snippet allows me to type C-S-u and a hexadecimal value of a Unicode codepoint to insert it. This is apparently the norm for GTK, as it works in Chromium-derived browsers (I use Brave), and these are the only two applications where I find it relevant to input Unicode.

In the Pure GTK builds, this keybinding is intercepted at the GUI framework level - and it is expected that the user holds Control and Shift while typing the hex. I programmed my keyboard to be able to support this input method, so I can switch between the two builds seamlessly. This input method also seems to work in the browser.

I would also prefer it to be inside a use-package form, but I’m pretty sure that would require the funcionality to be extracted into a require‘able form (which is understandable).

(defconst igloo--input-unicode-map
  (let ((keymap (make-sparse-keymap)))
    (define-key keymap (kbd "SPC") #'exit-minibuffer)
    (define-key keymap (kbd "C-g") #'abort-recursive-edit)
    keymap))
(defun igloo-input-unicode ()
  (interactive)
  (let ((hex (read-from-minibuffer "" nil igloo--input-unicode-map)))
    (self-insert-command 1 (string-to-number hex 16))))
(with-eval-after-load 'general
  (general-def insert override
    "C-S-u" #'igloo-input-unicode))

Japanese

I’m learning Japanese, so sometimes I need to input Japanese text. I tried ddskk for a while, but I found the input method unintuitive, and the documentation is in Japanese, so I switched to mozc, which is the open source version of Google Japanese Input.

One needs to install the mozc server and Emacs helper independently of the extension. I use a customized version of mozc-ut-common, which builds the server, the Emacs helper, copies the mozc.el package to /usr/share/emacs/site-lisp/mozc/mozc.el, byte compiles it, generates autoloads, and doesn’t depend on Qt. The PKGBUILD can be found here.

The use-package declaration then loads the autoloads, and tries to JIT the code when it is used (this would normally be handled by straight.el, but this package is installed via the system package manager).

(igloo-use-package mozc
  :if (file-exists-p "/usr/share/emacs/site-lisp/mozc")
  :load-path "/usr/share/emacs/site-lisp/mozc"
  :custom
  (mozc-candidate-style 'echo-area)
  (default-input-method "japanese-mozc")
  (mozc-leim-title "Aあ")
  :preface
  (declare-function mozc-mode "mozc")
  (declare-function find-library-name "find-func")
  :init
  (require 'mozc-autoloads)
  :config
  (when (and (fboundp 'native-compile-async)
             (not (subr-native-elisp-p (symbol-function #'mozc-mode))))
    (require 'find-func)
    (native-compile-async (find-library-name "mozc") nil t)))

Spell checking

I use the builtin flyspell package with aspell, which is automatically picked up when installed. To prevent it from putting its shit in $HOME, use the ASPELL_CONF environment variable (search for aspell in the ArchWiki article).

(igloo-use-package ispell)

Persistent undo history

(igloo-use-package undo-fu-session
  :straight (:protocol https)
  :demand t
  :custom
  (undo-fu-session-linear t)
  :config
  (undo-fu-session-global-mode))

Evil

Emacs is a great operating system without a decent text editor, amirite fellas 😂. Up top 🤣!

Configuration

Reminder: the following forms are not exported as-is, but are included in the :custom section of the igloo-use-package form under the next heading.

I can’t think of a time when I wanted to replace only the first occurrence on a line, therefore make the global substitution the default.

(evil-ex-substitute-global t)

At the same time, when I make a selection, I want replacements (and other operations) to affect only the selection, not all the lines the selection spans.

(evil-ex-visual-char-range t)

I occasionally use visual-mode, mostly for org files, so it’s convenient to have Evil treat visual lines like it would normal lines - so for example D, C and vertical movement work as one might expect.

(evil-respect-visual-line-mode t)

Make < and > respect the configured tab width.

(evil-shift-width tab-width)

I often use * to search for symbols in elisp, so make it look up symbols instead of words.

(evil-symbol-word-search t)

Make Y consistent with other capital-single-letter commands: yank to the end of line. There is always yy for yanking the whole line.

(evil-want-Y-yank-to-eol t)

Evil can setup some keybindings for other modes as well - turn it off here, because we will be using evil-collection instead a little later.

(evil-want-keybinding nil)

I prefer the Emacs undo heuristics to Evil’s “what happens in insert mode, stays in insert mode”. Also, since this is running on Emacs 28, use its new undo/redo primitives, because as beautiful as undo-tree is, it is still buggy and I don’t find myself using the history explorer as often as I thought I would.

(evil-want-fine-undo t)
(evil-undo-system 'undo-redo)

This setting somehow makes more sense to my brain when operating with visual mode around bol/eol.

(evil-want-visual-char-semi-exclusive t)

The state is shown in the modeline, no need to echo it.

(evil-echo-state nil)

Use evil’s custom search module. The only difference I’ve encountered so far is that I can enable input methods inside the prompt, instead of having to exit to the proper buffer and toggle them there.

EDIT: evil-search has fucky highlighing; I couldn’t figure out how to un-highlight the search results after pressing n / p, even with the evil-flash-delay variable set; it just doesn’t trigger…

;; (evil-search-module 'evil-search)

Keybindings

Window movement

(:keymaps 'override ; See exception under Conventions
  (igloo-lcag "h") #'evil-window-left
  (igloo-lcag "j") #'evil-window-down
  (igloo-lcag "k") #'evil-window-up
  (igloo-lcag "l") #'evil-window-right
  (igloo-lcag "S-h") #'evil-window-move-far-left
  (igloo-lcag "S-j") #'evil-window-move-very-bottom
  (igloo-lcag "S-k") #'evil-window-move-very-top
  (igloo-lcag "S-l") #'evil-window-move-far-right)

General movement

(motion global
  "j" #'evil-next-visual-line
  "k" #'evil-previous-visual-line
  "L" #'evil-end-of-line-or-visual-line
  "H" #'evil-first-non-blank-of-visual-line)

Misc

(motion global
  ";" #'evil-ex
  "," #'evil-repeat-find-char)

Macros are annoying

(normal global
  "q" nil
  "C-q" #'evil-record-macro)

Insert bindings in minibuffer

(minibuffer-local-map
  "C-v" #'yank
  "C-w" #'evil-delete-backward-word)

Igloo bindings

(igloo-leader
  "SPC" #'evil-switch-to-windows-last-buffer
  "gn" '(evil-buffer-new :wk "open new buffer"))

Shadowing other keybindings

(normal global
  "M-." nil
  "<mouse-2>" nil)

Installation

Evil is one of the few packages that loads eagerly. It also contributes ~30% to the startup time, so it might be worth investigating how to make it load faster (maybe look into possible interaction with general?).

(eval-when-compile (defvar evil-want-keybinding nil))
(igloo-use-package evil
  :straight t
  :demand t
  :custom
  <<evil-config>>
  (evil-lookup-func #'helpful-at-point)
  :general
  <<evil-bindings>>
  :preface
  (declare-function evil-repeat-type "evil-repeat")
  (declare-function evil-normalize-keymaps "evil-core")
  (declare-function evil-state-property "evil-common")
  (declare-function evil-refresh-cursor "evil-common")
  (declare-function evil-local-mode "evil-core")
  (declare-function evil-initialize "evil-core")
  (declare-function helpful-at-point "helpful")
  :config
  (require 'cl-lib)
  (evil-mode))

Sidenote

Why is it necessary to defvar evil-want-keybinding outside the use-package form? If we take a look at the package configuration after macro expansion, we get something like this:

(progn
  (use-package-statistics-gather :use-package 'evil nil)
  (straight-use-package 'evil)
  (use-package-statistics-gather :preface 'evil nil)
  (eval-and-compile
    (eval-when-compile
      (with-demoted-errors "Cannot load evil: %S" nil
                           (unless
                               (featurep 'evil)
                             (load "evil" nil t))))
    (declare-function evil-repeat-type "evil-repeat")
    ...)
  (customize-set-variable 'evil-ex-substitute-global t "Customized with use-package evil")
  ...

Now, evil can set up some keybindings for other modes, by require‘ing evil-keybindings, which is done automatically if evil-want-keybinding is non-nil.

Since we don’t want the default keybindings (because we are using evil-collection), we set evil-want-keybindings to nil in the :custom section. BUT!!! this only happens after evil is loaded, which is too late. use-package loads evil during byte compilation too, so we get a warning from evil-collection that we are not properly disabling evil’s keybindings.

There is currently no way to add custom code to the beginning of the preface while byte-compiling (only :defines and :functions can go there, but use-package doesn’t allow for setting a default value for defvar), so the variable needs to be set outside. Big sad 😢.

Extensions

evil-collection is a collection of Evil-themed keybindings for various special modes.

(igloo-use-package evil-collection
  :after evil
  :straight t
  :demand t)

evil-surround allows one to create, change and delete paired delimiters.

(igloo-use-package evil-surround
  :after evil
  :straight t
  :demand t
  :config
  (global-evil-surround-mode))

evil-goggles shows a little animation when editing text with operators, to make the user aware of the region the action is applied to. Shorten the duration tho, because I still want it to be snappy.

(igloo-use-package evil-goggles
  :after evil
  :straight t
  :demand t
  :custom
  (evil-goggles-duration 0.1)
  :config
  (evil-goggles-mode))

evil-commentary implements operators for (un)commenting a region, which I find familiar from my vim setup.

(igloo-use-package evil-commentary
  :after evil
  :straight t
  :demand t
  :config
  (evil-commentary-mode))

evil-numbers adds functions for vim’s incrementing and decrementing of numbers under cursor. However, since C-x is heavily used in Emacs for other purposes, the functions are not bound to any keys by default.

(igloo-use-package evil-numbers
  :after evil
  :straight t
  :general
  (motion global
    "C-a" #'evil-numbers/inc-at-pt
    "C-S-a" #'evil-numbers/dec-at-pt))

Misc modes

These packages have no meaningful configuration on its own (yet), except for evil-collection keybindings. A configuration should be moved out of this section if it were to get more extensive.

(igloo-use-package debug
  :preface
  (declare-function evil-collection-debug-setup "modes/debug/evil-collection-debug")
  :config
  (evil-collection-debug-setup))
(igloo-use-package calc
  :preface
  (declare-function evil-collection-calc-setup "modes/calc/evil-collection-calc")
  :config
  (evil-collection-calc-setup))
(igloo-use-package tabulated-list
  :hook
  ((tabulated-list) . hl-line-mode)
  :preface
  (declare-function evil-collection-tabulated-list-setup "modes/tabulated-list/evil-collection-tabulated-list")
  :config
  (evil-collection-tabulated-list-setup))

Docs

Use an alternative viewer for builtin documentation. There is currently an issue where the helpful- functions might crap out at some positions in the buffer, scrolling a few lines usually fixes it though.

The functions to show and close the help buffer are documented under Window layout. The q binding needs to be bound after evil-collection-helpful-setup, because evil-collection rebinds it to quit-window.

(igloo-use-package helpful
  :straight t
  :custom
  (helpful-switch-buffer-function #'igloo--helpful-show-buffer)
  :general
  ([remap describe-function] #'helpful-function
   [remap describe-symbol] #'helpful-symbol
   [remap describe-variable] #'helpful-variable
   [remap describe-key] #'helpful-key)
  :preface
  (declare-function evil-collection-helpful-setup "modes/helpful/evil-collection-helpful")
  :config
  (evil-collection-helpful-setup)
  (general-def normal helpful-mode-map
    "q" #'igloo--helpful-quit-buffer))

Turn on evil bindings for the default Info viewer.

(igloo-use-package info
  :preface
  (declare-function evil-collection-info-setup "modes/info/evil-collection-info")
  :config
  (evil-collection-info-setup))

Show elisp function signatures in the echo area. However, it’s supposed to be subtle - one can always pull up the documentation with a single keypress, so don’t allow it to expand the echo area to multiple lines.

(igloo-use-package eldoc
  :hook
  ((prog-mode) . eldoc-mode)
  :custom
  (eldoc-idle-delay 0.25)
  (eldoc-echo-area-use-multiline-p nil))

File explorer

TODO: docs, is some more setup necessary? I don’t use dired that much right now.

(igloo-use-package dired
  :general
  (igloo-leader
    "ad" '(dired :wk "Dired"))
  :preface
  (declare-function evil-collection-dired-setup "modes/dired/evil-collection-dired")
  :config
  (evil-collection-dired-setup))

Shell

TODO: docs

(igloo-use-package sh-script
  :init
  (igloo-link-offset 'sh-basic-offset))
(igloo-use-package compile
  :hook
  ((compilation-filter) . ansi-color-compilation-filter)
  :general
  (compilation-mode-map
    "h" nil
    "?" nil)
  :preface
  (declare-function evil-collection-compile-setup "modes/compile/evil-collection-compile")
  :config
  (evil-collection-compile-setup))

Eshell

Prompt

(eval-when-compile
  (defmacro igloo--eshell-segment (form &optional fg bg &rest props)
    (declare (indent defun))
    (unless bg (setq bg ''term))
    (unless fg (setq fg ''term))
    (list 'propertize
          form
          ''face
          (append
           `(list :background (face-background ,bg)
                  :foreground (face-foreground ,fg))
           props))))
(defun igloo--eshell-prompt ()
  (concat
   (igloo--eshell-segment (user-login-name)
     'term-color-green)
   (igloo--eshell-segment (concat "@" (system-name)))
   " "
   (igloo--eshell-segment
     (igloo-fishy-abbrev (abbreviate-file-name (eshell/pwd)))
     'term-color-green)
   (when-let ((branch (and (fboundp 'magit-get-current-branch)
                           (magit-get-current-branch))))
     (igloo--eshell-segment
       (concat " (" branch ")")))
   (when (not (eq 0 eshell-last-command-status))
     (igloo--eshell-segment
      (concat " ["
              (number-to-string eshell-last-command-status)
              "]")
      'term-color-red))
   (igloo--eshell-segment "> ")))
(defun igloo--abbrev-terminal (filename)
  (or (equal filename "/")
      (equal filename "~")))
(defun igloo-fishy-abbrev (filename)
  (let ((dir filename)
        segments)
    (when (file-directory-p filename)
      (setq filename (directory-file-name filename)))
    (unless (igloo--abbrev-terminal filename)
      (push (file-name-nondirectory filename) segments)
      (setq dir (directory-file-name (file-name-directory filename))))
    (while (not (igloo--abbrev-terminal dir))
      (let ((parent (file-name-directory dir))
            (segment (file-name-nondirectory dir)))
        (push (substring segment 0 1) segments)
        (setq dir (directory-file-name parent))))
    (if (equal dir "/")
        (push "" segments)
      (push dir segments))
    (string-join segments "/")))

Completion

TODO: completing files in folders is fucky.

(igloo-use-package fish-completion
  :straight (:protocol https)
  :hook
  ((eshell-mode) . fish-completion-mode))

Aliases

(igloo-use-package esh-autosuggest
  :straight t
  :hook
  ((eshell-mode) . esh-autosuggest-mode))

Installation

(igloo-use-package eshell
  :custom
  (eshell-prompt-function #'igloo--eshell-prompt)
  (eshell-prompt-regexp (rx bol
                            (literal (user-login-name))
                            ?@
                            (literal (system-name))
                            " "
                            (+ anychar)
                            "> "))
  :general
  (igloo-leader
    "as" #'eshell)
  (insert eshell-mode-map
    "C-d" '(igloo-eshell-C-d :wk "EOF" :no-autoload t))
  :preface
  (declare-function eshell-get-old-input "esh-mode")
  (declare-function eshell-life-is-too-much "esh-mode")
  (declare-function eshell-tail-process "esh-cmd")
  (declare-function eshell-send-eof-to-process "esh-mode")
  (declare-function eshell/pwd "em-dirs")
  (declare-function evil-collection-eshell-setup "modes/eshell/evil-collection-eshell")
  (defun igloo-eshell-C-d (&optional force)
    "Send C-d to running command.
If no interactive process is running, kill the current
Eshell session if there is no command at the last prompt.

If FORCE is non-nil, always kill the session, regardless of prompt."
    (interactive)
    (if (eshell-tail-process)
        (eshell-send-eof-to-process)
      (when (or force
                (string-empty-p (eshell-get-old-input)))
        (eshell-life-is-too-much))))
  <<eshell-prompt>>
  :config
  (setq-mode-local eshell-mode truncate-lines nil)
  (evil-collection-eshell-setup))

Magit

magit is arguably the best git experience you can have, integrated into your editing environment.

TODO: docs

Configuration

The configuration provided here is rather bare.

Diffs are not shown when committing, because it pops two frames (one for the commit message, one for the diff), and autofocuses the wrong one, so one has to manually change focus. TODO: this probably can be fixed with magit-display-buffer-function.

(magit-commit-show-diff nil)

Then, I generally don’t want to be asked to save the open files, and I don’t want it to be done automatically, so make magit not worry about it.

(magit-save-repository-buffers nil)

There is a secret flag for enabling interpretation of ANSI escape sequences in git output.

(magit-process-finish-apply-ansi-colors t)

Magit can show the diff in word-granularity instead of line-granularity.

(magit-diff-refine-hunk 'all)

Finally, magit has keybindings for h and l, so one can’t move around with vim bindings in muscle memory. Evil-collection can fortunately change the bindings so that horizontal movement works as expected.

(evil-collection-magit-want-horizontal-movement t)

Installation

(igloo-use-package magit
  :straight t
  :custom
  <<magit-config>>
  (magit-repository-directories
    <<magit-directories>>)
  (magit-repolist-columns
   '(("Name" 25 magit-repolist-column-ident ())
     ("Version" 25 magit-repolist-column-version ())
     ("F" 1 magit-repolist-column-flag ())
     ("" 1 magit-repolist-column-unpulled-from-upstream
      ((:right-align t)
       (:help-echo "Unpulled commits")))
     ("" 1 magit-repolist-column-unpushed-to-upstream
      ((:right-align t)
       (:help-echo "Unpushed commits")))
     ("Path" 99 magit-repolist-column-path ())))
  :general
  (igloo-leader
    "m" '(nil :wk "magit")
    "mm" '(magit-status :wk "magit status")
    "mf" '(magit-file-dispatch :wk "magit file")
    "ml" '(magit-list-repositories :wk "magit repos"))
  :preface
  (declare-function evil-collection-magit-setup "modes/magit/evil-collection-magit")
  :init
  (with-eval-after-load 'magit-repos ; magit-repos does not load magit, so the evil-collection setup is not triggered
    (evil-collection-magit-setup))
  :config
  (evil-collection-magit-setup))

Enhancements

With an extension, magit can collect TODO’s sprinkled around your git projects, and show them in the status buffer.

(igloo-use-package magit-todos
  :disabled
  :after magit
  :straight t
  :hook
  ((magit-mode) . magit-todos-mode)
  :preface
  (declare-function evil-collection-magit-todos-setup "modes/magit-todos/evil-collection-magit-todos")
  :config
  (evil-collection-magit-todos-setup))

Forge

TODO: emacs-evil/evil-collection#543 TODO: move forge configs to room.org, to not leak private/school git forges’ URLs

(igloo-use-package forge
  :after magit
  :straight t
  :demand t
  :hook
  ((magit-status-sections) . forge-insert-authored-pullreqs)
  ((magit-status-sections) . forge-insert-authored-issues)
  :custom
  (forge-topic-list-limit '(20 . -5))
  (forge-add-default-bindings nil)
  :preface
  (declare-function magit-add-section-hook "magit-section")
  (declare-function evil-collection-forge-setup "modes/forge/evil-collection-forge")
  :config
  (evil-collection-forge-setup)
  (dolist (section (list #'forge-insert-authored-pullreqs
                         #'forge-insert-authored-issues))
    (magit-add-section-hook
     'magit-status-sections-hook
     section
     'magit-insert-unpulled-from-upstream
     'after)))

Time tracking

I use ActivityWatch for tracking how I use my time - it’s an opensource, local-only alternative to WakaTime and similar solutions.

(igloo-use-package activity-watch-mode
  :disabled
  :straight t
  :defer 1
  :config
  (global-activity-watch-mode))

Profiler

Often times, when trying to profile a performance bottleneck, selecting the commands from the M-x popup introduces unnecessary perfcounters, so make keybindings for the most commonly used commands.

(igloo-use-package profiler
  :general
  (igloo-leader
    "dd" '(profiler-start :wk "Start profiler")
    "ds" '(profiler-stop :wk "Stop profiler")
    "dr" '(profiler-report :wk "Report profiling"))
  :preface
  (declare-function evil-collection-profiler-setup "modes/profiler/evil-collection-profiler")
  :config
  (evil-collection-profiler-setup))

Programming

TODO: docs

Error reporting

(igloo-use-package flycheck
  :straight t
  :hook
  ((prog-mode) . flycheck-mode)
  :custom
  (flycheck-display-errors-delay 0.5)
  (flycheck-emacs-lisp-initialize-packages nil)
  (flycheck-display-errors-function #'flycheck-display-error-messages-unless-error-list)
  :preface
  (declare-function evil-collection-flycheck-setup "modes/flycheck/evil-collection-flycheck")
  (declare-function flycheck-display-error-messages-unless-error-list "flycheck")
  (defun igloo--ignore-eldoc-when-flycheck (&rest _)
    (not (and (fboundp 'flycheck-overlay-errors-at)
              (flycheck-overlay-errors-at (point)))))
  :config
  (advice-add #'eldoc-display-message-no-interference-p
              :after-while
              #'igloo--ignore-eldoc-when-flycheck)
  (evil-collection-flycheck-setup))

Code formatting

(igloo-use-package apheleia
  :straight (:repo "raxod502/apheleia"))

TODO: brag

(eval-when-compile
  (use-package apheleia-use-package
    :straight (:host github
               :repo "VojtechStep/apheleia-use-package.el")
    :demand t))

Code folding

Rudimentary built-in folding. Use z m to fold all blocks, z r to open them, z a to toggle block at point.

(igloo-use-package hideshow
  :hook
  ((prog-mode) . hs-minor-mode))

Auto complete

TODO: docs

(igloo-use-package company
  :straight t
  :custom
  (company-idle-delay 0)
  (company-selection-wrap-around t)
  :general
  (company-active-map
    "C-j" #'company-select-next
    "C-k" #'company-select-previous
    "C-w" nil)
  (insert company-mode
    :definer 'minor-mode
    "C-SPC" #'company-complete))

LSP

TODO: reason about personal choices here

Semantic tokens use 100% CPU, I should investigate…

The JSON overrides are required because some languages servers return NULL bytes in their responses. Emacs 29 should fix it.

(igloo-use-package lsp-mode
  :straight t
  :commands lsp
  :custom
  (lsp-enable-snippet nil)
  (lsp-semantic-tokens-enable nil)
  (lsp-enable-symbol-highlighting nil)
  (lsp-completion-provider :capf)
  (lsp-keep-workspace-alive nil)
  (lsp-lens-place-position 'above-line)
  (lsp-auto-execute-action nil)
  (lsp-disabled-clients '(lsp-volar))
  :general
  (normal lsp-mode
    :definer 'minor-mode
    ;; "gh" (lambda () (interactive) (lsp-hover))
    "gR" '(lsp-rename :wk "Rename identifier")
    "g." '(lsp-execute-code-action :wk "Code actions"))
  (visual lsp-mode
    :definer 'minor-mode
    "C-j" '(lsp-extend-selection :wk "Extend selection"))
  ;; :preface
  ;; (declare-function lsp-hover "lsp-mode")
  :config
  (delete 'lsp-volar lsp-client-packages) ;; messes up typescript dependency
  (advice-add 'json-parse-string :around
              (lambda (orig string &rest rest)
                (apply orig (string-replace "\\u0000" "" string)
                       rest)))
  (advice-add 'json-parse-buffer :around
              (lambda (orig &rest rest)
                (while (re-search-forward "\\u0000" nil t)
                  (replace-match ""))
                (apply orig rest))))

Elisp

(igloo-use-package elisp-mode
  :hook
  ((emacs-lisp-mode) . outline-minor-mode))
(igloo-use-package flycheck-package
  :after flycheck
  :straight t
  :demand t
  :config
  (flycheck-package-setup))

Racket

(igloo-use-package racket-mode
  :straight t)

Fish shell

Fish keeps its tab-width, since the default in the built-in fish_indent command is not configurable.

(igloo-use-package fish-mode
  :straight t
  :hook
  ((fish-mode) . apheleia-mode))

Haskell

(igloo-use-package haskell-mode
  :straight t
  :hook
  ((haskell-mode) . lsp-deferred)
  ((haskell-mode) . interactive-haskell-mode))
(igloo-use-package lsp-haskell
  :straight t
  :custom
  (lsp-haskell-formatting-provider "brittany"))

Nix

(igloo-use-package nix-mode
  :straight t
  :mode "\\.nix\\'"
  :hook
  ((nix-mode) . apheleia-mode)
  ((nix-mode) . lsp-deferred)
  :apheleia
  (nixpkgs-fmt . ("nixpkgs-fmt"))
  nix-mode
  :config
  (with-eval-after-load 'lsp-mode
    (add-to-list 'lsp-language-id-configuration '(nix-mode . "nil"))
    (lsp-register-client
     (make-lsp-client :new-connection (lsp-stdio-connection "nil")
                      :activation-fn (lsp-activate-on "nil")
                      :server-id 'nix-nil))
    (add-to-list 'lsp-language-id-configuration '(nix-mode . "nixd"))
    (lsp-register-client
     (make-lsp-client :new-connection (lsp-stdio-connection "nixd")
                      :activation-fn (lsp-activate-on "nixd")
                      :server-id 'nix-nixd))))

Coq

(igloo-use-package proof-general
  :straight t
  :custom
  ;; (coq-prog-name "hoqtop")
  (proof-splash-enable nil)
  (proof-follow-mode 'followdown)
  (coq-compile-before-require t)
  (coq-diffs 'removed)
  (proof-multiple-frames-enable t)
  :general
  ((insert motion) coq-mode-map
    "C-j" #'proof-assert-next-command-interactive
    "C-k" #'proof-undo-last-successful-command
    "C-<return>" #'proof-goto-point)
  (motion coq-mode-map
    "<tab>" #'pg-toggle-visibility)
  (normal coq-mode-map
    "gh" #'company-coq-doc)
  :init
  (when-let* ((pg-file (find-library-name "proof-general"))
              (pg-dir (file-name-directory pg-file))
              (ps-dir (expand-file-name "generic" pg-dir)))
    (cl-pushnew ps-dir load-path)))
(igloo-use-package company-coq
  :straight t
  :hook
  ((coq-mode) . company-coq-mode)
  :general
  (company-coq-map
    "C-<return>" #'company-coq-proof-goto-point))
(igloo-use-package alectryon
  :straight t)

Agda

TODO: document the shit out of this, maybe extract it to a package?

use-package tries to compile agda2-mode during byte-compilation, and even doing (eval-and-compile (let ((byte-compile-current-file nil)) ...)) doesn’t seem to prevent it, hence this ugly hack with igloo--byte-compile-current-file.

(eval-when-compile
  (defvar igloo--byte-compile-current-file byte-compile-current-file)
  (setq byte-compile-current-file nil))
(igloo-use-package agda2-mode
  :straight nil
  :mode ("\\.\\(agda\\|lagda\\(\\.\\(tex\\|md\\|rst\\|org\\)\\)?\\)\\'" . igloo--load-agda-mode)
  :hook
  ((agda2-mode) . (lambda ()
                    (setq fill-column 80)
                    (display-fill-column-indicator-mode)))
  :custom
  (agda-input-tweak-all
    '(agda-input-compose (agda-input-prepend ";")
                         (agda-input-nonempty)))
  (agda2-highlight-level 'interactive)
  :preface
  (declare-function lsp-register-client "lsp-mode")
  (declare-function make-lsp-client "lsp-mode")
  (declare-function lsp-stdio-connection "lsp-mode")
  (declare-function lsp-activate-on "lsp-mode")
  (defvar igloo--did-setup-agda-highlight nil)
  (defun igloo--setup-agda-highlight ()
    (unless igloo--did-setup-agda-highlight
      (require 'agda2-highlight)
      ;; boxify
      (dolist (face-part '(unsolved-meta
                           unsolved-constraint
                           termination-problem
                           positivity-problem
                           shadowing-in-telescope
                           catchall-clause
                           confluence-problem
                           missing-definition))
        (let ((face-name
               (intern
                (concat "agda2-highlight-" (symbol-name face-part) "-face"))))
          (message "Background color was %s" (face-attribute face-name :background))
          (set-face-attribute
           face-name nil
           ;; the order matters here
           :box `(:line-width 2 :color ,(face-attribute face-name :background))
           :background 'unspecified)))
      ;; underline
      (let ((face 'agda2-highlight-coverage-problem-face))
        (set-face-attribute
         face nil
         :underline (face-attribute face :background)
         :background 'unspecified))
      ;; strikethrough
      (let ((face 'agda2-highlight-deadcode-face))
        (set-face-attribute
         face nil
         :strike-through (face-attribute face :background)
         :background 'unspecified))
      (setq igloo--did-setup-agda-highlight t)))
  (defun igloo--load-agda-mode ()
    (let ((buffer (current-buffer)))
      (run-with-timer
       0 nil
       (lambda ()
         (when (buffer-live-p buffer)
           (let ((agda-mode-file (shell-command-to-string "agda-mode locate")))
             (when (string-prefix-p "/bin/sh:" agda-mode-file)
               (error "Agda is not installed in the environment"))
             (when-let ((existing-path
                         (condition-case nil
                             (find-library-name "agda2")
                           (error nil))))
               (unless (equal existing-path agda-mode-file)
                 (error "Another agda mode is loaded in this Emacs instance")))
             (let ((agda-mode-dir (file-name-directory agda-mode-file)))
               (cl-pushnew agda-mode-dir load-path :test #'equal)
               (igloo--setup-agda-highlight)
               (require 'agda2-mode)
               (if (fboundp 'agda2-mode)
                   (agda2-mode)
                 (error "Agda mode not found")))))))))
  :config
  (with-eval-after-load 'lsp-mode
    (add-to-list 'lsp-language-id-configuration '(agda2-mode . "agda"))
    (lsp-register-client
     (make-lsp-client :new-connection (lsp-stdio-connection "als")
                      :activation-fn (lsp-activate-on "agda")
                      :server-id 'als))))
(eval-when-compile (setq byte-compile-current-file igloo--byte-compile-current-file))

Lean

(eval-when-compile
  (defvar igloo--byte-compile-current-file byte-compile-current-file)
  (setq byte-compile-current-file nil))
(igloo-use-package lean4-mode
  :straight nil
  :mode ("\\.lean\\'" . igloo--load-lean-mode)
  :preface
  (defun igloo--load-lean-mode ()
    (let ((buffer (current-buffer)))
      (run-with-timer
       0 nil
       (lambda ()
         (when (buffer-live-p buffer)
           (let ((lean-site-lisp (getenv "LEAN_MODE")))
             (unless lean-site-lisp
               (error "Lean mode is not available in the environment"))
             (when-let ((existing-site-lisp
                         (condition-case nil
                             (find-library-name "lean4")
                           (error nil))))
               (unless (equal lean-site-lisp existing-site-lisp)
                 (error "Another Lean mode is loaded in this Emacs instance")))
             (cl-pushnew lean-site-lisp load-path :test #'equal)
             (require 'lean4-mode)
             (if (fboundp 'lean4-mode)
                 (lean4-mode)
               (error "Lean mode not found")))))))))
(eval-when-compile (setq byte-compile-current-file igloo--byte-compile-current-file))

C/C++

(igloo-use-package cc-mode
  :hook
  ((c-mode-common) . apheleia-mode)
  ((c-mode-common) . lsp-deferred)
  :apheleia
  (clang-format . ("clang-format" "--assume-filename" filepath))
  c-mode c++-mode java-mode)
(igloo-use-package cmake-mode
  :straight t)

TypeScript

(igloo-use-package lsp-eslint
  :preface
  (declare-function lsp-eslint-server-command "lsp-eslint")
  :config
  (advice-add #'lsp-eslint-server-command :before
              (lambda (&rest _)
                (when-let ((eslint-server-path (getenv "VSCODE_ESLINT")))
                  (setq lsp-eslint-server-command
                        `("node" ,eslint-server-path "--stdio"))))))
(igloo-use-package web-mode
  :straight t
  :mode
  ("\\.[tj]sx?\\'" . web-mode)
  ("\\.json\\'" . web-mode)
  :hook
  ((web-mode) . igloo--enable-standard-pairs)
  ((web-mode) . apheleia-mode)
  :apheleia
  (prettier-local . ("yarn" "--silent" "prettier" "--stdin-filepath" filepath))
  web-mode
  :custom
  (lsp-javascript-display-inlay-hints t)
  (lsp-javascript-display-parameter-name-hints "literals")
  (lsp-json-schemas
   [((fileMatch . ["*.tsconfig.json"])
     (url . "http://json.schemastore.org/tsconfig"))])
  (web-mode-comment-formats
   '(("javascript" . "//")
     ("typescript" . "//")))
  :preface
  (declare-function lsp-dependency "lsp-mode")
  (defun igloo--try-enable-tsx ()
    (when (string-match-p "[tj]sx?\\'" (file-name-extension buffer-file-name))
      (setq-local web-mode-enable-auto-quoting nil)
      (require 'lsp-javascript)
      (let ((potential-tsserver (expand-file-name "node_modules/.bin/tsserver" (projectile-project-root))))
        (when (file-exists-p potential-tsserver)
          (lsp-dependency 'typescript `(:system ,potential-tsserver))))
      (lsp)))
  :init
  (add-hook 'web-mode-hook #'igloo--try-enable-tsx))
(igloo-use-package typescript-mode
  :disabled
  :straight t
  :hook
  ;; ((typescript-mode) . tide-setup)
  ((typescript-mode) . lsp-deferred)
  :custom
  (lsp-javascript-display-inlay-hints t)
  (lsp-javascript-display-parameter-name-hints "literals")
  :init
  (igloo-link-offset 'js-indent-level))
(igloo-use-package tide
  :disabled
  :straight t)

Rust

(igloo-use-package rustic
  :straight t
  :hook
  ((rustic-mode) . apheleia-mode)
  ((rustic-mode) . lsp-deferred)
  :apheleia
  ;; (rustfmt . ("rustup" "run" "nightly" "rustfmt" "--unstable-features" "--skip-children" "--quiet" "--emit" "stdout" file))
  (rustfmt . ("rustfmt" "--quiet" "--emit" "stdout" "--edition" "2021" "--config" "skip_children=true" file))
  rustic-mode
  :custom
  (rustic-lsp-setup-p nil)
  (lsp-rust-analyzer-server-display-inlay-hints t)
  (lsp-rust-analyzer-diagnostics-disabled ["unresolved-proc-macro"])
  (lsp-rust-clippy-preference "on")
  (lsp-rust-analyzer-cargo-watch-command "clippy"))

Zig

(igloo-use-package zig-mode
  :straight t
  :hook
  ((zig-mode) . lsp-deferred)
  :apheleia
  (zig-fmt . ("zig" "fmt" "--stdin"))
  zig-mode
  :custom
  (zig-format-on-save nil)
  :preface
  :config
  (setq-mode-local zig-mode auto-fill-mode -1)
  (require 'lsp-zig))

BQN

(igloo-use-package bqn-mode
  :straight '(:host github
              :repo "museoa/bqn-mode")
  :hook
  ((bqn-mode) . (lambda () (set-input-method "BQN-Z"))))

OCaml

(eval-when-compile
  (defvar igloo--byte-compile-current-file byte-compile-current-file)
  (setq byte-compile-current-file nil))
(igloo-use-package merlin
  :straight nil
  :hook
  ((merlin-mode) . company-mode)
  :init
  (defun igloo-load-merlin-mode ()
    (let ((buffer (current-buffer)))
      (run-with-timer
       0 nil
       (lambda ()
         (when (buffer-live-p buffer)
           (let ((merlin-site-lisp (getenv "MERLIN_MODE")))
             (unless merlin-site-lisp
               (error "Merlin mode is not available in the environment"))
             (when-let ((existing-site-lisp
                         (condition-case nil
                             (find-library-name "merlin-mode")
                           (error nil))))
               (unless (equal merlin-site-lisp existing-site-lisp)
                 (error "Another Merlin mode is loaded in this Emacs instance")))
             (cl-pushnew merlin-site-lisp load-path :test #'equal)
             (require 'merlin)
             (if (fboundp 'merlin-mode)
                 (merlin-mode)))))))))
(eval-when-compile (setq byte-compile-current-file igloo--byte-compile-current-file))
(igloo-use-package tuareg
  :straight t
  :hook
  ((tuareg-mode) . igloo-load-merlin-mode))

Java

Steps I haven’t automated yet: jdt-ls really insists on writing logs to its config directory, which in my case is always a nix store path. The solution is to create a temporary directory, copy /nix/store/...-jdt/share/java/jdtls/config_linux/config.ini in there, and set lsp-java-server-config-dir to it. Also we need to set lsp-java-server-install-dir to /nix/store/...-jdt/share/java/jdtls

There might be a solution involving not setting the -configuration flag, but instead fiddling with some -D stuff, c.f. NixOS/nixpkgs#99330 (comment).

(igloo-use-package lsp-java
  :disabled
  :straight t)

Web

(igloo-use-package browse-url
  :custom
  (browse-url-browser-function #'browse-url-xdg-open))

Email

Produces a warning because of the deprecated package rfc2368, fixed in v1.7 (#2190).

TODO: docs TODO: move personal stuff to room.org TODO: split into config + installation

(igloo-use-package mu4e
  :load-path "/usr/share/emacs/site-lisp/mu4e"
  :hook
  ((mu4e-compose-hook) . company-mode)
  ((mu4e-compose-hook) . flyspell-mode)
  :custom
  (mu4e-read-option-use-builtin nil)
  (mu4e-completing-read-function #'completing-read)
  (mu4e-get-mail-command "mbsync-par")
  (mu4e-confirm-quit nil)
  (mu4e-context-policy 'pick-first)
  (mu4e-compose-context-policy 'ask)
  (mu4e-compose-format-flowed t)
  (mu4e-change-filenames-when-moving t)
  (mu4e-use-fancy-chars t)
  (mu4e-headers-precise-alignment t)
  (mu4e-headers-draft-mark '("D" . "👷"))
  (mu4e-headers-flagged-mark '("F" . "🚩"))
  (mu4e-headers-new-mark '("N" . "🔥"))
  (mu4e-headers-passed-mark '("P" . ""))
  (mu4e-headers-replied-mark   '("R" . "🗨"))
  (mu4e-headers-seen-mark      '("S" . ""))
  (mu4e-headers-trashed-mark   '("T" . "🗑"))
  (mu4e-headers-attach-mark    '("a" . "📎"))
  (mu4e-headers-encrypted-mark '("x" . "🔒"))
  (mu4e-headers-signed-mark    '("s" . "🖋"))
  (mu4e-headers-unread-mark    '("u" . ""))
  (mu4e-headers-list-mark      '("s" . "🔈"))
  (mu4e-headers-personal-mark '("p" . "🤓"))
  (mu4e-headers-calendar-mark  '("c" . "📅"))
  (mail-user-agent 'mu4e-user-agent)
  (message-dont-reply-to-names #'mu4e-personal-or-alternative-address-p)
  (message-send-mail-function #'message-send-mail-with-sendmail)
  (sendmail-program "msmtp")
  (message-sendmail-f-is-evil t)
  (message-sendmail-envelope-from 'header)
  (message-sendmail-extra-arguments '("--read-envelope-from" "--read-recipients"))
  (user-full-name "Vojtěch Štěpančík")
  :general
  (igloo-leader
    "am" #'mu4e)
  :preface
  (declare-function message-replace-header "message")
  (declare-function evil-collection-mu4e-setup "modes/mu4e/evil-collection-mu4e")
  (declare-function evil-collection-inhibit-insert-state "evil-collection")
  (declare-function message-send-mail-with-sendmail "message")
  (declare-function mu4e-personal-or-alternative-address-p "mu4e-contacts")
  (declare-function make-mu4e-context "mu4e-context" t t)
  (declare-function mu4e-message "mu4e-helpers")
  (defmacro igloo-mu4e-make-context (name email &optional sent drafts trash &rest vars)
    (let* ((name (eval name))
          (maildir-prefix (concat "/" name "/"))
          (sent (or sent "Sent"))
          (drafts (or drafts "Drafts"))
          (trash (or trash "Trash")))
      `(make-mu4e-context
        :name ,name
        :enter-func (lambda () (mu4e-message ,(format "Entering %s context" name)))
        :leave-func (lambda () (mu4e-message ,(format "Leaving %s context" name)))
        :match-func (igloo--maildir-in ,name)
        :vars '((user-mail-address . ,email)
                (mu4e-sent-messages-behavior . delete)
                (mu4e-sent-folder . ,(concat maildir-prefix sent))
                (mu4e-drafts-folder . ,(concat maildir-prefix drafts))
                (mu4e-trash-folder . ,(concat maildir-prefix trash))
                ,@vars))))
  (defun igloo--maildir-in (name)
    (lambda (msg)
      (when msg
        (string-match-p
         (rx bol "/" (literal name) "/")
         (mu4e-message-field msg :maildir)))))
  :config
  (cl-pushnew '("View in browser"
                . mu4e-action-view-in-browser)
              mu4e-view-actions
              :test #'equal)
  (evil-collection-mu4e-setup)
  (dolist (s '(mu4e-main-mode-map
               mu4e-headers-mode-map
               mu4e-view-mode-map))
    (evil-collection-inhibit-insert-state s))
  (general-def 'normal mu4e-main-mode-map
    "u" nil
    "gr" '(mu4e-update-mail-and-index :wk "refresh")))

HTML composition

(igloo-use-package org-mime
  :straight t
  :custom
  (org-mime-export-options '(:with-toc nil :with-latex imagemagick))
  :general
  (igloo-local-leader mu4e-compose-mode-map
    "oe" '(org-mime-edit-mail-in-org-mode :wk "Edit org")
    "ou" '(org-mime-revert-to-plain-text-mail :wk "Undo htmlize")
    "oo" '(org-mime-htmlize :wk "Htmlize")))
(igloo-use-package htmlize
  :straight t)

Org

TODO: document all of this TODO: split into more sections

(igloo-use-package org
  :straight (:type built-in)
  :hook
  ((org-mode) . visual-line-mode)
  ((org-mode) . org-indent-mode)
  ((org-mode) . flyspell-mode)
  ((org-mode) . (lambda () (hl-todo-mode -1)))
  :custom
  (org-highlight-latex-and-related '(native entities))
  (org-hide-emphasis-markers t)
  (org-pretty-entities t)
  (org-catch-invisible-edits 'smart)
  (org-log-done 'time)
  (org-log-into-drawer t)
  (org-special-ctrl-a/e t) ; bol/eol ignore stars, tags, ...
  (org-directory "~/Org/")
  (org-agenda-files
   (directory-files-recursively org-directory "\\`[^.].*\\.org\\'"))
  (org-use-fast-todo-selection 'expert)
  (org-todo-keywords
   '((sequence "TODO(t)" "NEXT(n)" "WAITING(w@)" "|" "DONE(d)" "CANCELLED(c@)")))
  (org-enforce-todo-dependencies t)
  ;; (org-use-tag-inheritance nil)
  (org-babel-load-languages
   '((emacs-lisp . t)
     (python . t)
     (shell . t)))
  (org-structure-template-alist
   '(("c" . "comment")
     ("d" . "definition")
     ("e" . "example")
     ("E" . "export")
     ("l" . "export latex")
     ("p" . "proof")
     ("s" . "src")))
   (org-preview-latex-default-process 'imagemagick)
   (org-preview-latex-process-alist
    '((imagemagick :programs
                   ("tectonic" "convert")
                   :description
                   "pdf > png"
                   :message "You done now"
                   :image-input-type "pdf"
                   :image-output-type "png"
                   :image-size-adjust (1.0 . 1.0)
                   :latex-compiler
                   ("tectonic -o %o %f")
                   :image-converter
                   ("convert -density %D -trim -antialias %f -quality 100 %O"))))
  :general
  (igloo-leader
    "occ" '(org-capture :wk "capture")
    "oi" '(igloo-clock-in :wk "clock in" :no-autoload t)
    "oo" '(igloo-clock-out :wk "clock out" :no-autoload t)
    "oa" '(igloo-org-agenda :wk "agenda" :no-autoload t)
    "of" '(igloo-open-agenda-file :wk "agenda files" :no-autoload t))
  (igloo-local-leader org-mode-map
    "g" '(consult-org-heading :wk "go to heading"))
  :preface
  (declare-function org-agenda-files "org")
  (declare-function org-agenda-clock-in "org-agenda")
  (declare-function org-clock-in "org-clock")
  (declare-function org-agenda-clock-out "org-agenda")
  (declare-function org-clock-out "org-clock")
  (defvar org-agenda-tag-filter-preset nil)
  (defun igloo-org-agenda ()
    (interactive)
    (require 'org-agenda)
    (let ((org-agenda-tag-filter-preset '("-drill")))
      (call-interactively #'org-agenda)))
  (defun igloo-open-agenda-file ()
    (interactive)
    (require 'org-agenda)
    (find-file (expand-file-name
                (completing-read "Open agenda file: "
                                 (org-agenda-files)))))
  (defun igloo-clock-in (arg)
    (interactive "P")
    (cond
     ((derived-mode-p 'org-agenda)
      (org-agenda-clock-in arg))
     ((null arg)
      (org-clock-in '(4)))
     (t (org-clock-in arg))))
  (defun igloo-clock-out (arg)
    (interactive "P")
    (if (derived-mode-p 'org-agenda)
        (org-agenda-clock-out)
      (org-clock-out arg nil)))
  :init
  (require 'org-loaddefs))
(igloo-use-package ox-latex
  :after org
  :custom
  (org-latex-pdf-process '("tectonic %f"))
  (org-preview-latex-default-process 'imagemagick)
  :preface
  (defun igloo-org-normalize-relative-packages (header)
    (replace-regexp-in-string
     (rx bol
         (group
          "\\usepackage"
          (? "[" (*? anychar) "]"))
         "{./" (group (*? anychar)) "}" eol)
     (lambda (match)
       (let ((opts (match-string 1 match))
             (pkg (match-string 2 match)))
         (concat opts "{" (expand-file-name pkg default-directory) "}")))
     header 'fixedcase 'literal))
  :init
  (put 'org-latex-classes 'safe-local-variable (lambda (_) t))
  :config
  (advice-add 'org-latex-make-preamble :filter-return
              #'igloo-org-normalize-relative-packages))
(igloo-use-package org-src
  :after org
  :hook
  ((org-src-mode) . (lambda ()
                      (when (boundp 'flycheck-disabled-checkers)
                        (cl-pushnew 'emacs-lisp-checkdoc flycheck-disabled-checkers))))
  :custom
  (org-edit-src-content-indentation 0)
  (org-src-window-setup 'other-frame)
  :general
  ((motion insert) org-src-mode-map
    [remap evil-write] #'org-edit-src-save))
(igloo-use-package org-faces
  :after org
  :custom
  (org-todo-keyword-faces
   '(("WAITING" . (:foreground "black" :background "white"))
     ("NEXT" . "#e2e")
     ("CANCELLED" . "#fff"))))
(igloo-use-package org-duration
  :after org
  :custom
  (org-duration-format 'h:mm))
(igloo-use-package org-agenda
  :after org
  :hook
  ((org-agenda-mode) . hl-line-mode)
  :custom
  (org-agenda-window-setup 'other-frame)
  (org-agenda-skip-scheduled-if-done t)
  (org-agenda-skip-deadline-if-done t)
  (org-agenda-clockreport-parameter-plist '(:maxlevel 3 :indent nil :fileskip0 t :link t))
  (org-agenda-clock-consistency-checks
   '(:max-duration "10:00"
     :min-duration 0
     :max-gap "0:30"
     :gap-ok-around ("6:00" "12:00" "14:00")
     :default-face ((:background "DarkRed")
                    (:foreground "white"))))
  (org-agenda-custom-commands
   '((" " "Agenda"
      ((agenda ""
               ((org-agenda-span 'day)))
       (todo "NEXT"
             ((org-agenda-overriding-header "Next tasks")
              (org-tags-match-list-sublevels 'indented)
              (org-agenda-sorting-strategy
               '(priority-down effort-up category-keep))))
       (todo "WAITING"
             ((org-agenda-overriding-header "Waiting tasks")
              (org-tags-match-list-sublevels 'indented)
              (org-agenda-sorting-strategy
               '(timestamp-up category-keep))))
       (search "{^\*+\s+Organization}"
               ((org-agenda-overriding-header "Organization tasks"))))
      ((org-agenda-start-with-clockreport-mode t)
       (org-agenda-start-with-log-mode 'clockcheck))))))
(igloo-use-package org-clock
  :after org
  :demand t
  :custom
  (org-clock-x11idle-program-name "xprintidle")
  (org-clock-persist t)
  (org-clock-history-length 25)
  (org-clock-in-resume t)
  (org-clock-out-remove-zero-time-clocks t)
  (org-clock-report-include-clocking-task t)
  (org-clock-in-switch-to-state #'igloo--clock-in-mark-next)
  :preface
  (declare-function org-get-todo-state "org")
  (defun igloo--clock-in-mark-next (_kw)
    (and (not (and (boundp 'org-capture-mode) org-capture-mode))
         (string= (org-get-todo-state) "TODO")
         "NEXT"))
  :config
  (org-clock-persistence-insinuate))
(igloo-use-package org-refile
  :after org
  :custom
  (org-outline-path-complete-in-steps nil)
  (org-refile-targets '((org-agenda-files :maxlevel . 3)))
  (org-refile-use-outline-path 'file))
(igloo-use-package org-capture
  :after org
  :hook
  ((org-capture-mode) . evil-insert-state)
  :custom
  (org-capture-templates nil)
  :config
  (let ((todofile "TODO.org")
        (taskfile "Tasks.org")
        (learnfile "Learn.org")
        (vocabfile "Vocabulary.org")
        (journalfile "Diary.org")
        (htfile "Hightec.org"))
    (dolist (tpl `(("e" "Emacs todo" entry
                    (file+olp ,todofile "Emacs")
                    "** TODO %?")
                   ("s" "System todo" entry
                    (file+olp ,todofile "System")
                    "** TODO %?")
                   ("l" "Learn stuff" entry
                    (file ,learnfile)
                    "* LEARN %?"
                    :empty-lines 1)
                   ("v" "Vocabulary" entry
                    (file+olp+datetree ,vocabfile)
                    "* %^{Vocab} :drill:
Reading: [%^{Reading}]
Meaning: [%^{Meaning}]
Kanji: [%^{Kanji}]")
                   ("t" "Task" entry
                    (file+olp+datetree ,taskfile)
                    "* TODO %?
SCHEDULED: %t")
                   ("d" "Diary" plain
                    (file+olp+datetree ,journalfile)
                    "%?")

                   ("h" "Hightec")
                   ("hs" "Standup" entry
                    (file+regexp ,htfile "Standups")
                    "* %u\n%?"
                    :clock-in t :clock-resume t)
                   ("hm" "Meeting" entry
                    (file+olp ,htfile "Meetings")
                    "* %?\n%U"
                    :clock-in t :clock-resume t)
                   ("hc" "Consultation" entry
                    (file+olp ,htfile "Consultations")
                    "* %? %^g\n%U"
                    :clock-in t :clock-resume t)
                   ("hi" "Issue" entry
                    (file+olp ,htfile "Issues")
                    "* TODO %? %^g\n%U"
                    :clock-in t :clock-resume t)
                   ("hr" "Month report" entry
                    (file+olp ,htfile "Reports")
                    "* %^{month}
#+BEGIN: clocktable :maxlevel 1 :block %\\1 :step month
#+END:
#+BEGIN: clocktable :maxlevel 2 :block %\\1 :step week :stepskip0 t :tcolumns 1
#+END:"
                    :immediate-finish t :jump-to-captured t
                    :tree-type month)))
      (cl-pushnew tpl org-capture-templates :test #'equal))))
(igloo-use-package org-attach
  :after org
  :demand t
  :custom
  (org-attach-use-inheritance t))
(igloo-use-package org-archive
  :after org
  :custom
  (org-archive-subtree-add-inherited-tags t))
(igloo-use-package org-inlinetask
  :after org
  :demand t)
(igloo-use-package evil-org
  :after org
  :straight t
  :hook
  ((org-mode) . evil-org-mode)
  :custom
  (evil-org-key-theme '(navigation insert return additional todo))
  (evil-org-use-additional-insert t)
  :preface
  (declare-function evil-org-set-key-theme "evil-org")
  :config
  (evil-org-set-key-theme))
(igloo-use-package evil-org-agenda
  :after org-agenda
  :demand t
  :hook
  ((org-agenda-mode) . evil-org-agenda-set-keys))
(igloo-use-package org-pomodoro
  :straight t
  :custom
  (org-pomodoro-play-sounds nil)
  (org-pomodoro-manual-break t)
  :general
  (igloo-leader
    "op" #'org-pomodoro))
(igloo-use-package org-drill
  :straight (:protocol https)
  :hook
  ((org-drill-response-mode) . (lambda ()
                                 (igloo-scaled-cjk-chars -1)
                                 (text-scale-set 6)))
  :custom
  (org-drill-learn-fraction 0.01)
  (org-drill-scope 'agenda)
  :preface
  (defun igloo-org-drill-cram-tree ()
    "Cram all items in the subtree."
    (interactive)
    (let ((org-drill-scope 'tree)
          (org-drill-cram-hours 0)
          org-drill-maximum-items-per-session)
      (org-drill-cram))))
(igloo-use-package org-sticky-header
  :straight t
  :hook
  ((org-mode) . org-sticky-header-mode)
  :custom
  (org-sticky-header-full-path 'full))

The manual add-hook calls are necessary, as use-package does not support local hooks (issue).

(igloo-use-package org-appear
  :straight t
  :hook
  ((org-mode) . org-appear-mode)
  ((org-appear-mode) .
   (lambda ()
     (add-hook 'evil-insert-state-entry-hook #'org-appear-manual-start nil 'local)
     (add-hook 'evil-insert-state-exit-hook #'org-appear-manual-stop nil 'local)))
  :custom
  (org-appear-trigger 'manual)
  (org-appear-autolinks t)
  (org-appear-autoentities t)
  :preface
  (declare-function org-appear-manual-start "org-appear")
  (declare-function org-appear-manual-stop "org-appear"))

Exports

Code blocks

Tex

(igloo-use-package tex-mode
  :custom
  (tex-command "tectonic")
  (latex-run-command "tectonic")
  :general
  (insert tex-mode-map
    "\"" #'self-insert-command)
  :config
  (setq-mode-local tex-mode tex-start-commands nil))

Document reading

PDF

TODO: pdf-annot-list-mode-map

(igloo-use-package pdf-tools
  :straight (:fork (:repo "vedang/pdf-tools"))
  :mode ("\\.pdf\\'" . pdf-view-mode)
  :commands pdf-view-mode
  :hook
  ((pdf-view-mode) . auto-revert-mode)
  ((pdf-view-mode) . pdf-isearch-minor-mode)
  ((pdf-view-mode) . pdf-history-minor-mode)
  :custom
  (pdf-links-read-link-convert-commands
   '("-family" <<default-convert-font-name>>
     "-stretch" "Expanded"
     "-pointsize" "%P"
     "-undercolor" "%f"
     "-fill" "%b"
     "-draw" "text %X,%Y '%c'"))
  (pdf-annot-list-display-buffer-action
   '((display-buffer-reuse-window display-buffer-pop-up-frame)
     (inhibit-same-window . t)
     (reusable-frames . t)))
  :general
  (visual pdf-view-mode-map
    "<down-mouse-1>" #'pdf-view-mouse-set-region)
  (normal pdf-view-mode-map
    "r" #'image-rotate
    "c" #'pdf-view-center-in-window
    [remap evil-jump-forward] #'pdf-history-forward
    [remap evil-jump-backward] #'pdf-history-backward)
  :preface
  (declare-function evil-collection-pdf-setup "modes/pdf/evil-collection-pdf")
  :config
  (setq-mode-local pdf-view-mode auto-revert-verbose nil)
  (evil-collection-pdf-setup))

RSS Feed

elfeed is a great interface for reading blog posts, especially with a few improvements to reading tech blogs specifically (primarily syntax highlighting). I might even setup elfeed to manage my YouTube feed, if I ever find a way to synchronize subscriptions between elfeed and NewPipe.

All the tweaks are described in the following sections - some are elfeed-specific, others apply to the rendering engine in general.

Also, elfeed puts its database into ~/.elfeed by default, which is just not right.

(igloo-use-package elfeed
  :straight t
  :custom
  (elfeed-feeds
   <<elfeed-feeds>>)
  (igloo-elfeed-ignored
   <<elfeed-ignored>>)
  (elfeed-db-directory (locate-user-emacs-file "elfeed"))
  (elfeed-search-filter "@2-months-ago -junk +unread")
  :general
  (igloo-leader
    "af" #'elfeed)
  :preface
  (declare-function evil-collection-elfeed-setup "modes/elfeed/evil-collection-elfeed")
  (declare-function elfeed-tag-1 "elfeed-db")
  <<elfeed-ignore-funs>>
  <<elfeed-parse-funs>>
  :init
  (add-hook 'elfeed-new-entry-hook #'igloo--elfeed-retag-junk)
  (add-hook 'elfeed-new-entry-parse-hook #'igloo--elfeed-parse-media)
  :config
  (evil-collection-elfeed-setup)
  (general-def 'normal elfeed-search-mode-map
    "gr" '(elfeed-search-fetch :wk "refresh")))

Ignoring entries

These are helpers for hiding some entries depending on their tags - instead of trying to write a complex filter, I decided write a configurable predicate that will tag new entries with the junk tag. See room.org.tpl for details.

(defcustom igloo-elfeed-ignored nil
  "Alist of mapping from feed id to an ignore pattern."
  :group 'igloo-elfeed
  :type '(choice symbol
                 string
                 (list (choice symbol
                               string))))
(defun igloo--elfeed-matches-ignore (categories pattern)
  (cond
   ((stringp pattern) (member pattern categories))
   ((symbolp pattern) (igloo--elfeed-matches-ignore categories (symbol-name pattern)))
   ((listp pattern) (cl-loop for p in pattern
                             always (igloo--elfeed-matches-ignore categories p)))))
(defun igloo--elfeed-retag-junk (entry)
  (when-let* ((meta (elfeed-entry-meta entry))
              (id (elfeed-entry-feed-id entry))
              (ignored (alist-get id igloo-elfeed-ignored nil nil #'equal))
              (categories (plist-get meta :categories)))
    (when (cl-loop for p in ignored
                   thereis (igloo--elfeed-matches-ignore categories p))
      (elfeed-tag-1 entry 'junk))))

YouTube parsing

TODO: docs on why (write a proper rant)

TLDR: youtube uses an abandoned unsupported format, need to reparse

(defun igloo--elfeed-parse-media (type xml-entry db-entry)
  (when (and (eq type :atom)
             (memq 'youtube (elfeed-entry-tags db-entry)))
    (let* ((thumbnail (cadr (xml-query* (group thumbnail) xml-entry)))
           (thumb-url (alist-get 'url thumbnail))
           (width (alist-get 'width thumbnail))
           (height (alist-get 'height thumbnail))
           (link (elfeed-entry-link db-entry))
           (description (xml-query* (group description *) xml-entry))
           (content-html (format "<div><a href=\"%s\"><img src=\"%s\" width=%s height=%s/></a><p style=\"white-space:pre-wrap;\">%s</p></div>" link thumb-url width height (string-replace "\n" "<br>" description))))
      (setf (elfeed-entry-content db-entry) content-html)
      (setf (elfeed-entry-content-type db-entry) 'html))))

Simple HTML Renderer

The SHR module is the renderer used by elfeed to show the HTML. It is quite configurable, so here it goes.

Slicing images

First of all, showing big images in Emacs buffers can lead to uncomfortable jumping around, specifically when they are scrolled into or out of view. SHR can instead slice up the image, and span it over multiple lines. AFAIK the grid is not configurable (hardcoded to 20 rows and 1 column), so small images suffer somewhat (it takes unexpectedly long to “scroll” through them), but that’s fixable. TODO: remove slices for small images.

The only way of forcing this behaviour without replacing the entire shr-put-image function is adding the '(size . original) flag to the image.

(defun igloo--shr-put-image-always-slice (original spec alt &optional flags)
  (unless (assq 'size flags)
    (setq flags (cons '(size . original) flags)))
  (funcall original spec alt flags))

Local links

Blog posts often have links to other content in the same post - be it footnotes, backreferences, or tables of contents. Unfortunately, shr doesn’t seem to natively understand such local links, and instead opens them in the system web browser.

This can be fixed by remapping what shr-browse-url does on such a link - instead of opening the link, it searches for a text with the property shr-target-id matching the target of the link. This is achieved with a custom keymap that gets applied to all links beginning with #, by overriding how links are rendered.

First, the browsing function - its task is to look at the link under point, then look around the buffer for the tag with the correct id, and move point there.

(defun igloo--shr-local-href-browse ()
  (interactive)
  (let ((url (get-text-property (point) 'shr-url)))
    (unless (and url
                 (eq (string-to-char url) ?#)
                 (goto-char (point-min))
                 (text-property-search-forward
                  'shr-target-id (substring url 1)))
      (message "No local link under point"))))

Second, the keymap - as mentioned above, this just remaps shr-browse-url, and inherits the rest from shr-map. This needs to be put in :config instead of :preface, because it uses shr-map.

(defvar igloo--shr-local-href-map
  (let ((map (make-sparse-keymap)))
    (set-keymap-parent map shr-map)
    (define-key map [remap shr-browse-url] #'igloo--shr-local-href-browse)
    map))

And finally, the function to add the keymap to the local links. This function is used in the next section.

(defun igloo--shr-local-href (a)
  (let ((href (dom-attr a 'href))
        (start (point)))
    (if (not (eq (string-to-char href) ?#))
        (shr-tag-a a)
      (shr-generic a)
      (put-text-property
       start (point)
       'keymap igloo--shr-local-href-map)
      (shr-urlify start href (dom-attr a 'title)))))

Image links

With the above configuration, images can span multiple lines. On the other hand, if an image functions as a link, then each of these lines is shown in the shr-link face, which is underlined - resulting in images with white lines across them. To fix this, tweak how links that are images are rendered - remove the link from the image, and add a text link with the alt text for its contents.

(defun igloo--shr-render-a (a)
  (when-let* ((inner (dom-children a))
              (img (and (eq 'img
                            (dom-tag inner))
                        (car inner))))
    (shr-tag-img img)
    (setq a (dom-node 'a
                      (dom-attributes a)
                      (concat
                       "("
                       (or (when-let ((alt (dom-attr img 'alt)))
                             (and (stringp alt)
                                  (not (string-empty-p alt))
                                  alt))
                           "Link")
                       ")"))))
  (igloo--shr-local-href a))

pre syntax highlight

Another enhancement is adding syntax highlighting to code blocks. The package shr-tag-pre-highlight provides a function for rendering a pre tag with pretty colors, but it uses the same background as the rest of the buffer - hence the wrapper that adds an overlay.

(igloo-use-package shr-tag-pre-highlight
  :after shr
  :straight t
  :demand t
  :preface
  (declare-function shr-tag-pre-highlight "shr-tag-pre-highlight")
  (defun igloo--shr-render-pre (pre)
    (require 'org-faces)
    (let ((beg (point)))
      (shr-tag-pre-highlight pre)
      (overlay-put (make-overlay beg (point))
                   'face 'org-block)))
  :config
  (cl-pushnew '(pre . igloo--shr-render-pre)
              shr-external-rendering-functions
              :test #'equal)
  (dolist (pair '(("nix" . nix)
                  ("haskell" . haskell)
                  ("hl" . haskell)))
    (cl-pushnew pair shr-tag-pre-highlight-lang-modes
                :test #'equal)))

Installation

Putting it all together - helper functions go into :preface, special keymap definition and the image advice into :config, and the link renderer is registered in a hook.

(igloo-use-package shr
  :hook
  ((elfeed-show-mode)
   . (lambda ()
       (setq-local shr-external-rendering-functions
                   (append
                    '((a . igloo--shr-render-a))
                    shr-external-rendering-functions))))
  :preface
  (declare-function shr-tag-a "shr")
  (declare-function shr-tag-img "shr")
  (declare-function shr-put-image "shr")
  (declare-function shr-urlify "shr")
  (declare-function text-property-search-forward "text-property-search")
  (declare-function dom-node "dom")
  <<shr-preface>>
  :config
  <<shr-local-href-keymap>>
  (advice-add #'shr-put-image :around #'igloo--shr-put-image-always-slice))

Utilities

(defvar igloo--yt-history)

(defun igloo-yt (url)
  (interactive (list (read-string "URL: " nil 'igloo--yt-history)))
  (let ((yt-url (replace-regexp-in-string
                 (rx "//" (* (not "/")) "/")
                 "//www.youtube.com/"
                 url)))
    (start-process "yt" nil "mpv" yt-url)))
(with-eval-after-load 'general
  (igloo-leader
    "ay" '(igloo-yt :wk "youtube")))

Footer

Check the correctness of all declared functions.

(eval-when-compile
  (when byte-compile-current-file
    (check-declare-file byte-compile-current-file)))

Footer of a proper elisp file.

(provide 'init)
;;; init.el ends here

Footnotes

[fn:1] TODO: This might be solved by somehow figuring out the color at comptime

About

My Emacs setup (literally)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published