dotfiles

🎜 Clone'em, tweak'em, stick'em in your $HOME 🎝
git clone https://git.kevinlegouguec.net/dotfiles
Log | Files | Refs | README

init.el (34621B)


      1 ;;; -*- lexical-binding: t -*-
      2 
      3 ;;; "Custom"ization & theming.
      4 
      5 ;; Trying to migrate to use-package instead of Custom's serialized
      6 ;; forms.  It's a long-term project; until that's done, start by
      7 ;; setting and loading the `custom-file'.
      8 (setq custom-file (file-name-concat user-emacs-directory "custom.el"))
      9 (load custom-file)
     10 
     11 ;; Compatibility shim for setopt.
     12 (if (fboundp 'setopt)
     13     (defalias 'my/setopt 'setopt)
     14   (defmacro my/setopt (&rest pairs)
     15     `(let ((pairs (quote ,pairs)))
     16        (while pairs
     17          (customize-set-variable (pop pairs) (pop pairs))))))
     18 
     19 ;; Helper for customizing list options.
     20 ;;
     21 ;; None of Emacs's customization tools (Custom, setopt, use-package)
     22 ;; can be told "add this element, take those two away": I need to "set
     23 ;; in stone" an exhaustive list that will make me (1) scratch my head
     24 ;; a few months later when I try to remember which of those items I
     25 ;; deliberately added vs which were part of the default list (2) miss
     26 ;; out on additions to the default list, unless I cautiously audit
     27 ;; every release of every package.
     28 ;;
     29 ;; Examples: erc-modules, git-commit-setup-hook, package-archives.
     30 (defmacro my/setopt-update-list (l to-add &optional to-remove)
     31   `(my/setopt ,l (thread-first
     32                    ,l (seq-union ,to-add) (seq-difference ,to-remove))))
     33 
     34 (load-theme 'eighters t)
     35 
     36 ;;; Key bindings.
     37 
     38 ;; Rebinding C-h, the gateway to the Self-Documenting Editor's
     39 ;; defining feature, proved tricky; this is the most reliable way I
     40 ;; found to consistently get C-h to do what DEL does.
     41 ;;
     42 ;; Likewise, C-M-h is re-bound by some major modes (CC, Python, Perl),
     43 ;; so this is the simplest way I know of to make sure C-M-h sticks as
     44 ;; "backward-kill-word".
     45 ;;
     46 ;; Same story with M-h (mark-paragraph) which gets re-bound by eg
     47 ;; markdown-mode and nxml-mode.
     48 (define-key input-decode-map (kbd "C-h") (kbd "DEL"))
     49 (define-key input-decode-map (kbd "C-M-h") (kbd "M-DEL"))
     50 
     51 (global-set-key (kbd "C-x C-b") 'ibuffer)
     52 
     53 (defun my/find-user-file ()
     54   (interactive)
     55   (let ((default-directory user-emacs-directory))
     56    (call-interactively 'find-file)))
     57 
     58 ;; Hopefully these will be easier to remember than TeX commands:
     59 
     60 (quail-define-package
     61  "my/symbols" "UTF-8" "𝒰" t
     62  "Input arbitrary Unicode symbols with other arbitrary symbols.")
     63 
     64 (pcase-dolist
     65     (`(,key ,translation)
     66      '(;; Punctuation
     67        ("..." ?…)
     68        ("--" ?–) ("---" ?β€”)
     69        ;; Math symbols
     70        ("~~" ?β‰ˆ) ("~~=" ?β‰Š) ("~==" ?β‰…) ("~=" ?≃)
     71        ("==" ?≑) ("^=" ?≙) (":=" ?≔)
     72        ("<=" ?≀) (">=" ?β‰₯)
     73        ("-->" ?β†’) ("-/>" ?↛) ("==>" ?β‡’) ("=/>" ?⇏)
     74        ("<--" ?←) ("</-" ?β†š) ("<==" ?⇐) ("</=" ?⇍)
     75        ("<->" ?↔) ("<=>" ?⇔)
     76        ;; Emojis
     77        ("/!\\" ["⚠️"]) ("\\o/" ?πŸ™Œ) ("\\m/" ?🀘)))
     78   (quail-defrule key translation "my/symbols"))
     79 
     80 (defmacro my/make-input-toggle (input-method)
     81   (let ((fsym (intern (format "my/toggle-input-%s" input-method)))
     82         ;; Unfortunately, by default `help-make-xrefs' does not try to
     83         ;; cross-reference input methods, as `help-xref-mule-regexp'
     84         ;; is nil.  This can be worked around by setting this variable
     85         ;; to `help-xref-mule-regexp-template'.
     86         (doc (format "Toggle `%s' input method." input-method)))
     87     `(defun ,fsym ()
     88        ,doc
     89        (interactive)
     90        ;; `current-input-method' is a string; if INPUT-METHOD is a
     91        ;; symbol, neither eq, eql nor equal would return t.
     92        (if (string= current-input-method ',input-method)
     93            (deactivate-input-method)
     94          (set-input-method ',input-method t)))))
     95 
     96 (defun my/set-tab-width (&optional arg)
     97   (interactive "P")
     98   (let ((new-width (cond (arg (prefix-numeric-value arg))
     99                          ((= tab-width 4) 8)
    100                          (4)))
    101         (old-width tab-width))
    102     ;; TODO: for some reason, set-variable takes effect immediately,
    103     ;; but setq(-local)? do not: I need to move the cursor before tabs
    104     ;; are re-drawn.
    105     (set-variable 'tab-width new-width)
    106     (message "changed from %s to %s" old-width new-width)))
    107 
    108 (defun my/auto-hscroll-toggle ()
    109   (interactive)
    110   ;; Use indices to avoid confusing `if-let*' with nil.
    111   (if-let* ((values '(nil t current-line))
    112             (prev (seq-position values auto-hscroll-mode))
    113             (next (% (1+ prev) (length values))))
    114       (progn
    115         (message (concat
    116                   (propertize (format "%s" (nth prev values)) 'face 'shadow)
    117                   " β†’ "
    118                   (propertize (format "%s" (nth next values)) 'face 'bold)))
    119         (setopt auto-hscroll-mode (nth next values)))
    120     (error "Unknown value for auto-hscroll-mode: %s" auto-hscroll-mode)))
    121 
    122 (defun my/kill (stuff)
    123   (kill-new stuff)
    124   (message "%s" stuff))
    125 
    126 ;; TODO: my/kill-where
    127 ;; * filename
    128 ;;   * absolute, project-relative (w/o project), namespace-relative, base
    129 ;; * function
    130 ;; * line number
    131 ;; * public URL
    132 
    133 ;; TODO: my/kill-cite
    134 ;; * prefix: nil, >, |
    135 ;; * indent
    136 ;; * attribution: see my/kill-where
    137 ;;   * concise: "(manual) Node", "manual(7)"
    138 ;;   * executable: (info "(manual) Node"), "man 7 manual"
    139 ;;   * <https://somewhe.re/manual.html#node>
    140 
    141 (defun my/read (prompt default)
    142   (read-string (format-prompt prompt default) nil nil default))
    143 
    144 (defvar my/run-strip-newline t
    145   "Whether `my/run' will remove a trailing newline from a command's output.")
    146 
    147 (defun my/run (program &rest args)
    148   "Return output from 'PROGRAM [ARGS…]'.
    149 Raise a user error if the command fails.  Heed `my/run-strip-newline'."
    150   (with-temp-buffer
    151     (let* ((status (apply 'call-process program nil t nil args))
    152            (output (buffer-string)))
    153       (if (eq status 0)
    154           (if my/run-strip-newline
    155               (string-remove-suffix "\n" output)
    156             output)
    157         (user-error "%s returned %d:\n%s" program status output)))))
    158 
    159 (defun my/kill-command (program &rest args)
    160   "Send output from PROGRAM to kill-ring.
    161 See `my/run' for details, e.g. status handling and output massaging."
    162   (my/kill (apply 'my/run program args)))
    163 
    164 (defun my/kill-char (c)
    165   (interactive (list (read-char-by-name "Codepoint? " t)))
    166   (my/kill (string c)))
    167 
    168 (defun my/kill-date (date format)
    169   (interactive
    170    (if current-prefix-arg
    171        (list (my/read "Date spec?" "today")
    172              (my/read "Format?" "%F"))
    173      (list "today" "%F")))
    174   (my/kill-command "date" (concat "-d" date) (concat "+" format)))
    175 
    176 (defun my/kill-filename ()
    177   (interactive)
    178   (my/kill (or (buffer-file-name) default-directory)))
    179 
    180 (defun my/kill-pipe-region (command)
    181   (interactive (list (read-shell-command "Shell command on region: ")))
    182   (let ((input (funcall region-extract-function nil)))
    183     (with-temp-buffer
    184       (insert input)
    185       (call-process-region (point-min) (point-max) shell-file-name
    186                            t t nil shell-command-switch command)
    187       (my/kill (buffer-string)))))
    188 
    189 (defun my/kill-shell (command)
    190   "Send output from COMMAND to kill-ring.
    191 Meant for interactive prompting for full commands passed to a shell.
    192 For Lisp use, prefer `my/kill-command', where arguments are passed via a
    193 list and require no escaping."
    194   (interactive (list (read-shell-command "Shell command: ")))
    195   (with-temp-buffer
    196     (call-process-shell-command command nil t)
    197     (my/kill (buffer-string))))
    198 
    199 (defun my/shell-command-help (command)
    200   (interactive
    201    (list (read-shell-command "Show --help for: ")))
    202   (let* ((command--help (concat command " --help"))
    203          (help-buf (get-buffer-create (format "*%s*" command--help))))
    204     (shell-command (concat command--help) help-buf)
    205     (display-buffer help-buf)))
    206 
    207 (defun my/magit-project ()
    208   (interactive)
    209   (require 'project)
    210   (magit-status (project-prompt-project-dir)))
    211 
    212 (defun my/magit-toggle-margin-date ()
    213   (interactive)
    214   (let ((do-message
    215          (lambda (old new)
    216            (message
    217             "%s β‡’ %s"
    218             (propertize old 'face 'shadow)
    219             (propertize new 'face 'bold)))))
    220     (apply do-message (if magit-log-margin-show-committer-date
    221                           '("commit" "author") '("author" "commit")))
    222     (setq magit-log-margin-show-committer-date
    223           (not magit-log-margin-show-committer-date))
    224     (revert-buffer)))
    225 
    226 (defmacro my/define-prefix-command (name doc bindings)
    227   (declare (indent defun))
    228   `(defvar ,name
    229      (let ((map (define-prefix-command ',name)))
    230        (pcase-dolist (`(,key ,fun) ,bindings)
    231          (define-key map key fun))
    232        map)
    233      ,doc))
    234 
    235 (my/define-prefix-command my/buffer-map
    236   "Keymap for buffer manipulation commands."
    237   '(("b" bury-buffer)
    238     ("g" revert-buffer)
    239     ("r" rename-buffer)))
    240 
    241 (my/define-prefix-command my/display-map
    242   "Keymap for display-related commands."
    243   '(("h" my/auto-hscroll-toggle)
    244     ("l" hl-line-mode)
    245     ("n" display-line-numbers-mode)
    246     ("t" toggle-truncate-lines)
    247     ("v" visual-line-mode)))
    248 
    249 (my/define-prefix-command my/editing-map
    250   "Keymap for toggling editing features."
    251   '(("f" auto-fill-mode)))
    252 
    253 (my/define-prefix-command my/find-map
    254   "Keymap for finding things."
    255   '(("u" my/find-user-file)))
    256 
    257 (my/define-prefix-command my/magit-map
    258   "Keymap for Magit commands."
    259   '(("d" my/magit-toggle-margin-date)
    260     ("f" magit-file-dispatch)
    261     ("g" magit-status)
    262     ("p" my/magit-project)
    263     ("x" magit-dispatch)
    264     ("\C-f" magit-find-file)))
    265 
    266 (my/define-prefix-command my/input-map
    267   "Keymap for input methods shortcuts."
    268   `(("e" ,(my/make-input-toggle emoji))
    269     ("t" ,(my/make-input-toggle TeX))
    270     ("u" ,(my/make-input-toggle my/symbols))))
    271 
    272 (my/define-prefix-command my/kill-map
    273   "Keymap for adding things to the kill ring (or system clipboard)."
    274   '(("C" my/kill-char)
    275     ("d" my/kill-date)
    276     ("f" my/kill-filename)
    277     ("W" my/kill-as-html)
    278     ("|" my/kill-pipe-region)
    279     ("!" my/kill-shell)))
    280 
    281 (my/define-prefix-command my/manual-map
    282   "Keymap for reading manuals."
    283   '(("h" my/shell-command-help)
    284     ("i" info-display-manual)
    285     ("m" man)
    286     ("s" shortdoc-display-group)))
    287 
    288 (my/define-prefix-command my/whitespace-map
    289   "Keymap for whitespace-related commands."
    290   '(("c" whitespace-cleanup)
    291     ("m" whitespace-mode)
    292     ("t" my/set-tab-width)))
    293 
    294 ;; C-c [[:alpha:]] is reserved for users - let's make good use of it.
    295 
    296 (global-set-key (kbd "C-c b") 'my/buffer-map)
    297 (global-set-key (kbd "C-c c") 'compile)
    298 (global-set-key (kbd "C-c d") 'my/display-map)
    299 (global-set-key (kbd "C-c e") 'my/editing-map)
    300 (global-set-key (kbd "C-c f") 'my/find-map)
    301 (global-set-key (kbd "C-c g") 'my/magit-map)
    302 (global-set-key (kbd "C-c i") 'my/input-map)
    303 (global-set-key (kbd "C-c k") 'my/kill-map)
    304 (global-set-key (kbd "C-c m") 'my/manual-map)
    305 (global-set-key (kbd "C-c w") 'my/whitespace-map)
    306 
    307 (rg-enable-default-bindings)            ; Uses the C-c s prefix.
    308 
    309 ;; What's life without a little risk?
    310 (setq disabled-command-function nil)
    311 
    312 ;;; Window management.
    313 
    314 ;; Bindings ala Terminator
    315 (when window-system
    316   (global-set-key (kbd "C-S-o") 'split-window-below)
    317   (global-set-key (kbd "C-S-e") 'split-window-right)
    318   (global-set-key (kbd "C-+") 'text-scale-adjust)
    319   (global-set-key (kbd "C--") 'text-scale-adjust)
    320   (global-set-key (kbd "C-0") 'text-scale-adjust)
    321   (global-set-key (kbd "C-S-<up>") 'enlarge-window)
    322   (global-set-key (kbd "C-S-<down>") 'shrink-window)
    323   (global-set-key (kbd "C-S-<right>") 'enlarge-window-horizontally)
    324   (global-set-key (kbd "C-S-<left>") 'shrink-window-horizontally))
    325 
    326 ;;; Lighters.
    327 
    328 ;; So long, Will Mengarini.
    329 (delight 'abbrev-mode nil 'abbrev)
    330 (delight 'auto-fill-function "⏎" t)
    331 (delight 'auto-revert-mode "⟳" 'autorevert)
    332 (delight 'auto-revert-tail-mode "–" 'autorevert)
    333 (delight
    334  'flyspell-mode (propertize "πŸ–‹οΈ" 'face 'flyspell-incorrect) 'flyspell)
    335 (delight 'hi-lock-mode nil 'hi-lock)
    336 (delight 'hs-minor-mode "…" 'hideshow)
    337 (delight 'mml-mode "πŸ“§" 'mml)
    338 (delight 'scroll-lock-mode "πŸ“œ" 'scroll-lock)
    339 (delight 'text-scale-mode
    340          '(:eval (if (>= text-scale-mode-amount 0) "πŸ—š" "πŸ—›"))
    341          'face-remap)
    342 (delight 'visual-line-mode nil t)
    343 (delight 'with-editor-mode "⸎" 'with-editor)
    344 ;; TODO: Narrow (βŒ–, β›Ά)
    345 
    346 (let* ((indicator (alist-get 'compilation-in-progress mode-line-modes))
    347        (old-props (text-properties-at 0 (car indicator)))
    348        (face '(:inverse-video t :inherit compilation-mode-line-run))
    349        (new-props (append `(face ,face) old-props))
    350        (icon "βš™οΈ"))
    351   (setcar indicator (concat (apply #'propertize icon new-props) " ")))
    352 
    353 (setq eglot-menu-string "🦻")
    354 
    355 (with-eval-after-load 'flymake
    356   (let ((indicator (propertize "βš’οΈ" 'face 'flymake-error)))
    357     ;; Prefer customizing the string instead delight'ing, as flymake
    358     ;; slaps a bunch of helpful properties on top of the lighter,
    359     ;; which delight would strip.
    360     (if (boundp 'flymake-mode-line-lighter)
    361         (setq flymake-mode-line-lighter indicator)
    362       (delight 'flymake-mode indicator 'flymake))))
    363 
    364 ;;; Version control.
    365 
    366 (defvar my/git-commit-fill-columns
    367   '((my/emacs-repo-p . 63)))
    368 
    369 (defun my/git-upstreams ()
    370   ;; TODO: memoize, perhaps?
    371   (seq-uniq
    372    (seq-keep
    373     (lambda (remote-desc)
    374       (and (string-match "\\`.*\t\\(.*\\) (fetch)\\'" remote-desc)
    375            (match-string 1 remote-desc)))
    376     (process-lines "git" "remote" "-v"))))
    377 
    378 (cl-defun my/git-commit-maybe-set-fill-column ()
    379   (let ((remotes (my/git-upstreams)))
    380     (pcase-dolist (`(,pred . ,column) my/git-commit-fill-columns)
    381       (when (funcall pred remotes)
    382         (cl-return-from my/git-commit-maybe-set-fill-column
    383           (setq fill-column column))))))
    384 
    385 (defvar my/git-commit-use-changelog
    386   (list 'my/emacs-repo-p))
    387 
    388 (defun my/git-commit-maybe-set-changelog-support ()
    389   (let ((remotes (my/git-upstreams)))
    390     (when (run-hook-with-args-until-success
    391            'my/git-commit-use-changelog remotes)
    392       (git-commit-setup-changelog-support))))
    393 
    394 (defun my/revision-at-point ()
    395   (cond
    396    ((derived-mode-p 'magit-mode)
    397     (magit-branch-or-commit-at-point))
    398    ((derived-mode-p 'vc-git-log-view-mode)
    399     (log-view-current-tag))
    400    ((derived-mode-p 'vc-annotate-mode)
    401     (car (vc-annotate-extract-revision-at-line)))))
    402 
    403 (defun my/describe-revision (rev)
    404   "Format a Git revision in a format suitable for changelogs."
    405   (interactive
    406    (list (my/read "Revision" (my/revision-at-point))))
    407   (my/kill-command
    408    "git" "show" "--no-patch" "--date=short" "--format=%cd \"%s\" (%h)" rev))
    409 
    410 ;;; Major modes configuration.
    411 
    412 (defun my/c-modes-hook ()
    413   (c-set-style "bsd")
    414   (c-set-offset 'arglist-close 0))
    415 
    416 (add-hook 'c-mode-common-hook 'my/c-modes-hook)
    417 
    418 (defun my/calendar-iso-week (year month day)
    419   ;; NIH version of `calendar-intermonth-text''s serving suggestion.
    420   (propertize
    421    (format-time-string "%V" (encode-time (list 0 0 0 day month year)))
    422    'font-lock-face 'eighters-date))
    423 
    424 (defun my/compilation-notify (buffer results)
    425   (require 'notifications)
    426   (notifications-notify
    427    :title (buffer-name buffer)
    428    :body results
    429    :app-icon (if (equal results "finished\n")
    430                  'compilation-success 'compilation-failure)
    431    :timeout 3000))
    432 
    433 (add-to-list 'compilation-finish-functions 'my/compilation-notify)
    434 
    435 (defun my/make-tabless (f)
    436   "Make a function which will run F with `indent-tabs-mode' disabled."
    437   (lambda ()
    438     (:documentation (format "Run `%s' with `indent-tabs-mode' set to nil." f))
    439     (interactive)
    440     (let ((indent-tabs-mode nil))
    441       (call-interactively f))))
    442 
    443 (defun my/makefile-hook ()
    444   ;; I would rather align backslashes with spaces rather than tabs;
    445   ;; however, I would also like indent-tabs-mode to remain non-nil.
    446   (local-set-key (kbd "C-c C-\\") (my/make-tabless 'makefile-backslash-region))
    447   (local-set-key (kbd "M-q") (my/make-tabless 'fill-paragraph)))
    448 
    449 (add-hook 'makefile-mode-hook 'my/makefile-hook)
    450 
    451 (defun my/shell-hook ()
    452   (setq truncate-lines nil)
    453   (setq-local recenter-positions '(top middle bottom)))
    454 
    455 (add-to-list 'ibuffer-saved-filter-groups
    456              '("my/ibuffer-groups"
    457                ("REPL"
    458                 (or (derived-mode . comint-mode)
    459                     (mode . lisp-interaction-mode)))
    460                ("Programming" (derived-mode . prog-mode))
    461                ("Folders" (mode . dired-mode))
    462                ("Messaging"
    463                 (or (mode . erc-mode)
    464                     (mode . message-mode)
    465                     (derived-mode . gnus-mode)))
    466                ("Documentation"
    467                 (or (mode . Info-mode)
    468                     (mode . Man-mode)
    469                     (mode . help-mode)))
    470                ("Version control"
    471                 (or (derived-mode . magit-mode)
    472                     (name . "\\`\\*vc")))))
    473 
    474 (add-hook 'ibuffer-mode-hook
    475           (lambda ()
    476             (ibuffer-switch-to-saved-filter-groups "my/ibuffer-groups")))
    477 
    478 ;;; Development helpers.
    479 (defun my/emacs-repo-p (upstreams)
    480   "Guess whether we are working in the Emacs repository.
    481 UPSTREAMS is a list of fetch URLs."
    482   (member "https://git.savannah.gnu.org/git/emacs.git" upstreams))
    483 
    484 (defun my/emacs-run-testcase ()
    485   (interactive)
    486   (require 'which-func)
    487   (let* ((emacs-root (project-root (project-current)))
    488          (testfile (file-name-sans-extension
    489                     (file-relative-name
    490                      buffer-file-name (file-name-concat
    491                                        emacs-root "test"))))
    492          (cores (num-processors 'all))
    493          (options
    494           `(("SELECTOR"                   . ,(which-function))
    495             ("TEST_BACKTRACE_LINE_LENGTH" . nil)))
    496          (options-list
    497           (seq-map
    498            (lambda (opt) (format "%s=%s" (car opt) (cdr opt)))
    499            options))
    500          (compile-command
    501           (format "make -j%s && make -C test %s %s"
    502                   cores testfile (string-join options-list " "))))
    503     (call-interactively 'project-compile)))
    504 
    505 ;;; Helper functions and miscellaneous settings.
    506 
    507 ;;;; French quick toggle.
    508 (defun my/froggify ()
    509   (ispell-change-dictionary "fr")
    510   (setq-local
    511    colon-double-space nil
    512    sentence-end-double-space nil
    513    fill-nobreak-predicate (cons 'fill-french-nobreak-p fill-nobreak-predicate)))
    514 
    515 (defun my/unfroggify ()
    516   (ispell-change-dictionary "default")
    517   (setq-local
    518    colon-double-space t
    519    sentence-end-double-space t
    520    fill-nobreak-predicate (remq 'fill-french-nobreak-p fill-nobreak-predicate)))
    521 
    522 (define-minor-mode my/frog-mode
    523   "Croak like a froggy."
    524   :init-value nil
    525   :lighter "🐸"
    526   (if my/frog-mode
    527       (my/froggify)
    528     (my/unfroggify)))
    529 
    530 (defalias 'my/croak 'my/frog-mode)
    531 
    532 ;;;; Mailing lists utilities.
    533 (defun my/kill-message-id ()
    534   (interactive)
    535   (my/kill (mail-header-message-id (gnus-summary-article-header))))
    536 
    537 (defun my/describe-message (id url)
    538   (my/kill (format "%s\n%s\n"
    539                    (if (string-prefix-p "<" id)
    540                        id
    541                      (format "<%s>" id))
    542                    url)))
    543 
    544 (defun my/describe-message-id (list id)
    545   "Format references from the Message-ID of a gnu.org list."
    546   (interactive
    547    (list
    548     (read-string "List: ")            ; TODO: default to current list.
    549     (let ((default-id (mail-header-message-id
    550                        (gnus-summary-article-header))))
    551       (my/read "Message-ID" default-id))))
    552   (with-current-buffer
    553       (url-retrieve-synchronously
    554        (concat
    555         ;; For some reason, literal "+" chars cause the search to fail.
    556         ;; Escape them.
    557         "https://lists.gnu.org/archive/cgi-bin/namazu.cgi"
    558         "?query=%2Bmessage-id:"
    559         (replace-regexp-in-string "\\+" "%2B" id)
    560         "&submit=Search!"
    561         "&idxname=" list))
    562     (search-forward-regexp
    563      (rx "<a href=\""
    564          (group "/archive/html/" (literal list) "/"
    565                 (+ (any "0-9-")) "/msg" (+ (any "0-9")) ".html")
    566          "\">"))
    567     (let ((url (concat "https://lists.gnu.org" (match-string 1))))
    568       (my/describe-message id url))))
    569 
    570 (defun my/describe-message-url (url)
    571   "Format references from an article archived on MHonArc."
    572   (interactive
    573    (list
    574     (let ((default (or (thing-at-point 'url)
    575                        (and (derived-mode-p 'eww-mode)
    576                             (shr-url-at-point nil)))))
    577       (read-string (format-prompt "URL" default) nil nil default))))
    578   (with-current-buffer (url-retrieve-synchronously url)
    579     (search-forward-regexp "^<!--X-Message-Id: \\(.+\\) -->$")
    580     (let ((id (xml-substitute-numeric-entities (match-string 1))))
    581       (my/describe-message id url))))
    582 
    583 ;;;; Frame title.
    584 
    585 (defun my/project-root ()
    586   (and-let* ((project (project-current)))
    587     (project-root project)))
    588 
    589 (defun my/project-name ()
    590   (and-let* ((root (my/project-root))
    591              ;; Home is under VC to track dotfile changes.  Not a
    592              ;; "project" I want shown in the UI though.
    593              ((not (file-equal-p root "~"))))
    594     (file-name-nondirectory (directory-file-name root))))
    595 
    596 (defun my/connection-name ()
    597   (let ((method (file-remote-p default-directory 'method)))
    598     (pcase method
    599       ;; No method: nil.
    600       ('nil method)
    601       ;; sudo(edit): just "METHOD".
    602       ((pred (string-match-p "sudo")) method)
    603       ;; Default: "METHOD:HOST".
    604       (_ (format "%s:%s" method (file-remote-p default-directory 'host))))))
    605 
    606 (defun my/frame-title-format ()
    607   (let ((prefix
    608          ;; Messing with match data during redisplay is dangerous
    609          ;; (cf. bug#33697).
    610          (save-match-data
    611            ;; For some reason, calling filename-parsing functions
    612            ;; while TRAMP is busy opens the gates to Infinite
    613            ;; Minibuffer Recursion Hell.  Cautiously side-step that.
    614            (or
    615             (my/connection-name)
    616             (my/project-name)))))
    617     (concat (when prefix (format "[%s] " prefix))
    618             "%b")))
    619 
    620 (setq frame-title-format '(:eval (my/frame-title-format)))
    621 
    622 ;;;; Clipboard interaction.
    623 (defun my/kill-as-html (text markup)
    624   (interactive
    625    (list (buffer-substring (region-beginning) (region-end))
    626          (or (alist-get major-mode '((markdown-mode . "markdown")
    627                                      (org-mode . "org")
    628                                      (rst-mode . "rst")))
    629              (let ((default "plain"))
    630                (read-string (format-prompt "Convert from:" default)
    631                             nil nil default)))))
    632   ;; TODO: make this a transient to easily (un)set pandoc extensions.
    633   (with-temp-buffer
    634     (call-process-region text nil "pandoc" nil t nil
    635                          "--from" markup "--to" "html")
    636     ;; TODO: could `gui-set-selection' help here?  The docstring makes
    637     ;; it sound like passing a value with a 'text/html property set to
    638     ;; the HTML string should work, but empirically it doesn't.
    639     ;; Maybe look into `selection-converter-alist'.
    640     (call-process-region
    641      nil nil "wl-copy" nil 0 nil "--type" "text/html")
    642     (message "Copied:\n\n%s" (buffer-string))))
    643 
    644 (defun my/yank-from-html (html markup)
    645   (interactive
    646    (list
    647     (gui-get-selection 'CLIPBOARD 'text/html)
    648     (or (alist-get major-mode '((markdown-mode . "markdown")
    649                                 (org-mode . "org")
    650                                 (rst-mode . "rst")))
    651         (let ((default "plain"))
    652           (read-string (format-prompt "Convert to:" default)
    653                        nil nil default)))))
    654   ;; TODO: make this a transient to easily (un)set
    655   ;; * extensions
    656   ;; * switches (--wrap)
    657   ;; * filters (remove all attributes)
    658   (let* ((disabled-html-extensions (list
    659                                     "native_divs"
    660                                     "native_spans"
    661                                     ))
    662          (disabled-markup-extensions (list
    663                                       ;; "smart"
    664                                       ))
    665          (html-spec
    666           (funcall 'string-join `("html" ,@disabled-html-extensions) "-"))
    667          (markup-spec
    668           (funcall 'string-join `(,markup ,@disabled-markup-extensions) "-")))
    669     (call-process-region html nil "pandoc" nil t t
    670                          "--wrap=none"
    671                          "--from" html-spec "--to" markup-spec)))
    672 
    673 ;;;; Miscellany.
    674 (setq-default paragraph-start (concat "[ 	]*- \\|" paragraph-start))
    675 
    676 (defun my/screenshot (output)
    677   (interactive
    678    (list
    679     (let ((default (format-time-string "/tmp/Emacs-Screenshot-%F-%T.pdf")))
    680       (read-file-name (format-prompt "Output?" default) nil default))))
    681   (let ((data (x-export-frames))
    682         (buf (find-file output)))
    683     (insert data)
    684     (save-buffer)
    685     (kill-buffer buf)))
    686 
    687 ;; Trying out use-package.
    688 
    689 (use-package use-package
    690   :custom
    691   (use-package-always-defer t))
    692 
    693 (use-package package
    694   :custom
    695   (package-selected-packages
    696    (append '(auctex
    697              debbugs
    698              delight
    699              diff-hl
    700              elisp-benchmarks
    701              forge
    702              gnus-mock
    703              magit
    704              markdown-mode
    705              rg
    706              rust-mode
    707              wgrep)
    708            (and (< emacs-major-version 29)
    709                 '(eglot use-package))
    710            (and (< emacs-major-version 30)
    711                 '(which-key))))
    712   :config
    713   (my/setopt-update-list
    714    package-archives '(("melpa" . "https://melpa.org/packages/"))))
    715 
    716 (use-package emacs
    717   :custom
    718   (auto-hscroll-mode 'current-line)
    719   ;; Disabled until bug#56662 is solved.  Prefer `hl-line-mode'.
    720   ;; (highlight-nonselected-windows t)
    721   )
    722 
    723 (use-package calendar
    724   :custom
    725   (calendar-intermonth-text '(my/calendar-iso-week year month day))
    726   (calendar-today-visible-hook '(calendar-mark-today))
    727   (calendar-week-start-day 1))
    728 
    729 (use-package diff-hl
    730   :custom
    731   (diff-hl-flydiff-mode t)
    732   (global-diff-hl-mode t)
    733 
    734   ;; FIXME: Adding to these hooks _here_ clobbers them, i.e. they end
    735   ;; up containing (a) the diff-hl functions (b) whatever functions
    736   ;; their libraries add dynamically (c) *none* of the functions
    737   ;; included in the defcustom's default value.
    738   ;;
    739   ;; Therefore, set these hooks up in the :config form _for the
    740   ;; libraries that define these hooks_, so that (presumably) the
    741   ;; default values for these hooks are loaded *before* adding the
    742   ;; diff-hl functions.
    743   ;;
    744   ;; :hook
    745   ;; ((dired-mode . diff-hl-dired-mode-unless-remote)
    746   ;;  (magit-pre-refresh . diff-hl-magit-pre-refresh)
    747   ;;  (magit-post-refresh . diff-hl-magit-post-refresh))
    748   )
    749 
    750 (use-package dired
    751   :custom
    752   (dired-kill-when-opening-new-dired-buffer t)
    753   (dired-listing-switches "-al -Fhv --group-directories-first")
    754   :config
    755   (add-hook 'dired-mode-hook 'diff-hl-dired-mode-unless-remote))
    756 
    757 (use-package dired-aux
    758   :custom
    759   (dired-vc-rename-file t))
    760 
    761 (use-package ediff
    762   :custom
    763   (ediff-merge-split-window-function 'split-window-vertically)
    764   (ediff-split-window-function 'split-window-horizontally)
    765   (ediff-window-setup-function 'ediff-setup-windows-plain))
    766 
    767 (use-package eldoc
    768   :delight "πŸ“–")
    769 
    770 (use-package erc
    771   :custom
    772   (erc-log-channels-directory
    773    (concat user-emacs-directory "erc/logs"))
    774   (erc-log-write-after-insert t)
    775   (erc-log-write-after-send t)
    776   (erc-notifications-icon
    777    (concat data-directory "images/icons/hicolor/scalable/apps/emacs.svg"))
    778   (erc-prompt-for-nickserv-password nil)
    779   (erc-prompt-for-password nil)
    780   (erc-use-auth-source-for-nickserv-password t)
    781   (erc-user-full-name 'user-full-name)
    782   ;; Timestamps are a mess.
    783   ;;
    784   ;; The default `left-and-right' tries to keep timestamps flush right
    785   ;; either with hard-spacing or with :align-to; both cause jank when
    786   ;; splitting windows or rescaling faces.  The default `left' does
    787   ;; not do the separate-date-and-time thing.
    788   ;;
    789   ;; It may be possible to define my own function to do the
    790   ;; date-if-changed-then-time-if-changed thing, but that would
    791   ;; require a lot of cargo-culting of erc-stamp.el which, as of
    792   ;; 30.0.50, makes this look more complex than I have patience for:
    793   ;; an obsolete variable (`erc-stamp-prepend-date-stamps-p'), an
    794   ;; internal minor mode (`erc-stamp--date-mode'), lots of text
    795   ;; properties ('field, 'invisible)…
    796   ;;
    797   ;; The options below seem like the least bad compromise, even though
    798   ;; they yield a huge left margin interrupted by continuation lines;
    799   ;; `erc-fill-wrap' _should_ help with those, except it causes
    800   ;; impromptu recentering.  `visual-wrap' could help here.
    801   (erc-insert-timestamp-function 'erc-insert-timestamp-left)
    802   (erc-timestamp-format "[%F %H:%M] ")
    803   :config
    804   (my/setopt-update-list erc-modules '(log notifications stamp track) '(fill))
    805   (my/setopt-update-list erc-track-exclude-types '("JOIN" "PART" "QUIT")))
    806 
    807 (use-package find-dired
    808   :custom
    809   (find-ls-option
    810    ;; The default value (-default-ls) relies on 'find -ls', which
    811    ;; escapes non-ASCII chars.
    812    ;; The suggested alternatives (-default-exec, -default-xargs)
    813    ;; _display_ non-ASCII chars correctly, but ls quotes filenames
    814    ;; that contain spaces: `dired-get-filename' fails to strip them,
    815    ;; so they cannot be visited.
    816    ;; So go for -default-exec, *plus* -N to disable quoting
    817    ;; altogether.  That takes care of all the edge cases I tested
    818    ;; (non-ASCII chars, spaces).
    819    '("-exec ls -Nld {} +" . "-Nld")))
    820 
    821 (use-package forge
    822   ;; Auto-load after Magit, to ensure `f n' works.
    823   :after magit
    824   ;; We have `use-package-always-defer' set, so `:after' does nothing
    825   ;; unless we also set `:demand' (xref GH#572):
    826   :demand t)
    827 
    828 (use-package generic-x
    829   :demand t
    830   :custom
    831   (generic-extras-enable-list
    832    '(etc-fstab-generic-mode
    833      etc-modules-conf-generic-mode
    834      etc-passwd-generic-mode
    835      etc-services-generic-mode
    836      etc-sudoers-generic-mode
    837      hosts-generic-mode
    838      pkginfo-generic-mode
    839      resolve-conf-generic-mode
    840      x-resource-generic-mode)))
    841 
    842 (use-package git-commit
    843   :config
    844   (my/setopt-update-list
    845    git-commit-setup-hook
    846    '(git-commit-turn-on-flyspell
    847      my/git-commit-maybe-set-fill-column
    848      my/git-commit-maybe-set-changelog-support)
    849    '(git-commit-setup-changelog-support)))
    850 
    851 (use-package gnus
    852   :custom
    853   ;; Only set file locations here; let gnus-init-file do the heavy
    854   ;; lifting.
    855   (gnus-home-directory (file-name-concat user-emacs-directory "gnus"))
    856   (gnus-init-file  (file-name-concat user-emacs-directory "gnus" "init.el")))
    857 
    858 (use-package isearch
    859   :delight "πŸ”"
    860   :custom
    861   (isearch-allow-scroll t)
    862   (isearch-lazy-count t)
    863   (search-default-mode 'char-fold-to-regexp))
    864 
    865 (use-package magit
    866   :init
    867   (setq magit-bind-magit-project-status nil)
    868   :custom
    869   (magit-define-global-key-bindings nil)
    870   (magit-diff-refine-hunk t)
    871   (magit-ediff-dwim-show-on-hunks t)
    872   (magit-process-apply-ansi-colors t)
    873   (magit-revision-show-gravatars t)
    874   :config
    875   ;; See `diff-hl' form for rationale.
    876   (add-hook 'magit-pre-refresh-hook 'diff-hl-magit-pre-refresh)
    877   (add-hook 'magit-post-refresh-hook 'diff-hl-magit-post-refresh))
    878 
    879 (use-package magit-blame
    880   :delight "πŸ‘‰")
    881 
    882 (use-package markdown-mode
    883   :custom
    884   (markdown-asymmetric-header t)
    885   (markdown-command "pandoc -s")
    886   (markdown-enable-math t)
    887   (markdown-header-scaling t)
    888   (markdown-indent-on-enter 'indent-and-new-item))
    889 
    890 (use-package message
    891   :custom
    892   (message-confirm-send t))
    893 
    894 ;; TODO: completion-eager-*?
    895 ;; TODO: completion-styles bindings, e.g. completion-ignore-case?
    896 ;; TODO: completion-category-overrides: motivation for multiple behaviors?
    897 ;; TODO: mct?
    898 ;;
    899 ;; Gripes:
    900 ;; - underused keys: C-M-i, C-j
    901 ;; - (minibuffer-)choose-completion ignore completion-no-auto-exit
    902 ;;   when the candidate is a directory: the candidate is inserted in
    903 ;;   the minibuffer and the user does *not* exit the minibuffer.
    904 ;;
    905 ;; In minibuffer:
    906 ;; - TAB            complete, or show/update completions
    907 ;; - TABΒ²           jump to completions
    908 ;; - C-M-n, C-M-p   highlight candidate (without changing minibuffer)
    909 ;; - RET, C-j       accept minibuffer input
    910 ;; - M-RET          accept highlighted candidate
    911 ;; - C-u M-RET      insert highlighted candidate (without accepting)
    912 ;;
    913 ;; In completions:
    914 ;; - n, TAB, p      highlight candidate (without changing minibuffer)
    915 ;; - RET            accept highlighted candidate
    916 ;; - C-u RET        insert highlighted candidate in minibuffer (without accepting)
    917 ;; - C-g, q         back to minibuffer
    918 (use-package minibuffer
    919   :config
    920   (setq completion-ignore-case t)
    921   ;; TODO: tuck all "compat keys" in one spot; as a minor mode?
    922   ;;       (i.e. alternatives to <left|right|up|down> for
    923   ;;       minibuffer-*-completion, *-buffer, *window*-map)
    924   (define-key completion-in-region-mode-map (kbd "C-M-n") 'minibuffer-next-completion)
    925   (define-key completion-in-region-mode-map (kbd "C-M-p") 'minibuffer-previous-completion)
    926   (define-key minibuffer-mode-map (kbd "C-M-n") 'minibuffer-next-completion)
    927   (define-key minibuffer-mode-map (kbd "C-M-p") 'minibuffer-previous-completion)
    928   :custom
    929   (completion-auto-help 'visible)
    930   (completion-auto-select 'second-tab)
    931   (completion-pcm-leading-wildcard t)
    932   (completion-show-help nil)
    933   (completions-detailed t)
    934   (completions-format 'one-column)
    935   (completions-group t)
    936   (completions-max-height 10)
    937   (minibuffer-completion-auto-choose nil)
    938   (read-buffer-completion-ignore-case t)
    939   (read-file-name-completion-ignore-case t))
    940 
    941 (use-package org
    942   :config
    943   (when (version< org-version "9.4")
    944     (define-key org-mode-map (kbd "C-j") 'org-return)
    945     (define-key org-mode-map (kbd "RET") 'org-return-indent))
    946   :custom
    947   (org-edit-src-content-indentation 0)
    948   (org-ellipsis "…")
    949   (org-fontify-done-headline nil)
    950   (org-fontify-quote-and-verse-blocks t)
    951   (org-goto-interface 'outline-path-completion)
    952   (org-startup-indented t)
    953   (org-use-extra-keys t)
    954   (org-use-speed-commands t)
    955   ;; Make org-refile a bit more eager.
    956   (org-outline-path-complete-in-steps nil)
    957   (org-refile-targets '((nil . (:maxlevel . 10))))
    958   (org-refile-use-outline-path t))
    959 
    960 (use-package org-indent
    961   :delight "Β»")
    962 
    963 (use-package paren
    964   :custom
    965   (show-paren-mode t)
    966   (show-paren-predicate t))
    967 
    968 (use-package python
    969   :custom
    970   (python-fill-docstring-style 'pep-257-nn)
    971   (python-forward-sexp-function nil)
    972   (python-indent-def-block-scale 1))
    973 
    974 (use-package shell
    975   :config
    976   (setq shell-font-lock-keywords nil)
    977   (add-hook 'shell-mode-hook 'my/shell-hook))
    978 
    979 (use-package shr
    980   :custom
    981   ;; Prefer visual-line-mode, which refills text automatically when
    982   ;; the window width changes.
    983   (shr-fill-text nil))
    984 
    985 (use-package which-key
    986   :custom
    987   (which-key-dont-use-unicode nil)
    988   (which-key-idle-delay 0.5)
    989   (which-key-mode t)
    990   :delight)
    991 
    992 (use-package whitespace
    993   :config
    994   (my/setopt-update-list whitespace-style nil '(lines missing-newline-at-eof))
    995   :delight
    996   ;; FIXME: without :demand t, enabling whitespace-mode in a diff
    997   ;; buffer first causes diff-mode's settings to be applied globally.
    998   :demand t)
    999 
   1000 ;;; TODO:
   1001 ;; * decruftify mode-line (e.g. remove superflous parens).
   1002 ;; * teach some modes to give better names to their buffers to reduce
   1003 ;;   clobbering: info, occur