mirror of
https://github.com/SqrtMinusOne/dotfiles.git
synced 2025-12-10 19:23:03 +03:00
13846 lines
499 KiB
Org Mode
13846 lines
499 KiB
Org Mode
#+PROPERTY: header-args :mkdirp yes
|
||
#+PROPERTY: header-args:bash :tangle-mode (identity #o755) :comments link :shebang "#!/usr/bin/env bash"
|
||
#+PROPERTY: header-args:emacs-lisp :tangle ~/.emacs.d/init.el :mkdirp yes :eval never-export :exports both
|
||
#+TODO: CHECK(s) | OFF(o)
|
||
#+TITLE: Emacs config
|
||
#+OPTIONS: broken-links:auto h:6 toc:nil
|
||
|
||
#+begin_quote
|
||
One day we won't hate one another, no young boy will march to war and I will clean up my Emacs config. But that day isn't today.
|
||
#+end_quote
|
||
- Me, <2021-05-27 Thu 17:35> in commit 93a0573. Adapted from [[https://www.youtube.com/watch?v=pIdBinlW40E][The Dark Element - "The Pallbearer Walks Alone"]]. T_T
|
||
|
||
* Introduction
|
||
My configuration of [[https://www.gnu.org/software/emacs/][GNU Emacs]], an awesome +text editor+ piece of software that can do almost anything.
|
||
|
||
At the moment of writing this, that "almost anything" includes:
|
||
- *Programming environment*. With LSP & Co, Emacs is as good as many IDEs and is certainly on par with editors like VS Code.\\
|
||
Emacs is also particularly great at writing Lisp code, e.g. Clojure, Common Lisp, and of course, Emacs Lisp.
|
||
- *Org Mode* is useful for a lot of things. My use cases include:
|
||
- *[[https://leanpub.com/lit-config/read][Literate configuration]]*
|
||
- *Interactive programming* à la Jupyter Notebook
|
||
- *Task / project management*
|
||
- *Formatting documents*. I've written my Master's Thesis in Org Mode.
|
||
- *Notetaking*, mostly with org-roam and org-journal
|
||
- *File management*. Dired is my primary file manager.
|
||
- *Email*, with notmuch.
|
||
- *Multimedia management*, with EMMS.
|
||
- *RSS feed reader*, with elfeed.
|
||
- *Managing passwords*, with pass.
|
||
- *Messengers*:
|
||
- *IRC*, with ERC.
|
||
- *Telegram*, with telega.el
|
||
- *X Window management*, with EXWM. I literally live in Emacs.
|
||
- ...
|
||
|
||
As I mentioned above, this document is a piece of literate configuration, i.e. program code interwoven with (occasionally semi-broken) English-language commentary.
|
||
|
||
I find that approach helpful for maintaining the configuration, but the quality and quantity of comments may vary. I also usually incorporate my Emacs-related blog posts back into this config.
|
||
|
||
So, you might extract something of value from here if you're an avid Emacs user, but probably not if you're a newcomer to the Elisp wonderland. If the latter applies to you, I'd advise checking out David Wilson's [[https://www.youtube.com/c/SystemCrafters][System Crafters]] YouTube channel.
|
||
|
||
* Some remarks
|
||
I decided not to keep configs for features that I do not use anymore because this config is already huge. But here are the last commits that had these features presented.
|
||
|
||
| Feature | Last commit |
|
||
|--------------------------+------------------------------------------|
|
||
| ytel | 327340a95c4ff9cffd171f6bd937c6041f63add7 |
|
||
| org-roam dailies | d2648918fcc338bd5c1cd6d5c0aa60a65077ccf7 |
|
||
| org-roam projects | 025278a1e180e86f3aade20242e4ac1cdc1a2f13 |
|
||
| treemacs | 3d87852745caacc0863c747f1fa9871d367240d2 |
|
||
| tab-bar.el | 19ff54db9fe21fd5bdf404a8d2612176baa8a6f5 |
|
||
| spaceline | 19ff54db9fe21fd5bdf404a8d2612176baa8a6f5 |
|
||
| code compass | 8594d6f53e42c70bbf903e168607841854818a38 |
|
||
| vue-mode | 8594d6f53e42c70bbf903e168607841854818a38 |
|
||
| svelte-mode | 8594d6f53e42c70bbf903e168607841854818a38 |
|
||
| pomidor | 8594d6f53e42c70bbf903e168607841854818a38 |
|
||
| elfeed-score | 8e591e0d2afd909ae5be00caf17f9b17c6cd8b61 |
|
||
| org-trello | 3f5967a5f63928ea9c8567d8d9f31e84cdbbc21f |
|
||
| jabber | 9b0e73a4703ff35a2d30fd704200052888191217 |
|
||
| wallabag | 9b0e73a4703ff35a2d30fd704200052888191217 |
|
||
| conda | 609fc84e439b11ea5064f3a948079daebb654aca |
|
||
| notmuch tags keybindings | eac134c5456051171c1c777254f503cc71ce12cd |
|
||
| expand-region | ab0d01c525f2b44dd64ec09747daf0fced4bd9c7 |
|
||
| org-latex-impatient | ab0d01c525f2b44dd64ec09747daf0fced4bd9c7 |
|
||
| dired-single | ab0d01c525f2b44dd64ec09747daf0fced4bd9c7 |
|
||
| progidy | ab0d01c525f2b44dd64ec09747daf0fced4bd9c7 |
|
||
| tree-sitter | 1920a48aec49837d63fa88ca315928dc4e9d14c2 |
|
||
| org-roam-protocol | 2f0c20eb01b8899d00d129cc7ca5c6b263c69c65 |
|
||
| eshell-info-banner | 4ccc0bbc412b68e1401533264d801d86b1fc8cc7 |
|
||
| aweshell | 4ccc0bbc412b68e1401533264d801d86b1fc8cc7 |
|
||
| link tasks to meetings | 23496bfacc31ffedf2092da04e4e602b71373425 |
|
||
| forge and code-review | bb1365d9a026cefb7051992a68176ea00f041abd |
|
||
| phscroll | bb1365d9a026cefb7051992a68176ea00f041abd |
|
||
| org-make-toc | bb1365d9a026cefb7051992a68176ea00f041abd |
|
||
| edraw-org | bb1365d9a026cefb7051992a68176ea00f041abd |
|
||
| sx.el | bb1365d9a026cefb7051992a68176ea00f041abd |
|
||
| atomic-chrome | bb1365d9a026cefb7051992a68176ea00f041abd |
|
||
| screenshot.el | bb1365d9a026cefb7051992a68176ea00f041abd |
|
||
|
||
* Initial setup
|
||
Setting up the environment, performance tuning and a few basic settings.
|
||
|
||
First things first, lexical binding.
|
||
#+begin_src emacs-lisp
|
||
;;; -*- lexical-binding: t -*-
|
||
#+end_src
|
||
|
||
** Packages
|
||
*** straight.el
|
||
Straight.el is my Emacs package manager of choice. Its advantages & disadvantages over other options are listed pretty thoroughly in the README file in the repo.
|
||
|
||
The following is the bootstrap script of =straight.el=.
|
||
|
||
References:
|
||
- [[https://github.com/raxod502/straight.el][straight.el repo]]
|
||
|
||
#+begin_src emacs-lisp :noweb-ref minimal
|
||
(defvar bootstrap-version)
|
||
(let ((bootstrap-file
|
||
(expand-file-name "straight/repos/straight.el/bootstrap.el" user-emacs-directory))
|
||
(bootstrap-version 5))
|
||
(unless (file-exists-p bootstrap-file)
|
||
(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)))
|
||
(load bootstrap-file nil 'nomessage))
|
||
#+end_src
|
||
*** use-package
|
||
A macro to simplify package specification & configuration. Integrates with straight.el.
|
||
|
||
Set ~use-package-verbose~ to ~t~ to print out loading times for individual packages.
|
||
|
||
References:
|
||
- [[https://github.com/jwiegley/use-package][use-package repo]]
|
||
|
||
#+begin_src emacs-lisp :noweb-ref minimal
|
||
(straight-use-package 'use-package)
|
||
(eval-when-compile (require 'use-package))
|
||
#+end_src
|
||
** Modular configuration & environments
|
||
I've made the config sort of modular because I need different parts in different environments. With such a large config as mine, this is important.
|
||
|
||
The problem with one large =init.el= is it's evaluated fully in every envronment, which means =use-package= will try to download, compile, etc. every package, which takes a lot of time on termux.
|
||
|
||
So this isn't about separation of concerns or something like that. Modules aren't independent; they're loaded in the same order as they are specified in this file; further modules may depend on previously loaded modules.
|
||
|
||
Also, the modules are basically as large or small as they need to be required on each system.
|
||
|
||
Obviously, there are a lot of compromises in this approach, but it works for me. For a more careful approach to modules, take a look, e.g., at [[https://protesilaos.com/emacs/dotemacs][Protesilaos']] config.
|
||
|
||
First, I need to get the current environment:
|
||
#+begin_src emacs-lisp
|
||
(defun my/get-env ()
|
||
(or (getenv "EMACS_ENV")
|
||
(when (member (system-name) '("dev-digital" "viridian"))
|
||
"remote")
|
||
(when (string-match-p (rx (* nonl) "com.termux" (* nonl)) (getenv "HOME"))
|
||
"termux")
|
||
"normal"))
|
||
|
||
(setq my/env (my/get-env))
|
||
#+end_src
|
||
|
||
Also, I sometimes need to know if a program is running inside Emacs (say, inside a terminal emulator). And sometimes I need to know if I'm running a nested Emacs session. To do that, I set the following environment variable:
|
||
#+begin_src emacs-lisp
|
||
(setq my/nested-emacs (and (getenv "IS_EMACS") t))
|
||
(setenv "IS_EMACS" "true")
|
||
#+end_src
|
||
|
||
Custom system name logic because on termux it's always "localhost".
|
||
#+begin_src emacs-lisp
|
||
(defun my/system-name ()
|
||
(or (getenv "ANDROID_NAME")
|
||
(system-name)))
|
||
#+end_src
|
||
|
||
And a few legacy variables, which is true on termux.
|
||
#+begin_src emacs-lisp
|
||
(setq my/is-termux (equal (my/get-env) "termux"))
|
||
(setq my/remote-server (equal (my/get-env) "remote"))
|
||
#+end_src
|
||
|
||
Below is the configuration table that shows which module is activated in which environment.
|
||
#+NAME: modules-settings
|
||
| Module name | normal | termux | remote |
|
||
|-------------------+--------+--------+--------|
|
||
| performance | + | + | + |
|
||
| micromamba | + | | |
|
||
| misc-initial | + | + | + |
|
||
| keybindings | + | + | + |
|
||
| termux | | + | |
|
||
| i3 | + | | |
|
||
| general-config | + | + | + |
|
||
| wakatime | + | + | |
|
||
| activitywatch | + | | |
|
||
| general-ui | + | + | + |
|
||
| doom-modeline | + | + | + |
|
||
| perspective | + | + | + |
|
||
| treemacs | + | + | |
|
||
| lsp | + | | |
|
||
| flycheck | + | + | + |
|
||
| tree-sitter | + | + | + |
|
||
| dap | | | |
|
||
| reformatter | + | | |
|
||
| copilot | | | |
|
||
| web | + | | |
|
||
| latex | + | + | |
|
||
| markup | + | + | + |
|
||
| lisp | + | + | + |
|
||
| misc-programming | + | | |
|
||
| general-org | + | + | |
|
||
| org-literate | + | | |
|
||
| org-productivity | + | + | |
|
||
| org-export | + | | |
|
||
| dired | + | + | + |
|
||
| tramp | + | | |
|
||
| terms | + | + | + |
|
||
| dotfiles | + | + | + |
|
||
| elfeed | + | | |
|
||
| mail | + | | |
|
||
| gnus | + | | |
|
||
| emms | + | | |
|
||
| misc-internet | + | + | + |
|
||
| erc | + | | |
|
||
| mastodon | + | | |
|
||
| wallabag | | | |
|
||
| ement | + | | |
|
||
| telega | + | + | |
|
||
| docs | + | + | |
|
||
| ai | + | | |
|
||
| index | + | + | |
|
||
| pass | + | + | |
|
||
| docker | + | | |
|
||
| misc-apps | + | + | |
|
||
| guix | + | | |
|
||
| emacs-pinentry | | + | |
|
||
| productivity-apps | + | + | |
|
||
| chess | + | | |
|
||
| discord | | | |
|
||
| gource | + | | |
|
||
|
||
I want this table to be updated automatically based on this file.
|
||
|
||
So, first, specify how the modules are stored:
|
||
#+begin_src emacs-lisp
|
||
(setq my/modules-dir (file-name-as-directory
|
||
(expand-file-name "modules" user-emacs-directory)))
|
||
|
||
(setq my/modules-prefix "sqrt-")
|
||
#+end_src
|
||
|
||
Then, scan =Emacs.org= for headings with the =MODULE_NAME= property and set the tangle property:
|
||
#+begin_src emacs-lisp
|
||
(defun my/modules--refresh-and-list ()
|
||
(let (modules-list
|
||
headlines)
|
||
(org-element-map (org-element-parse-buffer) 'headline
|
||
(lambda (elem) (push elem headlines)))
|
||
(mapc
|
||
(lambda (elem)
|
||
(when-let ((module-name (org-element-property :MODULE_NAME elem)))
|
||
(save-excursion
|
||
(goto-char (org-element-property :begin elem))
|
||
(org-set-property
|
||
"header-args:emacs-lisp"
|
||
(concat ":tangle " my/modules-dir my/modules-prefix module-name ".el"
|
||
" :comments links"))
|
||
(push module-name modules-list))))
|
||
headlines)
|
||
(seq-uniq modules-list)))
|
||
#+end_src
|
||
|
||
Then, read and update the settings table:
|
||
#+begin_src emacs-lisp
|
||
(defun my/modules--read-table ()
|
||
(save-excursion
|
||
(goto-char (point-min))
|
||
(re-search-forward (rx bol "#+NAME: modules-settings"))
|
||
(forward-line)
|
||
(let* ((table (org-element-at-point))
|
||
(table-contents
|
||
(mapcar
|
||
(lambda (elem)
|
||
(if (listp elem)
|
||
(mapcar #'substring-no-properties elem)
|
||
elem))
|
||
(org-table-to-lisp)))
|
||
(envs-list (cdar table-contents)))
|
||
(mapcar
|
||
(lambda (elem)
|
||
(cons (car elem)
|
||
(cl-loop for sign in (cdr elem)
|
||
for env in envs-list
|
||
collect (cons env (not (string-empty-p sign))))))
|
||
(cddr table-contents)))))
|
||
|
||
(defun my/modules--refresh-table (modules-list)
|
||
(save-excursion
|
||
(goto-char (point-min))
|
||
(search-forward "#+NAME: modules-settings")
|
||
(let* ((table (org-element-at-point))
|
||
(table-contents
|
||
(my/modules--read-table))
|
||
(envs-list (mapcar #'car (cdar table-contents))))
|
||
(delete-region (org-element-property :contents-begin table)
|
||
(org-element-property :contents-end table))
|
||
(insert
|
||
"\n"
|
||
(string-trim
|
||
(with-temp-buffer
|
||
(insert "| Module name |"
|
||
(mapconcat #'identity envs-list " | ")
|
||
"\n")
|
||
(insert "|--|\n")
|
||
(dolist (module modules-list)
|
||
(insert "| " module " | "
|
||
(cl-loop
|
||
for env in envs-list
|
||
if (alist-get
|
||
env
|
||
(alist-get module table-contents nil nil #'equal)
|
||
nil nil #'equal)
|
||
concat " + |"
|
||
else
|
||
concat " |")
|
||
"\n"))
|
||
(goto-char (point-min))
|
||
(org-table-align)
|
||
(buffer-substring-no-properties (point-min) (point-max))))))))
|
||
#+end_src
|
||
|
||
Then, update the loading code that requires the necessary modules based on the environment:
|
||
#+begin_src emacs-lisp
|
||
(defun my/modules--refresh-loading ()
|
||
(let* ((modules-table (my/modules--read-table))
|
||
(envs-list (mapcar #'car (cdar modules-table))))
|
||
(save-excursion
|
||
(goto-char (point-min))
|
||
(re-search-forward (rx bol "#+NAME: modules-loading"))
|
||
(beginning-of-line 3)
|
||
(delete-region
|
||
(point)
|
||
(save-excursion
|
||
(re-search-forward (rx bol "#+end_src"))
|
||
(end-of-line 0)
|
||
(point)))
|
||
(insert
|
||
(pp-to-string
|
||
`(pcase my/env
|
||
,@(mapcar
|
||
(lambda (env)
|
||
`(,env
|
||
,@(seq-filter
|
||
#'identity
|
||
(mapcar
|
||
(lambda (elem)
|
||
(when (alist-get env (cdr elem) nil nil #'equal)
|
||
`(require ',(intern (concat my/modules-prefix (car elem))))))
|
||
modules-table))))
|
||
envs-list)))))))
|
||
#+end_src
|
||
|
||
Also delete unnecessary modules:
|
||
#+begin_src emacs-lisp
|
||
(defun my/modules--cleanup-folder (modules-list)
|
||
(let ((source-files (seq-filter
|
||
(lambda (f)
|
||
(equal (file-name-extension f) "el"))
|
||
(directory-files my/modules-dir)))
|
||
(target-files
|
||
(mapcar (lambda (m)
|
||
(concat my/modules-prefix m ".el"))
|
||
modules-list)))
|
||
(dolist (extra-file (seq-difference source-files target-files))
|
||
(delete-file (concat my/modules-dir extra-file)))))
|
||
#+end_src
|
||
|
||
And a function that combines all of the above:
|
||
#+begin_src emacs-lisp
|
||
(defun my/modules-settings-refresh ()
|
||
(interactive)
|
||
(org-fold-show-all)
|
||
(let ((modules-list (my/modules--refresh-and-list)))
|
||
(my/modules--refresh-table modules-list)
|
||
(my/modules--refresh-loading)
|
||
(my/modules--cleanup-folder modules-list)
|
||
(setq my/modules-list modules-list)))
|
||
#+end_src
|
||
|
||
Now, add the modules directory to the =load-path= variable:
|
||
#+begin_src emacs-lisp
|
||
(push my/modules-dir load-path)
|
||
#+end_src
|
||
|
||
And require the necessary modules with the autogenerated code:
|
||
#+NAME: modules-loading
|
||
#+begin_src emacs-lisp
|
||
(pcase my/env
|
||
("normal"
|
||
(require 'sqrt-performance)
|
||
(require 'sqrt-micromamba)
|
||
(require 'sqrt-misc-initial)
|
||
(require 'sqrt-keybindings)
|
||
(require 'sqrt-i3)
|
||
(require 'sqrt-general-config)
|
||
(require 'sqrt-wakatime)
|
||
(require 'sqrt-activitywatch)
|
||
(require 'sqrt-general-ui)
|
||
(require 'sqrt-doom-modeline)
|
||
(require 'sqrt-perspective)
|
||
(require 'sqrt-treemacs)
|
||
(require 'sqrt-lsp)
|
||
(require 'sqrt-flycheck)
|
||
(require 'sqrt-tree-sitter)
|
||
(require 'sqrt-reformatter)
|
||
(require 'sqrt-web)
|
||
(require 'sqrt-latex)
|
||
(require 'sqrt-markup)
|
||
(require 'sqrt-lisp)
|
||
(require 'sqrt-misc-programming)
|
||
(require 'sqrt-general-org)
|
||
(require 'sqrt-org-literate)
|
||
(require 'sqrt-org-productivity)
|
||
(require 'sqrt-org-export)
|
||
(require 'sqrt-dired)
|
||
(require 'sqrt-tramp)
|
||
(require 'sqrt-terms)
|
||
(require 'sqrt-dotfiles)
|
||
(require 'sqrt-elfeed)
|
||
(require 'sqrt-mail)
|
||
(require 'sqrt-gnus)
|
||
(require 'sqrt-emms)
|
||
(require 'sqrt-misc-internet)
|
||
(require 'sqrt-erc)
|
||
(require 'sqrt-mastodon)
|
||
(require 'sqrt-ement)
|
||
(require 'sqrt-telega)
|
||
(require 'sqrt-docs)
|
||
(require 'sqrt-ai)
|
||
(require 'sqrt-index)
|
||
(require 'sqrt-pass)
|
||
(require 'sqrt-docker)
|
||
(require 'sqrt-misc-apps)
|
||
(require 'sqrt-guix)
|
||
(require 'sqrt-productivity-apps)
|
||
(require 'sqrt-chess)
|
||
(require 'sqrt-gource))
|
||
("termux"
|
||
(require 'sqrt-performance)
|
||
(require 'sqrt-misc-initial)
|
||
(require 'sqrt-keybindings)
|
||
(require 'sqrt-termux)
|
||
(require 'sqrt-general-config)
|
||
(require 'sqrt-wakatime)
|
||
(require 'sqrt-general-ui)
|
||
(require 'sqrt-doom-modeline)
|
||
(require 'sqrt-perspective)
|
||
(require 'sqrt-treemacs)
|
||
(require 'sqrt-flycheck)
|
||
(require 'sqrt-tree-sitter)
|
||
(require 'sqrt-latex)
|
||
(require 'sqrt-markup)
|
||
(require 'sqrt-lisp)
|
||
(require 'sqrt-general-org)
|
||
(require 'sqrt-org-productivity)
|
||
(require 'sqrt-dired)
|
||
(require 'sqrt-terms)
|
||
(require 'sqrt-dotfiles)
|
||
(require 'sqrt-misc-internet)
|
||
(require 'sqrt-telega)
|
||
(require 'sqrt-docs)
|
||
(require 'sqrt-index)
|
||
(require 'sqrt-pass)
|
||
(require 'sqrt-misc-apps)
|
||
(require 'sqrt-emacs-pinentry)
|
||
(require 'sqrt-productivity-apps))
|
||
("remote"
|
||
(require 'sqrt-performance)
|
||
(require 'sqrt-misc-initial)
|
||
(require 'sqrt-keybindings)
|
||
(require 'sqrt-general-config)
|
||
(require 'sqrt-general-ui)
|
||
(require 'sqrt-doom-modeline)
|
||
(require 'sqrt-perspective)
|
||
(require 'sqrt-flycheck)
|
||
(require 'sqrt-tree-sitter)
|
||
(require 'sqrt-markup)
|
||
(require 'sqrt-lisp)
|
||
(require 'sqrt-dired)
|
||
(require 'sqrt-terms)
|
||
(require 'sqrt-dotfiles)
|
||
(require 'sqrt-misc-internet)))
|
||
|
||
#+end_src
|
||
|
||
Finally, some post-processing of the tangled files:
|
||
#+begin_src emacs-lisp
|
||
(defun my/modules--post-tangle ()
|
||
(when (string-match-p (rx bos (literal my/modules-dir)
|
||
(* nonl) ".el")
|
||
(buffer-file-name))
|
||
(goto-char (point-min))
|
||
(insert ";;; -*- lexical-binding: t -*-\n")
|
||
(goto-char (point-max))
|
||
(insert "\n(provide '" (file-name-base
|
||
(buffer-file-name)) ")")
|
||
(save-buffer)
|
||
(message "Processed %s as emacs config module" (buffer-file-name))))
|
||
|
||
(add-hook 'org-babel-post-tangle-hook #'my/modules--post-tangle)
|
||
#+end_src
|
||
|
||
** Performance
|
||
:PROPERTIES:
|
||
:MODULE_NAME: performance
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-performance.el :comments links
|
||
:END:
|
||
*** Measure startup speed
|
||
A small function to print out the loading time and number of GCs during the loading. Can be useful as a point of data for optimizing Emacs startup time.
|
||
#+begin_src emacs-lisp
|
||
(setq my/emacs-started nil)
|
||
|
||
(add-hook 'emacs-startup-hook
|
||
(lambda ()
|
||
(message "*** Emacs loaded in %s with %d garbage collections."
|
||
(format "%.2f seconds"
|
||
(float-time
|
||
(time-subtract after-init-time before-init-time)))
|
||
gcs-done)
|
||
(setq my/emacs-started t)))
|
||
#+end_src
|
||
|
||
Set the following to =t= to print debug information during the startup. This will include package loading order and time.
|
||
#+begin_src emacs-lisp
|
||
(setq use-package-verbose nil)
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq use-package-compute-statistics t)
|
||
#+end_src
|
||
|
||
*** Garbage collection
|
||
Just setting ~gc-cons-treshold~ to a larger value.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq gc-cons-threshold 80000000)
|
||
(setq read-process-output-max (* 1024 1024))
|
||
#+end_src
|
||
*** Run garbage collection when Emacs is unfocused
|
||
Run GC when Emacs loses focus. +Time will tell if that's a good idea.+
|
||
|
||
I still don't know if there is any quantifiable advantage to this, but it doesn't hurt.
|
||
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'emacs-startup-hook
|
||
(lambda ()
|
||
(if (boundp 'after-focus-change-function)
|
||
(add-function :after after-focus-change-function
|
||
(lambda ()
|
||
(unless (frame-focus-state)
|
||
(garbage-collect))))
|
||
(add-hook 'after-focus-change-function 'garbage-collect))))
|
||
#+end_src
|
||
*** Measure RAM usage
|
||
I've noticed that Emacs occasionally eats a lot of RAM, especially when used with EXWM. This is my attempt to measure RAM usage.
|
||
|
||
I have some concerns that =ps -o rss= may be unrepresentative because of [[https://stackoverflow.com/questions/131303/how-can-i-measure-the-actual-memory-usage-of-an-application-or-process][shared memory]], but I guess this shouldn't be a problem here because there's only one process of Emacs.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/get-ram-usage-async (callback)
|
||
(let* ((temp-buffer (generate-new-buffer "*ps*"))
|
||
(proc (start-process "ps" temp-buffer "ps"
|
||
"-p" (number-to-string (emacs-pid)) "-o" "rss")))
|
||
(set-process-sentinel
|
||
proc
|
||
(lambda (process _msg)
|
||
(when (eq (process-status process) 'exit)
|
||
(let* ((output (with-current-buffer temp-buffer
|
||
(buffer-string)))
|
||
(usage (string-to-number (nth 1 (split-string output "\n")))))
|
||
(ignore-errors
|
||
(funcall callback usage)))
|
||
(kill-buffer temp-buffer))))))
|
||
|
||
(defun my/ram-usage ()
|
||
(interactive)
|
||
(my/get-ram-usage-async
|
||
(lambda (data)
|
||
(message "%f Gb" (/ (float data) 1024 1024)))))
|
||
#+end_src
|
||
** Micromamba
|
||
:PROPERTIES:
|
||
:MODULE_NAME: micromamba
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-micromamba.el :comments links
|
||
:END:
|
||
[[https://github.com/mamba-org/mamba][mamba]] is a faster alternative to [[https://www.anaconda.com/][Anaconda]], a package and environment manager. =micromamba= is a tiny version that provides a subset of mamba commands.
|
||
|
||
[[https://github.com/SqrtMinusOne/micromamba.el][micromamba.el]] is my package to interact with the latter.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package micromamba
|
||
:straight t
|
||
:if (executable-find "micromamba")
|
||
:config
|
||
(ignore-errors
|
||
(micromamba-activate "general")))
|
||
#+end_src
|
||
** Config files
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-initial
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-initial.el :comments links
|
||
:END:
|
||
*** Custom file location
|
||
By default, =custom= writes stuff to =init.el=, which is somewhat annoying. The following makes it write to a separate file =custom.el=
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq custom-file (concat user-emacs-directory "custom.el"))
|
||
(load custom-file 'noerror)
|
||
#+end_src
|
||
*** authinfo
|
||
Use only the gpg-encrypted version of the file.
|
||
#+begin_src emacs-lisp
|
||
(setq auth-source-debug nil)
|
||
(setq auth-sources '("~/.authinfo.gpg"))
|
||
#+end_src
|
||
*** Private config
|
||
I have some variables which I don't commit to the repo, e.g. my current location. They are stored in =private.el=
|
||
|
||
#+begin_src emacs-lisp
|
||
(let ((private-file (expand-file-name "private.el" user-emacs-directory)))
|
||
(when (file-exists-p private-file)
|
||
(load-file private-file)))
|
||
#+end_src
|
||
*** No littering
|
||
By default Emacs and its packages create a lot files in =.emacs.d= and in other places. [[https://github.com/emacscollective/no-littering][no-littering]] is a collective effort to redirect all of that to two folders in =user-emacs-directory=.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package no-littering
|
||
:straight t)
|
||
#+end_src
|
||
** Helper functions
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-initial
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-initial.el :comments links
|
||
:END:
|
||
*** Run command in background
|
||
I think I've copied it from somewhere.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/run-in-background (command)
|
||
(let ((command-parts (split-string command "[ ]+")))
|
||
(apply #'call-process `(,(car command-parts) nil 0 nil ,@(cdr command-parts)))))
|
||
#+end_src
|
||
*** Close buffer and its windows
|
||
#+begin_src emacs-lisp
|
||
(defun my/quit-window-and-buffer ()
|
||
(interactive)
|
||
(quit-window t))
|
||
#+end_src
|
||
|
||
** Scratch buffer
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-initial
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-initial.el :comments links
|
||
:END:
|
||
I have a problem with =emacs-lisp-mode= as =initial-major-mode= because in my config it loads =lispy=, which loads =org-mode=.
|
||
|
||
So until I've made a better loading screen, this will do.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq initial-major-mode 'fundamental-mode)
|
||
(setq initial-scratch-message "Hello there <3\n\n")
|
||
#+end_src
|
||
* General settings
|
||
** Keybindings
|
||
:PROPERTIES:
|
||
:MODULE_NAME: keybindings
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-keybindings.el :comments links
|
||
:END:
|
||
*** general.el
|
||
general.el provides a convenient interface to manage Emacs keybindings.
|
||
|
||
References:
|
||
- [[https://github.com/noctuid/general.el][general.el repo]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package general
|
||
:straight t
|
||
:config
|
||
(general-evil-setup))
|
||
#+end_src
|
||
*** which-key
|
||
A package that displays the available keybindings in a popup. The package is pretty useful, as Emacs seems to have more keybindings than I can remember at any given point.
|
||
|
||
References:
|
||
- [[https://github.com/justbur/emacs-which-key][which-key repo]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package which-key
|
||
:config
|
||
(setq which-key-idle-delay 0.3)
|
||
(setq which-key-popup-type 'frame)
|
||
(which-key-mode)
|
||
(which-key-setup-side-window-bottom)
|
||
(set-face-attribute 'which-key-local-map-description-face nil
|
||
:weight 'bold)
|
||
:straight t)
|
||
#+end_src
|
||
|
||
**** dump keybindings
|
||
A function to dump keybindings starting with a prefix to a buffer in a tree-like form.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/dump-bindings-recursive (prefix &optional level buffer)
|
||
(dolist (key (which-key--get-bindings (kbd prefix)))
|
||
(with-current-buffer buffer
|
||
(when level
|
||
(insert (make-string level ? )))
|
||
(insert (apply #'format "%s%s%s\n" key)))
|
||
(when (string-match-p
|
||
(rx bos "+" (* nonl))
|
||
(substring-no-properties (elt key 2)))
|
||
(my/dump-bindings-recursive
|
||
(concat prefix " " (substring-no-properties (car key)))
|
||
(+ 2 (or level 0))
|
||
buffer))))
|
||
|
||
(defun my/dump-bindings (prefix)
|
||
"Dump keybindings starting with PREFIX in a tree-like form."
|
||
(interactive "sPrefix: ")
|
||
(let ((buffer (get-buffer-create "bindings")))
|
||
(with-current-buffer buffer
|
||
(erase-buffer))
|
||
(my/dump-bindings-recursive prefix 0 buffer)
|
||
(with-current-buffer buffer
|
||
(goto-char (point-min))
|
||
(setq-local buffer-read-only t))
|
||
(switch-to-buffer-other-window buffer)))
|
||
#+end_src
|
||
*** Evil
|
||
An entire ecosystem of packages that emulates the main features of Vim. Probably the best vim emulator out there.
|
||
|
||
The only problem is that the package name makes it hard to google anything by just typing "evil".
|
||
|
||
References:
|
||
- [[https://github.com/emacs-evil/evil][evil repo]]
|
||
- [[https://www.youtube.com/watch?v=JWD1Fpdd4Pc][(YouTube) Evil Mode: Or, How I Learned to Stop Worrying and Love Emacs]]
|
||
|
||
**** Evil-mode
|
||
Basic evil configuration.
|
||
|
||
#+begin_src emacs-lisp :noweb-ref minimal
|
||
(use-package evil
|
||
:straight t
|
||
:init
|
||
(setq evil-want-integration t)
|
||
(setq evil-want-C-u-scroll t)
|
||
(setq evil-want-keybinding nil)
|
||
(setq evil-search-module 'evil-search)
|
||
(setq evil-split-window-below t)
|
||
(setq evil-vsplit-window-right t)
|
||
(unless (display-graphic-p)
|
||
(setq evil-want-C-i-jump nil))
|
||
:config
|
||
(evil-mode 1)
|
||
;; (setq evil-respect-visual-line-mode t)
|
||
(when (fboundp #'undo-tree-undo)
|
||
(evil-set-undo-system 'undo-tree))
|
||
(when (fboundp #'general-define-key)
|
||
(general-define-key
|
||
:states '(motion))))
|
||
#+end_src
|
||
**** Addons
|
||
[[https://github.com/emacs-evil/evil-surround][evil-surround]] emulates one of my favorite vim plugins, surround.vim. Adds a lot of parentheses management options.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package evil-surround
|
||
:straight t
|
||
:after evil
|
||
:config
|
||
(global-evil-surround-mode 1))
|
||
#+end_src
|
||
|
||
[[https://github.com/linktohack/evil-commentary][evil-commentary]] emulates commentary.vim. It provides actions for quick insertion and deletion of comments.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package evil-commentary
|
||
:straight t
|
||
:after evil
|
||
:config
|
||
(evil-commentary-mode))
|
||
#+end_src
|
||
|
||
[[https://github.com/blorbx/evil-quickscope][evil-quickscope]] emulates quickscope.vim. It highlights certain target characters for f, F, t, T keys.
|
||
#+begin_src emacs-lisp
|
||
(use-package evil-quickscope
|
||
:straight t
|
||
:after evil
|
||
:config
|
||
:hook ((prog-mode . turn-on-evil-quickscope-mode)
|
||
(LaTeX-mode . turn-on-evil-quickscope-mode)
|
||
(org-mode . turn-on-evil-quickscope-mode)))
|
||
#+end_src
|
||
|
||
[[https://github.com/cofi/evil-numbers][evil-numbers]] allows incrementing and decrementing numbers at point.
|
||
#+begin_src emacs-lisp
|
||
(use-package evil-numbers
|
||
:straight t
|
||
:commands (evil-numbers/inc-at-pt evil-numbers/dec-at-pt)
|
||
:init
|
||
(general-nmap
|
||
"g+" 'evil-numbers/inc-at-pt
|
||
"g-" 'evil-numbers/dec-at-pt))
|
||
#+end_src
|
||
|
||
[[https://github.com/edkolev/evil-lion][evil-lion]] provides alignment operators, somewhat similar to vim-easyalign.
|
||
#+begin_src emacs-lisp
|
||
(use-package evil-lion
|
||
:straight t
|
||
:config
|
||
(setq evil-lion-left-align-key (kbd "g a"))
|
||
(setq evil-lion-right-align-key (kbd "g A"))
|
||
(evil-lion-mode))
|
||
#+end_src
|
||
|
||
[[https://github.com/redguardtoo/evil-matchit][evil-matchit]] makes "%" to match things like tags. It doesn't work perfectly, so I +occasionally+ turn it off.
|
||
#+begin_src emacs-lisp
|
||
(use-package evil-matchit
|
||
:straight t
|
||
:disabled
|
||
:config
|
||
(global-evil-matchit-mode 1))
|
||
#+end_src
|
||
**** My additions
|
||
Do ex search in other buffer. Like =*=, but switch to other buffer and search there.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/evil-ex-search-word-forward-other-window (count &optional symbol)
|
||
(interactive (list (prefix-numeric-value current-prefix-arg)
|
||
evil-symbol-word-search))
|
||
(save-excursion
|
||
(evil-ex-start-word-search nil 'forward count symbol))
|
||
(other-window 1)
|
||
(evil-ex-search-next))
|
||
|
||
(general-define-key
|
||
:states '(normal)
|
||
"&" #'my/evil-ex-search-word-forward-other-window)
|
||
#+end_src
|
||
**** evil-collection
|
||
[[https://github.com/emacs-evil/evil-collection][evil-collection]] is a package that provides evil bindings for a lot of different packages. One can see the complete list in the [[https://github.com/emacs-evil/evil-collection/tree/master/modes][modes]] folder.
|
||
|
||
#+begin_src emacs-lisp :noweb-ref minimal
|
||
(use-package evil-collection
|
||
:straight t
|
||
:after evil
|
||
:config
|
||
(evil-collection-init
|
||
'(eww devdocs proced emms pass calendar dired debug guix calc
|
||
docker ibuffer geiser pdf info elfeed edebug bookmark company
|
||
vterm flycheck profiler cider explain-pause-mode notmuch custom
|
||
xref eshell helpful compile comint git-timemachine magit prodigy
|
||
slime forge deadgrep vc-annonate telega doc-view gnus outline)))
|
||
#+end_src
|
||
*** My keybindings
|
||
Various keybinding settings that I can't put anywhere else.
|
||
|
||
**** Escape key
|
||
+Use the escape key instead of =C-g= whenever possible+ No, not really after 2 years... But I'll keep this fragment.
|
||
|
||
I must have copied it from somewhere, but as I googled to find out the source, I discovered quite a number of variations of the following code over time. I wonder if Richard Dawkins was inspired by something like this a few decades ago.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun minibuffer-keyboard-quit ()
|
||
"Abort recursive edit.
|
||
In Delete Selection mode, if the mark is active, just deactivate it;
|
||
then it takes a second \\[keyboard-quit] to abort the minibuffer."
|
||
(interactive)
|
||
(if (and delete-selection-mode transient-mark-mode mark-active)
|
||
(setq deactivate-mark t)
|
||
(when (get-buffer "*Completions*") (delete-windows-on "*Completions*"))
|
||
(abort-recursive-edit)))
|
||
|
||
(defun my/escape-key ()
|
||
(interactive)
|
||
(evil-ex-nohighlight)
|
||
(keyboard-quit))
|
||
|
||
(general-define-key
|
||
:keymaps '(normal visual global)
|
||
[escape] #'my/escape-key)
|
||
|
||
(general-define-key
|
||
:keymaps '(minibuffer-local-map
|
||
minibuffer-local-ns-map
|
||
minibuffer-local-completion-map
|
||
minibuffer-local-must-match-map
|
||
minibuffer-local-isearch-map)
|
||
[escape] 'minibuffer-keyboard-quit)
|
||
#+end_src
|
||
**** Home & end
|
||
#+begin_src emacs-lisp
|
||
(general-def :states '(normal insert visual)
|
||
"<home>" 'beginning-of-line
|
||
"<end>" 'end-of-line)
|
||
#+end_src
|
||
**** My leader
|
||
Using the =SPC= key as a leader key, like in Doom Emacs or Spacemacs.
|
||
|
||
#+begin_src emacs-lisp
|
||
(general-create-definer my-leader-def
|
||
:keymaps 'override
|
||
:prefix "SPC"
|
||
:states '(normal motion emacs))
|
||
|
||
(general-def :states '(normal motion emacs)
|
||
"SPC" nil
|
||
"M-SPC" (general-key "SPC"))
|
||
|
||
(general-def :states '(insert)
|
||
"M-SPC" (general-key "SPC" :state 'normal))
|
||
|
||
(my-leader-def "?" 'which-key-show-top-level)
|
||
(my-leader-def "E" 'eval-expression)
|
||
|
||
(general-def :states '(insert)
|
||
"<f1> e" #'eval-expression)
|
||
#+end_src
|
||
|
||
=general.el= has a nice integration with which-key, so I use that to show more descriptive annotations for certain groups of keybindings (the default annotation is just =prefix=).
|
||
#+begin_src emacs-lisp
|
||
(my-leader-def
|
||
"a" '(:which-key "apps"))
|
||
#+end_src
|
||
|
||
**** Universal argument
|
||
Change the universal argument to =M-u=. I use =C-u= to scroll up, as I'm used to from vim.
|
||
|
||
#+begin_src emacs-lisp
|
||
(general-def
|
||
:keymaps 'universal-argument-map
|
||
"M-u" 'universal-argument-more)
|
||
(general-def
|
||
:keymaps 'override
|
||
:states '(normal motion emacs insert visual)
|
||
"M-u" 'universal-argument)
|
||
#+end_src
|
||
**** Profiler
|
||
The built-in profiler is a magnificent tool to troubleshoot performance issues.
|
||
|
||
#+begin_src emacs-lisp
|
||
(my-leader-def
|
||
:infix "P"
|
||
"" '(:which-key "profiler")
|
||
"s" 'profiler-start
|
||
"e" 'profiler-stop
|
||
"p" 'profiler-report)
|
||
#+end_src
|
||
**** Buffer switching
|
||
Some keybindings I used in vim to switch buffers and can't let go of. But I think I started to use these less since I made an attempt in [[*i3 integration][i3 integration]].
|
||
|
||
#+begin_src emacs-lisp
|
||
(general-define-key
|
||
:keymaps 'override
|
||
"C-<right>" 'evil-window-right
|
||
"C-<left>" 'evil-window-left
|
||
"C-<up>" 'evil-window-up
|
||
"C-<down>" 'evil-window-down
|
||
"C-h" 'evil-window-left
|
||
"C-l" 'evil-window-right
|
||
"C-k" 'evil-window-up
|
||
"C-j" 'evil-window-down
|
||
"C-x h" 'previous-buffer
|
||
"C-x l" 'next-buffer)
|
||
|
||
(general-define-key
|
||
:keymaps 'evil-window-map
|
||
"x" 'kill-buffer-and-window
|
||
"d" 'kill-current-buffer)
|
||
#+end_src
|
||
|
||
=winner-mode= to keep the history of window states.
|
||
|
||
It doesn't play too well with perspective.el, that is it has a single history list for all of the perspectives. But it is still quite usable.
|
||
|
||
#+begin_src emacs-lisp
|
||
(winner-mode 1)
|
||
|
||
(general-define-key
|
||
:keymaps 'evil-window-map
|
||
"u" 'winner-undo
|
||
"U" 'winner-redo)
|
||
#+end_src
|
||
**** Buffer management
|
||
The following is necessary since my scratch buffer isn't lisp-interaction.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/lisp-interaction-buffer ()
|
||
(interactive)
|
||
(let ((buf (get-buffer-create "*lisp-interaction*")))
|
||
(with-current-buffer buf
|
||
(lisp-interaction-mode))
|
||
(switch-to-buffer buf)))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(my-leader-def
|
||
:infix "b"
|
||
"" '(:which-key "buffers")
|
||
"s" '(my/lisp-interaction-buffer
|
||
:which-key "*lisp-interaction*")
|
||
"m" '((lambda () (interactive) (persp-switch-to-buffer "*Messages*"))
|
||
:which-key "*Messages*")
|
||
"l" 'next-buffer
|
||
"h" 'previous-buffer
|
||
"k" 'kill-buffer
|
||
;; "b" 'persp-ivy-switch-buffer
|
||
"b" #'persp-switch-to-buffer*
|
||
"r" 'revert-buffer
|
||
"u" 'ibuffer)
|
||
#+end_src
|
||
**** xref
|
||
Some keybindings for xref and go to definition.
|
||
|
||
#+begin_src emacs-lisp
|
||
(general-nmap
|
||
"gD" 'xref-find-definitions-other-window
|
||
"gr" 'xref-find-references
|
||
"gd" 'evil-goto-definition)
|
||
|
||
(my-leader-def
|
||
"fx" 'xref-find-apropos)
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package xref
|
||
:straight (:type built-in))
|
||
#+end_src
|
||
|
||
**** Folding
|
||
There are multiple ways to fold text in Emacs.
|
||
|
||
The most versatile is the built-in =hs-minor-mode=, which seems to work out of the box for Lisps, C-like languages, and Python. =outline-minor-mode= works for org-mode, LaTeX and the like. There is a 3rd-party solution [[https://github.com/elp-revive/origami.el][origami.el]], which I found to be somewhat less stable.
|
||
|
||
Evil does a pretty good job of abstracting all these packages with a set of vim-like keybindings. I was using =SPC= in vim, but as now this isn't an option, I set =TAB= to toggle folding.
|
||
|
||
#+begin_src emacs-lisp
|
||
(require 'hideshow)
|
||
(general-define-key
|
||
:keymaps '(hs-minor-mode-map outline-minor-mode-map outline-mode-map)
|
||
:states '(normal motion)
|
||
"TAB" 'evil-toggle-fold)
|
||
#+end_src
|
||
**** Zoom UI
|
||
#+begin_src emacs-lisp
|
||
(defun my/zoom-in ()
|
||
"Increase font size by 10 points"
|
||
(interactive)
|
||
(set-face-attribute 'default nil
|
||
:height
|
||
(+ (face-attribute 'default :height) 10)))
|
||
|
||
(defun my/zoom-out ()
|
||
"Decrease font size by 10 points"
|
||
(interactive)
|
||
(set-face-attribute 'default nil
|
||
:height
|
||
(- (face-attribute 'default :height) 10)))
|
||
|
||
;; change font size, interactively
|
||
(global-set-key (kbd "C-+") 'my/zoom-in)
|
||
(global-set-key (kbd "C-=") 'my/zoom-out)
|
||
#+end_src
|
||
** Termux settings
|
||
:PROPERTIES:
|
||
:MODULE_NAME: termux
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-termux.el :comments links
|
||
:END:
|
||
For some reason my ONYX device has the tilde and escape wrong.
|
||
|
||
#+begin_src emacs-lisp
|
||
(when (and my/is-termux (not (equal (my/system-name) "snow")))
|
||
(define-key key-translation-map (kbd "`") (kbd "<escape>"))
|
||
(define-key key-translation-map (kbd "<escape>") (kbd "`")))
|
||
#+end_src
|
||
|
||
And the screen is less wide.
|
||
#+begin_src emacs-lisp
|
||
(when my/is-termux
|
||
(setq split-width-threshold 90))
|
||
#+end_src
|
||
** i3 integration
|
||
:PROPERTIES:
|
||
:MODULE_NAME: i3
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-i3.el :comments links
|
||
:END:
|
||
UPD <2021-11-27 Sat>. I have finally switched to EXWM as my window manager, but as long as I keep i3 as a backup solution, this section persists. Check out the [[https://sqrtminusone.xyz/posts/2021-10-04-emacs-i3/][post]] for a somewhat better presentation.
|
||
|
||
One advantage of EXWM for an Emacs user is that EXWM gives one set of keybindings to manage both Emacs windows and X windows. In every other WM, like my preferred [[https://i3wm.org][i3wm]], two orthogonal keymaps seem to be necessary. But, as both programs are quite customizable, I want to see whether I can replicate at least some part of the EXWM goodness in i3.
|
||
|
||
But why not just use EXWM? One key reason is that to my taste (and perhaps on my hardware) EXWM didn't feel snappy enough. Also, I really like i3's tree-based layout structure; I feel like it fits my workflow much better than anything else I tried, including the master/stack paradigm of [[https://xmonad.org/][XMonad]], for instance.
|
||
|
||
One common point of criticism of i3 is that it is not extensible enough, especially compared to WMs that are configured in an actual programing language, like the mentioned XMonad, [[http://www.qtile.org/][Qtile]], [[https://awesomewm.org/][Awesome]], etc. But I think i3's extensibility is underappreciated, although the contents of this section may lie closer to the limits of how far one can go there.
|
||
|
||
The basic idea is to launch a normal i3 command with =i3-msg= in case the current window is not Emacs, otherwise pass that command to Emacs with =emacsclient=. In Emacs, execute the command if possible, otherwise pass the command back to i3.
|
||
|
||
This may seem like a lot of overhead, but I didn't feel it even in the worst case (i3 -> Emacs -> i3), so at least in that regard, the interaction feels seamless. The only concern is that this command flow is vulnerable to Emacs getting stuck, but it is still much less of a problem than with EXWM.
|
||
|
||
One interesting observation here is that Emacs windows and X windows are sort of one-level entities, so I can talk just about "windows".
|
||
|
||
At any rate, we need a script to do the i3 -> Emacs part:
|
||
#+begin_src bash :tangle ~/bin/scripts/emacs-i3-integration
|
||
if [[ $(xdotool getactivewindow getwindowname) =~ ^emacs(:.*)?@.* ]]; then
|
||
command="(my/emacs-i3-integration \"$@\")"
|
||
emacsclient -e "$command"
|
||
else
|
||
i3-msg $@
|
||
fi
|
||
#+end_src
|
||
|
||
This script is being run from the [[file:Desktop.org::*i3wm][i3 configuration]].
|
||
|
||
For this to work, we need to make sure that Emacs starts a server, so here is an expression to do just that:
|
||
#+BEGIN_SRC emacs-lisp
|
||
(unless (or my/remote-server my/nested-emacs)
|
||
(add-hook 'after-init-hook #'server-start))
|
||
#+END_SRC
|
||
|
||
And here is a simple macro to do the Emacs -> i3 part:
|
||
#+begin_src emacs-lisp
|
||
(defmacro i3-msg (&rest args)
|
||
`(start-process "emacs-i3-windmove" nil "i3-msg" ,@args))
|
||
#+end_src
|
||
|
||
Now we have to handle the required set of i3 commands. It is worth noting here that I'm not trying to implement a general mechanism to apply i3 commands to Emacs, rather I'm implementing a small subset that I use in my i3 configuration and that maps reasonably to the Emacs concepts.
|
||
|
||
Also, I use [[https://github.com/emacs-evil/evil][evil-mode]] and generally configure the software to have vim-style bindings where possible. So if you don't use evil-mode you'd have to detangle the given functions from evil, but then, I guess, you do not use super+hjkl to manage windows either.
|
||
|
||
First, for the =focus= command I want to move to an Emacs window in the given direction if there is one, otherwise move to an X window in the same direction. Fortunately, i3 and windmove have the same names for directions, so the function is rather straightforward.
|
||
|
||
One caveat here is that the minibuffer is always the bottom-most Emacs window, so it is necessary to check for that as well.
|
||
#+begin_src emacs-lisp
|
||
(defun my/emacs-i3-windmove (dir)
|
||
(let ((other-window (windmove-find-other-window dir)))
|
||
(if (or (null other-window) (window-minibuffer-p other-window))
|
||
(i3-msg "focus" (symbol-name dir))
|
||
(windmove-do-window-select dir))))
|
||
#+end_src
|
||
|
||
For the =move= I want the following behavior:
|
||
- if there is space in the required direction, move the Emacs window there;
|
||
- if there is no space in the required direction, but space in two orthogonal directions, move the Emacs window so that there is no more space in the orthogonal directions;
|
||
- otherwise, move an X window (Emacs frame).
|
||
|
||
For the first part, =window-swap-states= with =windmove-find-other-window= do well enough.
|
||
|
||
=evil-move-window= works well for the second part. By itself it doesn't behave quite like i3, for instance, =(evil-move-window 'right)= in a three-column split would move the window from the far left side to the far right side (bypassing center). Hence the combination as described here.
|
||
|
||
So here is a simple predicate which checks whether there is space in the given direction.
|
||
#+begin_src emacs-lisp
|
||
(defun my/emacs-i3-direction-exists-p (dir)
|
||
(cl-some (lambda (dir)
|
||
(let ((win (windmove-find-other-window dir)))
|
||
(and win (not (window-minibuffer-p win)))))
|
||
(pcase dir
|
||
('width '(left right))
|
||
('height '(up down)))))
|
||
#+end_src
|
||
|
||
And the implementation of the move command.
|
||
#+begin_src emacs-lisp
|
||
(defun my/emacs-i3-move-window (dir)
|
||
(let ((other-window (windmove-find-other-window dir))
|
||
(other-direction (my/emacs-i3-direction-exists-p
|
||
(pcase dir
|
||
('up 'width)
|
||
('down 'width)
|
||
('left 'height)
|
||
('right 'height)))))
|
||
(cond
|
||
((and other-window (not (window-minibuffer-p other-window)))
|
||
(window-swap-states (selected-window) other-window))
|
||
(other-direction
|
||
(evil-move-window dir))
|
||
(t (i3-msg "move" (symbol-name dir))))))
|
||
#+end_src
|
||
|
||
Next on the line are =resize grow= and =resize shrink=. =evil-window-= functions do nicely for this task.
|
||
|
||
This function also checks whether there is space to resize in the given direction with the help of the predicate defined above. The command is forwarded back to i3 if there is not.
|
||
#+begin_src emacs-lisp
|
||
(defun my/emacs-i3-resize-window (dir kind value)
|
||
(if (or (one-window-p)
|
||
(not (my/emacs-i3-direction-exists-p dir)))
|
||
(i3-msg "resize" (symbol-name kind) (symbol-name dir)
|
||
(format "%s px or %s ppt" value value))
|
||
(setq value (/ value 2))
|
||
(pcase kind
|
||
('shrink
|
||
(pcase dir
|
||
('width
|
||
(evil-window-decrease-width value))
|
||
('height
|
||
(evil-window-decrease-height value))))
|
||
('grow
|
||
(pcase dir
|
||
('width
|
||
(evil-window-increase-width value))
|
||
('height
|
||
(evil-window-increase-height value)))))))
|
||
#+end_src
|
||
|
||
[[https://github.com/emacsorphanage/transpose-frame][transpose-frame]] is a package to "transpose" the current frame layout, which behaves someone similar to the =layout toggle split= command in i3, so I'll use it as well.
|
||
#+begin_src emacs-lisp
|
||
(use-package transpose-frame
|
||
:straight t
|
||
:commands (transpose-frame))
|
||
#+end_src
|
||
|
||
Finally, the entrypoint for the Emacs integration. In addition to the commands defined above, it processes =split= and =kill= commands and passes every other command back to i3.
|
||
#+begin_src emacs-lisp
|
||
(defun my/emacs-i3-integration (command)
|
||
(pcase command
|
||
((rx bos "focus")
|
||
(my/emacs-i3-windmove
|
||
(intern (elt (split-string command) 1))))
|
||
((rx bos "move")
|
||
(my/emacs-i3-move-window
|
||
(intern (elt (split-string command) 1))))
|
||
((rx bos "resize")
|
||
(my/emacs-i3-resize-window
|
||
(intern (elt (split-string command) 2))
|
||
(intern (elt (split-string command) 1))
|
||
(string-to-number (elt (split-string command) 3))))
|
||
("layout toggle split" (transpose-frame))
|
||
("split h" (evil-window-split))
|
||
("split v" (evil-window-vsplit))
|
||
("kill" (evil-quit))
|
||
(- (i3-msg command))))
|
||
#+end_src
|
||
** Editing text
|
||
:PROPERTIES:
|
||
:MODULE_NAME: general-config
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-general-config.el :comments links
|
||
:END:
|
||
Various packages, tricks, and settings that help with the central task of Emacs - editing text.
|
||
*** Indentation & whitespace
|
||
**** Aggressive Indent
|
||
A package to keep the code intended.
|
||
|
||
Doesn't work too well with many ecosystems because the LSP-based indentation is rather slow but nice for Lisps.
|
||
|
||
References:
|
||
- [[https://github.com/Malabarba/aggressive-indent-mode][aggressive-indent-mode repo]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package aggressive-indent
|
||
:commands (aggressive-indent-mode)
|
||
:straight t)
|
||
#+end_src
|
||
**** Delete trailing whitespace
|
||
Delete trailing whitespace on save, unless in particular modes where trailing whitespace is important, like Markdown.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq my/trailing-whitespace-modes '(markdown-mode))
|
||
|
||
(require 'cl-extra)
|
||
|
||
(add-hook 'before-save-hook
|
||
(lambda ()
|
||
(unless (cl-some #'derived-mode-p my/trailing-whitespace-modes)
|
||
(delete-trailing-whitespace))))
|
||
#+end_src
|
||
**** Tabs
|
||
Some default settings to manage tabs.
|
||
#+begin_src emacs-lisp
|
||
(setq tab-always-indent nil)
|
||
|
||
(setq-default default-tab-width 4)
|
||
(setq-default tab-width 4)
|
||
(setq-default evil-indent-convert-tabs nil)
|
||
(setq-default indent-tabs-mode nil)
|
||
(setq-default evil-shift-round nil)
|
||
#+end_src
|
||
*** Settings
|
||
**** Scrolling
|
||
#+begin_src emacs-lisp
|
||
(setq scroll-conservatively scroll-margin)
|
||
(setq scroll-step 1)
|
||
(setq scroll-preserve-screen-position t)
|
||
(setq scroll-error-top-bottom t)
|
||
(setq mouse-wheel-progressive-speed nil)
|
||
(setq mouse-wheel-inhibit-click-time nil)
|
||
#+end_src
|
||
**** Clipboard
|
||
#+begin_src emacs-lisp
|
||
(setq select-enable-clipboard t)
|
||
(setq mouse-yank-at-point t)
|
||
#+end_src
|
||
**** Backups
|
||
#+begin_src emacs-lisp
|
||
(setq backup-inhibited t)
|
||
(setq auto-save-default nil)
|
||
#+end_src
|
||
*** Undo Tree
|
||
Replaces Emacs built-in sequential undo system with a tree-based one. Probably one of the greatest options of Emacs as a text editor.
|
||
|
||
References:
|
||
- [[https://www.emacswiki.org/emacs/UndoTree][UndoTree on EmacsWiki]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package undo-tree
|
||
:straight t
|
||
:config
|
||
(global-undo-tree-mode)
|
||
(evil-set-undo-system 'undo-tree)
|
||
(setq undo-tree-visualizer-diff t)
|
||
(setq undo-tree-visualizer-timestamps t)
|
||
(setq undo-tree-auto-save-history nil)
|
||
|
||
(my-leader-def "u" 'undo-tree-visualize)
|
||
(fset 'undo-auto-amalgamate 'ignore)
|
||
(setq undo-limit 6710886400)
|
||
(setq undo-strong-limit 100663296)
|
||
(setq undo-outer-limit 1006632960))
|
||
#+end_src
|
||
|
||
*** Snippets
|
||
A snippet system for Emacs and a collection of pre-built snippets.
|
||
|
||
~yasnippet-snippets~ has to be loaded before ~yasnippet~ for user snippets to override the pre-built ones.
|
||
|
||
Edit <2022-04-11 Mon> I don't really use ~yasnippet-snippets~, so I'd rather write stuff manually.
|
||
|
||
References:
|
||
- [[http://joaotavora.github.io/yasnippet/][yasnippet documentation]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package yasnippet-snippets
|
||
:disabled
|
||
:straight t)
|
||
|
||
(use-package yasnippet
|
||
:straight t
|
||
:config
|
||
(setq yas-snippet-dirs
|
||
`(,(concat (expand-file-name user-emacs-directory) "snippets")
|
||
;; yasnippet-snippets-dir
|
||
))
|
||
(setq yas-triggers-in-field t)
|
||
(yas-global-mode 1)
|
||
(my-leader-def
|
||
:keymaps 'yas-minor-mode-map
|
||
:infix "es"
|
||
"" '(:wk "yasnippet")
|
||
"n" #'yas-new-snippet
|
||
"s" #'yas-insert-snippet
|
||
"v" #'yas-visit-snippet-file))
|
||
|
||
(general-imap "M-TAB" 'company-yasnippet)
|
||
#+end_src
|
||
*** Input Method
|
||
#+begin_quote
|
||
I have to switch layouts all the time, especially in LaTeX documents, because for some reason the Bolsheviks abandoned the idea of replacing Russian Cyrillic letters with Latin ones.
|
||
#+end_quote
|
||
- Me, [2021-04-24 Sat], in a commit to [[https://github.com/SystemCrafters/crafter-configs][SystemCrafters/crafter-configs]].
|
||
|
||
Fortunately, Emacs offers a way out of the above with input methods.
|
||
|
||
References:
|
||
- https://protesilaos.com/codelog/2023-12-12-emacs-multilingual-editing/ - A video by Prot from which I learned about this feature.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq default-input-method "russian-computer")
|
||
#+end_src
|
||
|
||
I also want to call =xkb-switch= in EXWM buffers with the same keybindig.
|
||
|
||
| Guix dependency |
|
||
|-----------------|
|
||
| xkb-switch |
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/toggle-input-method ()
|
||
(interactive)
|
||
(if (derived-mode-p 'exwm-mode)
|
||
(my/run-in-background "xkb-switch -n")
|
||
(if (or
|
||
(not (executable-find "xkb-switch"))
|
||
(equal (string-trim
|
||
(shell-command-to-string "xkb-switch -p"))
|
||
"us"))
|
||
(toggle-input-method)
|
||
(my/run-in-background "xkb-switch -s us"))))
|
||
#+end_src
|
||
|
||
=M-x delete-horizontal-space= doesn't feel that useful to me.
|
||
|
||
#+begin_src emacs-lisp
|
||
(general-define-key
|
||
:keymaps 'global
|
||
"M-\\" #'my/toggle-input-method)
|
||
#+end_src
|
||
|
||
*** Other small packages
|
||
**** Managing parentheses (smartparens)
|
||
A minor mode to deal with pairs. Its functionality overlaps with evil-surround, but smartparens provides the most comfortable way to do stuff like automatically insert pairs.
|
||
|
||
References:
|
||
- [[https://github.com/Fuco1/smartparens][smartparens repo]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package smartparens
|
||
:straight t)
|
||
#+end_src
|
||
|
||
The function below make smartparens behave the way I like for C-like languages:
|
||
#+begin_src emacs-lisp
|
||
(defun my/set-smartparens-indent (mode)
|
||
(sp-local-pair mode "{" nil :post-handlers '(("|| " "SPC") ("||\n[i]" "RET")))
|
||
(sp-local-pair mode "[" nil :post-handlers '(("|| " "SPC") ("||\n[i]" "RET")))
|
||
(sp-local-pair mode "(" nil :post-handlers '(("|| " "SPC") ("||\n[i]" "RET"))))
|
||
#+end_src
|
||
|
||
**** Visual fill column mode
|
||
#+begin_src emacs-lisp
|
||
(use-package visual-fill-column
|
||
:straight t
|
||
:commands (visual-fill-column-mode)
|
||
:config
|
||
;; How did it get here?
|
||
;; (add-hook 'visual-fill-column-mode-hook
|
||
;; (lambda () (setq visual-fill-column-center-text t)))
|
||
)
|
||
#+end_src
|
||
**** Accents
|
||
Input accented characters.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defvar my/default-accents
|
||
'((a . ä)
|
||
(o . ö)
|
||
(u . ü)
|
||
(s . ß)
|
||
(A . Ä)
|
||
(O . Ö)
|
||
(U . Ü)
|
||
(S . ẞ)))
|
||
|
||
(defun my/accent (arg)
|
||
(interactive "P")
|
||
(require 'accent)
|
||
(message "%s" arg)
|
||
(let* ((after? (eq accent-position 'after))
|
||
(char (if after? (char-after) (char-before)))
|
||
(curr (intern (string char)))
|
||
(default-diac (cdr (assoc curr my/default-accents))))
|
||
(if (and default-diac (not arg))
|
||
(progn
|
||
(delete-char (if after? 1 -1))
|
||
(insert (format "%c" default-diac)))
|
||
(call-interactively #'accent-company))))
|
||
|
||
(use-package accent
|
||
:straight (:host github :repo "eliascotto/accent")
|
||
:init
|
||
(general-define-key
|
||
:states '(normal)
|
||
"gs" #'accent-company)
|
||
(general-define-key
|
||
:states '(normal insert)
|
||
"M-n" #'my/accent)
|
||
:commands (accent-menu)
|
||
:config
|
||
(general-define-key
|
||
:keymaps 'popup-menu-keymap
|
||
"C-j" #'popup-next
|
||
"C-k" #'popup-previous
|
||
"M-j" #'popup-next
|
||
"M-k" #'popup-previous)
|
||
(setq accent-custom '((a (ā))
|
||
(A (Ā)))))
|
||
#+end_src
|
||
*** Random editing tricks
|
||
**** Round number at point
|
||
#+begin_src emacs-lisp
|
||
(defun my/round-number-at-point (word signs)
|
||
(interactive
|
||
(list (or (when (region-active-p)
|
||
(buffer-substring-no-properties
|
||
(region-beginning)
|
||
(region-end)))
|
||
(thing-at-point 'number 'no-properties))
|
||
(read-number "Decimal signs: " 2)))
|
||
(when (stringp word)
|
||
(setq word (string-to-number word)))
|
||
(let ((number (/ (float (round (* (expt 10 signs) word)))
|
||
(expt 10 signs))))
|
||
(save-excursion
|
||
(replace-string-in-region
|
||
(number-to-string word)
|
||
(number-to-string number)
|
||
(line-beginning-position)
|
||
(line-end-position)))))
|
||
#+end_src
|
||
** Working with projects
|
||
:PROPERTIES:
|
||
:MODULE_NAME: general-config
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-general-config.el :comments links
|
||
:END:
|
||
Packages related to managing projects.
|
||
|
||
I used to have [[https://github.com/Alexander-Miller/treemacs][Treemacs]] here, but in the end decided that dired with [[https://github.com/jojojames/dired-sidebar][dired-sidebar]] does a better job. Dired has its separate section in "Applications".
|
||
|
||
*** Projectile
|
||
[[https://github.com/bbatsov/projectile][Projectile]] gives a bunch of useful functions for managing projects, like finding files within a project, fuzzy-find, replace, etc.
|
||
|
||
| Type | Note |
|
||
|------+-----------------------------------------------------------------------|
|
||
| TODO | Maybe set =projectile-project-search-path= based on the folder index? |
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package projectile
|
||
:straight t
|
||
:config
|
||
(projectile-mode +1)
|
||
(general-define-key
|
||
:keymaps 'projectile-command-map
|
||
"b" #'consult-project-buffer))
|
||
|
||
(my-leader-def
|
||
"p" '(:keymap projectile-command-map :which-key "projectile"))
|
||
|
||
(general-nmap "C-p" #'projectile-find-file)
|
||
#+end_src
|
||
*** Git & Magit
|
||
[[https://magit.vc/][Magit]] is a git interface for Emacs.
|
||
|
||
A few CLI alternatives:
|
||
- [[https://github.com/jesseduffield/lazygit][lazygit]]. I used it before Emacs
|
||
- [[https://github.com/altsem/gitu][gitu]]. A CLI magit clone
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package magit
|
||
:straight t
|
||
:commands (magit-status magit-file-dispatch)
|
||
:init
|
||
(my-leader-def
|
||
"m" 'magit
|
||
"M" 'magit-file-dispatch)
|
||
:config
|
||
(require 'forge)
|
||
(setq magit-blame-styles
|
||
'((headings
|
||
(heading-format . "%-20a %C %s\n"))
|
||
(highlight
|
||
(highlight-face . magit-blame-highlight))
|
||
(lines
|
||
(show-lines . t)
|
||
(show-message . t)))))
|
||
#+end_src
|
||
|
||
[[https://github.com/emacsorphanage/git-gutter][git-gutter]] is shows git changes for each line (added/changed/deleted lines).
|
||
#+begin_src emacs-lisp
|
||
(use-package git-gutter
|
||
:straight t
|
||
:config
|
||
(global-git-gutter-mode +1))
|
||
#+end_src
|
||
|
||
[[https://github.com/emacsmirror/git-timemachine][git-timemachine]] allows visiting previous versions of a file.
|
||
#+begin_src emacs-lisp
|
||
(use-package git-timemachine
|
||
:straight t
|
||
:commands (git-timemachine))
|
||
#+end_src
|
||
|
||
| Guix dependency |
|
||
|-----------------|
|
||
| difftastic-bin |
|
||
|
||
[[https://github.com/pkryger/difftastic.el][difftastic.el]] is a wrapper package for [[https://difftastic.wilfred.me.uk/][difftastic]].
|
||
#+begin_src emacs-lisp
|
||
(use-package difftastic
|
||
:straight t
|
||
:commands (difftastic-magit-diff
|
||
difftastic-magit-show
|
||
difftastic-files
|
||
difftastic-buffers)
|
||
:init
|
||
(with-eval-after-load 'magit-diff
|
||
(transient-append-suffix 'magit-diff '(-1 -1)
|
||
[("D" "Difftastic diff (dwim)" difftastic-magit-diff)
|
||
("S" "Difftastic show" difftastic-magit-show)])
|
||
(general-define-key
|
||
:keymaps 'magit-blame-read-only-mode-map
|
||
:states 'normal
|
||
"D" #'difftastic-magit-show
|
||
"S" #'difftastic-magit-show))
|
||
:config
|
||
(setq difftastic-executable (executable-find "difft"))
|
||
(general-define-key
|
||
:keymaps 'difftastic-mode-map
|
||
:states '(normal)
|
||
"gr" #'difftastic-rerun
|
||
"q" #'kill-buffer-and-window))
|
||
#+end_src
|
||
|
||
My screen isn't wide enough to run =difftastic= in vertical split, so...
|
||
#+begin_src emacs-lisp
|
||
(defun my/difftastic-pop-at-bottom (buffer-or-name _requested-width)
|
||
(let ((window (split-window-below)))
|
||
(select-window window)
|
||
(evil-move-window 'below))
|
||
(set-window-buffer (selected-window) buffer-or-name))
|
||
|
||
(setq difftastic-display-buffer-function #'my/difftastic-pop-at-bottom)
|
||
#+end_src
|
||
|
||
And I suspect the built-in window width function doesn't work as intended because of =global-display-line-numbers-mode=.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq difftastic-requested-window-width-function
|
||
(lambda () (- (frame-width) 4)))
|
||
#+end_src
|
||
|
||
*** Editorconfig
|
||
Editorconfig support for Emacs.
|
||
|
||
References:
|
||
- [[https://editorconfig.org/][Editorconfig reference]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package editorconfig
|
||
:straight t
|
||
:config
|
||
(add-to-list 'editorconfig-indentation-alist
|
||
'(emmet-mode emmet-indentation))
|
||
(editorconfig-mode))
|
||
#+end_src
|
||
|
||
*** Editing files
|
||
A minor mode to remember recently edited files.
|
||
#+begin_src emacs-lisp
|
||
(recentf-mode 1)
|
||
#+end_src
|
||
|
||
Save the last place visited in the file.
|
||
#+begin_src emacs-lisp
|
||
(save-place-mode nil)
|
||
#+end_src
|
||
*** Deadgrep
|
||
[[https://github.com/Wilfred/deadgrep][deadgrep]] is a nice Emacs interface for [[https://github.com/BurntSushi/ripgrep][ripgrep]]. Running =ivy-occur= in =counsel-rg= does something a bit similar, but the deadgrep is more full-featured.
|
||
|
||
Somehow I couldn't hook =toogle-truncate-lines= into the existing package hooks, so here goes advice.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/deadgrep-fix-buffer-advice (fun &rest args)
|
||
(let ((buf (apply fun args)))
|
||
(with-current-buffer buf
|
||
(toggle-truncate-lines 1))
|
||
buf))
|
||
|
||
(use-package deadgrep
|
||
:straight t
|
||
:commands (deadgrep)
|
||
:config
|
||
(advice-add #'deadgrep--buffer :around #'my/deadgrep-fix-buffer-advice))
|
||
#+end_src
|
||
** Navigation
|
||
:PROPERTIES:
|
||
:MODULE_NAME: general-config
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-general-config.el :comments links
|
||
:END:
|
||
Things to navigate in Emacs.
|
||
|
||
*** Registers
|
||
References:
|
||
- [[https://www.gnu.org/software/emacs/manual/html_node/emacs/Registers.html][Registers (GNU Emacs Manual)]]
|
||
|
||
Somehow there's no built-in function to clear a register.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/register-clear (register)
|
||
(interactive (list (register-read-with-preview "Clear register: ")))
|
||
(setq register-alist (delq (assoc register register-alist) register-alist)))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq register-preview-delay which-key-idle-delay)
|
||
|
||
(my-leader-def
|
||
:infix "g"
|
||
"" '(:wk "registers & marks")
|
||
"y" #'copy-to-register
|
||
"p" #'insert-register
|
||
"o" #'point-to-register
|
||
"c" #'my/register-clear
|
||
"r" #'jump-to-register
|
||
"R" #'consult-register
|
||
"w" #'window-configuration-to-register)
|
||
#+End_src
|
||
|
||
*** Marks
|
||
References:
|
||
- [[https://www.gnu.org/software/emacs/manual/html_node/emacs/Mark.html][The Mark and the Region (GNU Emacs Manual)]]
|
||
- [[https://www.masteringemacs.org/article/fixing-mark-commands-transient-mark-mode][Fixing the mark commands in transient mark mode - Mastering Emacs]]
|
||
|
||
=transient-mark-mode= makes using marks for navigation a bit more cumbersome, but I'm not sure of potential side effects of disabling it... As of now, I want only to push a mark without activating it, so here's a function for that (taken from Mickey Peterson's article):
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/push-mark-no-activate ()
|
||
"Pushes `point' to `mark-ring' and does not activate the region
|
||
Equivalent to \\[set-mark-command] when \\[transient-mark-mode] is disabled"
|
||
(interactive)
|
||
(push-mark (point) t nil)
|
||
(message "Pushed mark to ring"))
|
||
#+end_src
|
||
|
||
Also a function to clear the current mark ring.
|
||
#+begin_src emacs-lisp
|
||
(defun my/mark-ring-clear ()
|
||
(interactive)
|
||
(setq mark-ring nil))
|
||
#+end_src
|
||
|
||
Keybindings:
|
||
#+begin_src emacs-lisp
|
||
(my-leader-def
|
||
:infix "g"
|
||
"G" #'consult-global-mark
|
||
"g" #'consult-mark
|
||
"C" #'my/mark-ring-clear
|
||
"m" #'my/push-mark-no-activate)
|
||
|
||
(general-define-key
|
||
:keymaps 'global
|
||
"C-SPC" #'my/push-mark-no-activate)
|
||
#+end_src
|
||
|
||
*** Avy
|
||
[[https://github.com/abo-abo/avy][Avy]] is a package that helps navigate Emacs in a tree-like manner.
|
||
|
||
References:
|
||
- [[https://karthinks.com/software/avy-can-do-anything/][Avy can do anything]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package avy
|
||
:straight t
|
||
:config
|
||
(setq avy-timeout-seconds 0.5)
|
||
(setq avy-ignored-modes
|
||
'(image-mode doc-view-mode pdf-view-mode exwm-mode))
|
||
(general-define-key
|
||
:states '(normal motion)
|
||
"-" #'avy-goto-char-timer))
|
||
#+End_src
|
||
|
||
Integrate avy with embark:
|
||
#+begin_src emacs-lisp
|
||
(defun avy-action-embark (pt)
|
||
(unwind-protect
|
||
(save-excursion
|
||
(goto-char pt)
|
||
(embark-act))
|
||
(select-window
|
||
(cdr (ring-ref avy-ring 0))))
|
||
t)
|
||
|
||
(with-eval-after-load 'avy
|
||
(setf (alist-get ?. avy-dispatch-alist) 'avy-action-embark))
|
||
#+end_src
|
||
|
||
[[https://github.com/abo-abo/ace-link][ace-link]] is a package to jump to links with avy.
|
||
#+begin_src emacs-lisp
|
||
(use-package ace-link
|
||
:straight t
|
||
:commands (ace-link-info ace-link-help ace-link-woman ace-link-eww))
|
||
#+end_src
|
||
|
||
** Completion
|
||
:PROPERTIES:
|
||
:MODULE_NAME: general-config
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-general-config.el :comments links
|
||
:END:
|
||
|
||
*** vertico
|
||
[[https://github.com/minad/vertico][vertico]] is a vertical completion framework. I switched to it from [[https://github.com/abo-abo/swiper][Ivy]] (and to Ivy from [[https://github.com/emacs-helm/helm][Helm]]).
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package vertico
|
||
:straight t
|
||
:config
|
||
(setq enable-recursive-minibuffers t)
|
||
(general-define-key
|
||
:keymaps '(vertico-map)
|
||
"M-j" #'vertico-next
|
||
"M-k" #'vertico-previous
|
||
"TAB" #'minibuffer-complete)
|
||
(vertico-mode))
|
||
#+end_src
|
||
|
||
Add prompt indicator to =completing-read-multiple=:
|
||
#+begin_src emacs-lisp
|
||
(defun crm-indicator (args)
|
||
(cons (format "[CRM%s] %s"
|
||
(replace-regexp-in-string
|
||
"\\`\\[.*?]\\*\\|\\[.*?]\\*\\'" ""
|
||
crm-separator)
|
||
(car args))
|
||
(cdr args)))
|
||
(with-eval-after-load 'crm
|
||
(advice-add #'completing-read-multiple :filter-args #'crm-indicator))
|
||
#+end_src
|
||
|
||
Persist history over Emacs restarts.
|
||
#+begin_src emacs-lisp
|
||
(use-package savehist
|
||
:init
|
||
(savehist-mode))
|
||
#+end_src
|
||
|
||
*** vertico extensions
|
||
Vertico has a lot of extensions.
|
||
|
||
[[https://github.com/minad/vertico/blob/main/extensions/vertico-directory.el][vertico-directory]] simplifies directory navigation.
|
||
#+begin_src emacs-lisp
|
||
(use-package vertico-directory
|
||
:after (vertico)
|
||
:config
|
||
(general-define-key
|
||
:keymaps '(vertico-map)
|
||
"RET" #'vertico-directory-enter
|
||
"DEL" #'vertico-directory-delete-char)
|
||
(add-hook 'rfn-eshadow-update-overlay-hook #'vertico-directory-tidy))
|
||
#+end_src
|
||
|
||
[[https://github.com/minad/vertico/blob/main/extensions/vertico-grid.el][vertico-grid]] enabled grid display. It is useful when there are no annotations in the completion buffer.
|
||
#+begin_src emacs-lisp
|
||
(use-package vertico-grid
|
||
:after (vertico))
|
||
#+end_src
|
||
|
||
[[https://github.com/minad/vertico/blob/main/extensions/vertico-multiform.el][vertico-multiform]] enables per-mode configuration.
|
||
#+begin_src emacs-lisp
|
||
(defun my/sort-directories-first (files)
|
||
(setq files (vertico-sort-alpha files))
|
||
(nconc (seq-filter (lambda (x) (string-suffix-p "/" x)) files)
|
||
(seq-remove (lambda (x) (string-suffix-p "/" x)) files)))
|
||
|
||
(use-package vertico-multiform
|
||
:after vertico
|
||
:config
|
||
(vertico-multiform-mode)
|
||
(general-define-key
|
||
:keymap 'vertico-multiform-map
|
||
"M-b" #'vertico-multiform-buffer
|
||
"M-g" #'vertico-multiform-grid)
|
||
(setq vertico-multiform-categories
|
||
'((file (vertico-sort-function . my/sort-directories-first))
|
||
(password-store-pass grid)))
|
||
(setq vertico-multiform-commands
|
||
'((eshell-atuin-history (vertico-sort-function . nil))
|
||
(my/index-nav (vertico-sort-function . nil))
|
||
(org-ql-view (vertico-sort-function . nil))
|
||
(my/consult-line (vertico-sort-function . nil))
|
||
(telega-msg-add-reaction grid))))
|
||
#+end_src
|
||
|
||
[[https://github.com/minad/vertico/blob/main/extensions/vertico-quick.el][vertico-quick]] enables ivy-like bindings to choose candidates.
|
||
#+begin_src emacs-lisp
|
||
(use-package vertico-quick
|
||
:after vertico
|
||
:config
|
||
(general-define-key
|
||
:keymaps '(vertico-map)
|
||
"M-q" #'vertico-quick-insert
|
||
"C-q" #'vertico-quick-exit))
|
||
#+end_src
|
||
|
||
*** orderless
|
||
[[https://github.com/oantolin/orderless][orderless]] is a flexible completion style framework. Completion style refers to the way entries are filtered in the completion buffer.
|
||
|
||
I used to use [[https://github.com/radian-software/prescient.el][prescient.el]] with Ivy; unlike prescient, orderless doesn't sort completion entries.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package orderless
|
||
:straight t
|
||
:config
|
||
(setq completion-styles '(orderless basic))
|
||
(setq completion-category-defaults nil)
|
||
(setq completion-category-overrides
|
||
'((file (styles partial-completion))))
|
||
(setq orderless-matching-styles
|
||
'(orderless-literal orderless-initialism orderless-regexp)))
|
||
#+end_src
|
||
|
||
Disable orderless for company:
|
||
#+begin_src emacs-lisp
|
||
(defun company-completion-styles (capf-fn &rest args)
|
||
(let ((completion-styles '(basic partial-completion)))
|
||
(apply capf-fn args)))
|
||
|
||
(with-eval-after-load 'company
|
||
(advice-add 'company-capf :around #'company-completion-styles))
|
||
#+end_src
|
||
|
||
*** consult
|
||
[[https://github.com/minad/consult][consult]] provides various commands based on the =completing-read= API.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package consult
|
||
:straight t
|
||
:config
|
||
(setq consult-preview-excluded-files
|
||
`("\\`/[^/|:]+:"
|
||
,(rx "html" eos))))
|
||
#+end_src
|
||
|
||
*** marginalia
|
||
[[https://github.com/minad/marginalia][marginalia]] provides annotations in the completion interface.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package marginalia
|
||
:straight t
|
||
:config
|
||
(marginalia-mode)
|
||
(push '(projectile-find-file . file)
|
||
marginalia-command-categories))
|
||
#+end_src
|
||
*** embark
|
||
[[https://github.com/oantolin/embark][embark]] provides minibuffer actions.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package embark
|
||
:straight t
|
||
:commands (embark-act embark-dwim embark-bindings)
|
||
:init
|
||
(general-define-key
|
||
"M-e" #'embark-act))
|
||
|
||
(use-package embark-consult
|
||
:straight t
|
||
:after (embark)
|
||
:config
|
||
(add-hook 'embark-collect-mode #'consult-preview-at-point-mode))
|
||
#+end_src
|
||
|
||
Use =which-key= like indicator. Take from the [[https://github.com/oantolin/embark/wiki/Additional-Configuration#use-which-key-like-a-key-menu-prompt][Embark wiki]].
|
||
#+begin_src emacs-lisp
|
||
(defun embark-which-key-indicator ()
|
||
"An embark indicator that displays keymaps using which-key.
|
||
The which-key help message will show the type and value of the
|
||
current target followed by an ellipsis if there are further
|
||
targets."
|
||
(lambda (&optional keymap targets prefix)
|
||
(if (null keymap)
|
||
(which-key--hide-popup-ignore-command)
|
||
(which-key--show-keymap
|
||
(if (eq (plist-get (car targets) :type) 'embark-become)
|
||
"Become"
|
||
(format "Act on %s '%s'%s"
|
||
(plist-get (car targets) :type)
|
||
(embark--truncate-target (plist-get (car targets) :target))
|
||
(if (cdr targets) "…" "")))
|
||
(if prefix
|
||
(pcase (lookup-key keymap prefix 'accept-default)
|
||
((and (pred keymapp) km) km)
|
||
(_ (key-binding prefix 'accept-default)))
|
||
keymap)
|
||
nil nil t (lambda (binding)
|
||
(not (string-suffix-p "-argument" (cdr binding))))))))
|
||
|
||
(defun embark-hide-which-key-indicator (fn &rest args)
|
||
"Hide the which-key indicator immediately when using the completing-read prompter."
|
||
(which-key--hide-popup-ignore-command)
|
||
(let ((embark-indicators
|
||
(remq #'embark-which-key-indicator embark-indicators)))
|
||
(apply fn args)))
|
||
|
||
(with-eval-after-load 'embark
|
||
(advice-add #'embark-completing-read-prompter
|
||
:around #'embark-hide-which-key-indicator)
|
||
(setq embark-indicators (delq #'embark-mixed-indicator embark-indicators))
|
||
(push #'embark-which-key-indicator embark-indicators))
|
||
#+end_src
|
||
|
||
*** keybindings
|
||
Setting up quick access to various completions.
|
||
|
||
#+begin_src emacs-lisp
|
||
(my-leader-def
|
||
:infix "f"
|
||
"" '(:which-key "various completions")'
|
||
"b" #'persp-switch-to-buffer*
|
||
"e" 'micromamba-activate
|
||
"f" 'project-find-file
|
||
"c" 'consult-yank-pop
|
||
"a" 'consult-ripgrep
|
||
"d" 'deadgrep)
|
||
|
||
(general-define-key
|
||
:states '(insert normal)
|
||
"C-y" 'consult-yank-pop)
|
||
|
||
(defun my/consult-line ()
|
||
(interactive)
|
||
(if current-prefix-arg
|
||
(call-interactively #'consult-line-multi)
|
||
(consult-line nil t)))
|
||
|
||
;; (my-leader-def "SPC SPC" 'ivy-resume)
|
||
(my-leader-def "s" 'my/consult-line)
|
||
#+end_src
|
||
*** company
|
||
A completion framework for Emacs.
|
||
|
||
References:
|
||
- [[http://company-mode.github.io/][company homepage]]
|
||
- [[https://github.com/sebastiencs/company-box][company-box homepage]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package company
|
||
:straight t
|
||
:config
|
||
(global-company-mode)
|
||
(setq company-idle-delay 0.2)
|
||
(setq company-dabbrev-downcase nil)
|
||
(setq company-show-numbers t))
|
||
|
||
(general-imap "C-SPC" 'company-complete)
|
||
#+end_src
|
||
|
||
A company frontend with nice icons.
|
||
|
||
+Disabled since the base company got icons support and since company-box has some issues with spaceline.+ Enabled back because I didn't like spaceline.
|
||
#+begin_src emacs-lisp
|
||
(use-package company-box
|
||
:straight t
|
||
:if (display-graphic-p)
|
||
:after (company)
|
||
:hook (company-mode . company-box-mode))
|
||
#+end_src
|
||
** Help
|
||
:PROPERTIES:
|
||
:MODULE_NAME: general-config
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-general-config.el :comments links
|
||
:END:
|
||
- *CREDIT*: Thanks @phundrak on the System Crafters Discord for suggesting =help-map=
|
||
|
||
[[https://github.com/Wilfred/helpful][helpful]] package improves the =*help*= buffer.
|
||
#+begin_src emacs-lisp
|
||
(use-package helpful
|
||
:straight t
|
||
:commands (helpful-callable
|
||
helpful-variable
|
||
helpful-key
|
||
helpful-macro
|
||
helpful-function
|
||
helpful-command))
|
||
#+end_src
|
||
|
||
As I use =C-h= to switch buffers, I moved the help to =SPC-h= with the code below.
|
||
#+begin_src emacs-lisp
|
||
(my-leader-def
|
||
"h" '(:keymap help-map :which-key "help"))
|
||
|
||
(my-leader-def
|
||
:infix "h"
|
||
"" '(:which-key "help")
|
||
"h" '(:keymap help-map :which-key "help-map")
|
||
"f" 'helpful-function
|
||
"k" 'helpful-key
|
||
"v" 'helpful-variable
|
||
"o" 'helpful-symbol
|
||
"i" 'info)
|
||
|
||
(general-define-key
|
||
:keymaps 'help-map
|
||
"f" 'helpful-function
|
||
"k" 'helpful-key
|
||
"v" 'helpful-variable
|
||
"o" 'helpful-symbol)
|
||
#+end_src
|
||
** Time trackers
|
||
Time trackers I happen to use.
|
||
|
||
References:
|
||
- [[https://wakatime.com][WakaTime]]
|
||
- [[https://activitywatch.net/][ActivityWatch]]
|
||
|
||
*** WakaTime
|
||
:PROPERTIES:
|
||
:MODULE_NAME: wakatime
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-wakatime.el :comments links
|
||
:END:
|
||
Before I figure out how to package this for Guix:
|
||
- Clone [[https://github.com/wakatime/wakatime-cli][the repo]]
|
||
- Run ~go build~
|
||
- Copy the binary to the =~/bin= folder
|
||
|
||
#+begin_src emacs-lisp :noweb yes
|
||
(use-package wakatime-mode
|
||
:straight (:host github :repo "SqrtMinusOne/wakatime-mode")
|
||
:config
|
||
(setq wakatime-ignore-exit-codes '(0 1 102 112))
|
||
(advice-add 'wakatime-init :after
|
||
(lambda ()
|
||
(setq wakatime-cli-path (or
|
||
(executable-find "wakatime-cli")
|
||
(expand-file-name "~/bin/wakatime-cli")))))
|
||
(when (file-exists-p "~/.wakatime.cfg")
|
||
(setq wakatime-api-key
|
||
(string-trim
|
||
(shell-command-to-string "awk '/api-key/{print $NF}' ~/.wakatime.cfg"))))
|
||
;; (setq wakatime-cli-path (executable-find "wakatime"))
|
||
(global-wakatime-mode))
|
||
#+end_src
|
||
|
||
*** ActivityWatch
|
||
:PROPERTIES:
|
||
:MODULE_NAME: activitywatch
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-activitywatch.el :comments links
|
||
:END:
|
||
#+begin_src emacs-lisp
|
||
(use-package request
|
||
:straight t
|
||
:defer t)
|
||
|
||
(use-package activity-watch-mode
|
||
:straight t
|
||
:config
|
||
(global-activity-watch-mode))
|
||
#+end_src
|
||
* UI settings
|
||
** General settings
|
||
:PROPERTIES:
|
||
:MODULE_NAME: general-ui
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-general-ui.el :comments links
|
||
:END:
|
||
*** Miscellaneous
|
||
Disable GUI elements
|
||
#+begin_src emacs-lisp
|
||
(unless my/is-termux
|
||
(tool-bar-mode -1)
|
||
(menu-bar-mode -1)
|
||
(scroll-bar-mode -1))
|
||
|
||
(when my/is-termux
|
||
(menu-bar-mode -1))
|
||
#+end_src
|
||
|
||
Transparency. Not setting it here, as I used to use [[file:Desktop.org::*Picom][picom]] with i3, and EXWM config has its own settings.
|
||
#+begin_src emacs-lisp
|
||
;; (set-frame-parameter (selected-frame) 'alpha '(90 . 90))
|
||
;; (add-to-list 'default-frame-alist '(alpha . (90 . 90)))
|
||
#+end_src
|
||
|
||
This adds a confirmation to avoid accidental Emacs closing.
|
||
#+begin_src emacs-lisp
|
||
(setq confirm-kill-emacs 'y-or-n-p)
|
||
#+end_src
|
||
|
||
Prettify symbols. Also not setting it, ligatures seem to be enough for me.
|
||
#+begin_src emacs-lisp
|
||
;; (global-prettify-symbols-mode)
|
||
#+end_src
|
||
|
||
Do not show GUI dialogs
|
||
#+begin_src emacs-lisp
|
||
(setq use-dialog-box nil)
|
||
#+end_src
|
||
|
||
No start screen
|
||
#+begin_src emacs-lisp
|
||
(setq inhibit-startup-screen t)
|
||
#+end_src
|
||
|
||
Visual bell
|
||
#+begin_src emacs-lisp
|
||
(setq visible-bell 0)
|
||
#+end_src
|
||
|
||
y or n instead of yes or no
|
||
#+begin_src emacs-lisp
|
||
(defalias 'yes-or-no-p 'y-or-n-p)
|
||
#+end_src
|
||
|
||
Hide mouse cursor while typing
|
||
#+begin_src emacs-lisp
|
||
(setq make-pointer-invisible t)
|
||
#+end_src
|
||
|
||
Show pairs
|
||
#+begin_src emacs-lisp
|
||
(show-paren-mode 1)
|
||
#+end_src
|
||
|
||
Highlight the current line
|
||
#+begin_src emacs-lisp
|
||
(global-hl-line-mode 1)
|
||
#+end_src
|
||
*** Line numbers
|
||
Line numbers. There seems to be a catch with the relative number setting:
|
||
- =visual= doesn't take folding into account but also doesn't take wrapped lines into account (i.e. there are multiple numbers for a single wrapped line)
|
||
- =relative= makes a single number for a wrapped line, but counts folded lines.
|
||
|
||
=visual= option seems to be less of a problem in most cases.
|
||
#+begin_src emacs-lisp
|
||
(global-display-line-numbers-mode 1)
|
||
(line-number-mode nil)
|
||
(setq display-line-numbers-type 'visual)
|
||
(column-number-mode)
|
||
#+end_src
|
||
*** Word wrapping
|
||
Word wrapping. These settings aren't too obvious compared to =:set wrap= from vim:
|
||
- =word-wrap= means just "don't split one word between two lines". So, if there isn't enough place to put a word at the end of the line, it will be put on a new one. Run =M-x toggle-word-wrap= to toggle that.
|
||
- =visual-line-mode= seems to be a superset of =word-wrap=. It also enables some editing commands to work on visual lines instead of logical ones, hence the naming.
|
||
- =auto-fill-mode= does the same as =word-wrap=, except it actually *edits the buffer* to make lines break in the appropriate places.
|
||
- =truncate-lines= truncates long lines instead of continuing them. Run =M-x toggle-truncate-lines= to toggle that. I find that =truncate-lines= behaves strangely when =visual-line-mode= is on, so I use one or another.
|
||
#+begin_src emacs-lisp
|
||
(setq word-wrap 1)
|
||
(global-visual-line-mode 1)
|
||
#+end_src
|
||
*** Custom frame format
|
||
Title format, which used to look something like =emacs:project@hostname=. Now it's just =emacs=.
|
||
#+begin_src emacs-lisp
|
||
(setq-default frame-title-format
|
||
'(""
|
||
"emacs"
|
||
;; (:eval
|
||
;; (let ((project-name (projectile-project-name)))
|
||
;; (if (not (string= "-" project-name))
|
||
;; (format ":%s@%s" project-name (system-name))
|
||
;; (format "@%s" (system-name)))))
|
||
))
|
||
#+end_src
|
||
*** Olivetti
|
||
[[https://github.com/rnkn/olivetti][Olivetti]] is a package that limits the current text body width. It's pretty nice to use when writing texts.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package olivetti
|
||
:straight t
|
||
:if (display-graphic-p)
|
||
:commands (olivetti-mode)
|
||
:config
|
||
(setq-default olivetti-body-width 86))
|
||
#+end_src
|
||
*** Keycast
|
||
Showing the last pressed key. Occasionally useful.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package keycast
|
||
:straight t
|
||
:init
|
||
(define-minor-mode keycast-mode
|
||
"Keycast mode"
|
||
:global t
|
||
(if keycast-mode
|
||
(progn
|
||
(add-to-list 'global-mode-string '("" keycast-mode-line " "))
|
||
(add-hook 'pre-command-hook 'keycast--update t) )
|
||
(remove-hook 'pre-command-hook 'keycast--update)
|
||
(setq global-mode-string (delete '("" keycast-mode-line " ") global-mode-string))))
|
||
:commands (keycast--update))
|
||
#+end_src
|
||
** Themes and colors
|
||
:PROPERTIES:
|
||
:MODULE_NAME: general-ui
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-general-ui.el :comments links
|
||
:END:
|
||
*** Theme packages
|
||
My colorschemes of choice.
|
||
#+begin_src emacs-lisp
|
||
(use-package doom-themes
|
||
:straight t
|
||
;; Not deferring becuase I want `doom-themes-visual-bell-config'
|
||
:config
|
||
(setq doom-themes-enable-bold t
|
||
doom-themes-enable-italic t)
|
||
;; (if my/remote-server
|
||
;; (load-theme 'doom-gruvbox t)
|
||
;; (load-theme 'doom-palenight t))
|
||
(doom-themes-visual-bell-config)
|
||
(setq doom-themes-treemacs-theme "doom-colors")
|
||
(doom-themes-treemacs-config))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package modus-themes
|
||
:straight t)
|
||
#+end_src
|
||
|
||
Let's see...
|
||
#+begin_src emacs-lisp
|
||
(use-package ef-themes
|
||
:straight t
|
||
:config
|
||
(setq ef-duo-light-palette-overrides
|
||
'((constant green))))
|
||
#+end_src
|
||
*** Custom theme
|
||
Here I define a few things on the top of Emacs theme, because:
|
||
- Occasionally I want to have more theme-derived faces
|
||
- I also want Emacs theme to be applied to the rest of the system (see the [[file:Desktop.org][Desktop]] config on that)
|
||
|
||
Theme-derived faces have to placed in a custom theme, because if one calls =custom-set-faces= and =custom-set-variables= in code, whenever a variable is changed and saved in a customize buffer, data from all calls of these functions is saved as well.
|
||
|
||
**** Get color values
|
||
Here's a great package with various color tools:
|
||
#+begin_src emacs-lisp
|
||
(use-package ct
|
||
:straight t)
|
||
#+end_src
|
||
|
||
As of now I want this to support =doom-themes= and =modus-themes=. So, let's get which one is enabled:
|
||
#+begin_src emacs-lisp
|
||
(defun my/doom-p ()
|
||
(seq-find (lambda (x) (string-match-p (rx bos "doom") (symbol-name x)))
|
||
custom-enabled-themes))
|
||
|
||
(defun my/modus-p ()
|
||
(seq-find (lambda (x) (string-match-p (rx bos "modus") (symbol-name x)))
|
||
custom-enabled-themes))
|
||
|
||
(defun my/ef-p ()
|
||
(seq-find (lambda (x) (string-match-p (rx bos "ef") (symbol-name x)))
|
||
custom-enabled-themes))
|
||
#+end_src
|
||
|
||
I also want to know if the current theme is light or not:
|
||
#+begin_src emacs-lisp
|
||
(defun my/light-p ()
|
||
(ct-light-p (my/color-value 'bg)))
|
||
|
||
(defun my/dark-p ()
|
||
(not (my/light-p)))
|
||
#+end_src
|
||
|
||
Now, let's get the current color from =doom=. =doom-themes= provide =doom-color=, but I also want to:
|
||
- override some colors
|
||
- add =black=, =white=, =light-*= and =border=
|
||
#+begin_src emacs-lisp
|
||
(defconst my/theme-override
|
||
'((doom-palenight
|
||
(red . "#f07178"))))
|
||
|
||
(defvar my/alpha-for-light 7)
|
||
|
||
(defun my/doom-color (color)
|
||
(when (doom-color 'bg)
|
||
(let ((override (alist-get (my/doom-p) my/theme-override))
|
||
(color-name (symbol-name color))
|
||
(is-light (ct-light-p (doom-color 'bg))))
|
||
(or
|
||
(alist-get color override)
|
||
(cond
|
||
((eq 'black color)
|
||
(if is-light (doom-color 'fg) (doom-color 'bg)))
|
||
((eq 'white color)
|
||
(if is-light (doom-color 'bg) (doom-color 'fg)))
|
||
((eq 'border color)
|
||
(if is-light (doom-color 'base0) (doom-color 'base8)))
|
||
((string-match-p (rx bos "light-") color-name)
|
||
(ct-edit-hsl-l-inc (my/doom-color (intern (substring color-name 6)))
|
||
my/alpha-for-light))
|
||
((string-match-p (rx bos "dark-") color-name)
|
||
(or (doom-color color)
|
||
(ct-edit-hsl-l-dec (my/doom-color (intern (substring color-name 5)))
|
||
my/alpha-for-light)))
|
||
(t (doom-color color)))))))
|
||
#+end_src
|
||
|
||
And the same for =modus-themes=. =my/modus-color= has to accept the same arguments as I use for =my/doom-color= for backward compatibility, which requires a bit more tuning.
|
||
#+begin_src emacs-lisp
|
||
(defun my/modus-get-base (color)
|
||
(let ((base-value (string-to-number (substring (symbol-name color) 4 5)))
|
||
(base-start (cadr (assoc 'bg-main (modus-themes--current-theme-palette))))
|
||
(base-end (cadr (assoc 'fg-dim (modus-themes--current-theme-palette)))))
|
||
(nth base-value (ct-gradient 9 base-start base-end t))))
|
||
|
||
(defun my/prot-color (color palette)
|
||
(let ((is-light (ct-light-p (cadr (assoc 'bg-main palette)))))
|
||
(cond
|
||
((member color '(black white light-black light-white))
|
||
(let ((bg-main (cadr (assoc 'bg-main palette)))
|
||
(fg-main (cadr (assoc 'fg-main palette))))
|
||
(pcase color
|
||
('black (if is-light fg-main bg-main))
|
||
('white (if is-light bg-main fg-main))
|
||
('light-black (ct-edit-hsl-l-inc
|
||
(if is-light fg-main bg-main)
|
||
15))
|
||
('light-white (ct-edit-hsl-l-inc
|
||
(if is-light bg-main fg-main)
|
||
15)))))
|
||
((or (eq color 'bg))
|
||
(cadr (assoc 'bg-main palette)))
|
||
((or (eq color 'fg))
|
||
(cadr (assoc 'fg-main palette)))
|
||
((eq color 'bg-alt)
|
||
(cadr (assoc 'bg-dim palette)))
|
||
((eq color 'violet)
|
||
(cadr (assoc 'magenta-cooler palette)))
|
||
((string-match-p (rx bos "base" digit) (symbol-name color))
|
||
(my/modus-get-base color))
|
||
((string-match-p (rx bos "dark-") (symbol-name color))
|
||
(cadr (assoc (intern (format "%s-cooler" (substring (symbol-name color) 5)))
|
||
palette)))
|
||
((eq color 'grey)
|
||
(my/modus-get-base 'base5))
|
||
((string-match-p (rx bos "light-") (symbol-name color))
|
||
(or
|
||
(cadr (assoc (intern (format "%s-intense" (substring (symbol-name color) 6))) palette))
|
||
(cadr (assoc (intern (format "bg-%s-intense" (substring (symbol-name color) 6))) palette))))
|
||
(t (cadr (assoc color palette))))))
|
||
|
||
(defun my/modus-color (color)
|
||
(my/prot-color color (modus-themes--current-theme-palette)))
|
||
|
||
(defun my/ef-color (color)
|
||
(my/prot-color color (ef-themes--current-theme-palette)))
|
||
#+end_src
|
||
|
||
Test the three functions.
|
||
#+begin_src emacs-lisp
|
||
(defconst my/test-colors-list
|
||
'(black red green yellow blue magenta cyan white light-black
|
||
dark-red dark-green dark-yellow dark-blue dark-magenta dark-cyan
|
||
light-red light-green light-yellow light-blue light-magenta
|
||
light-cyan light-white bg bg-alt fg fg-alt violet grey base0 base1
|
||
base2 base3 base4 base5 base6 base7 base8 border))
|
||
|
||
(defun my/test-colors ()
|
||
(interactive)
|
||
(let ((buf (generate-new-buffer "*colors-test*")))
|
||
(with-current-buffer buf
|
||
(insert (format "%-20s %-10s %-10s %-10s" "Color" "Doom" "Modus" "Ef") "\n")
|
||
(cl-loop for color in my/test-colors-list
|
||
do (insert
|
||
(format "%-20s %-10s %-10s %-10s\n"
|
||
(prin1-to-string color)
|
||
(my/doom-color color)
|
||
(my/modus-color color)
|
||
(my/ef-color color))))
|
||
(special-mode)
|
||
(rainbow-mode))
|
||
(switch-to-buffer buf)))
|
||
#+end_src
|
||
|
||
Finally, one function to get the value of a color in the current theme.
|
||
#+begin_src emacs-lisp
|
||
(defun my/color-value (color)
|
||
(cond
|
||
((stringp color) (my/color-value (intern color)))
|
||
((eq color 'bg-other)
|
||
(or (my/color-value 'bg-dim)
|
||
(let ((color (my/color-value 'bg)))
|
||
(if (ct-light-p color)
|
||
(ct-edit-hsl-l-dec color 2)
|
||
(ct-edit-hsl-l-dec color 3)))))
|
||
((eq color 'modeline)
|
||
(or
|
||
(my/color-value 'bg-mode-line-active)
|
||
(my/color-value 'bg-mode-line)
|
||
(if (my/light-p)
|
||
(ct-edit-hsl-l-dec (my/color-value 'bg-alt) 10)
|
||
(ct-edit-hsl-l-inc (my/color-value 'bg-alt) 15))))
|
||
((my/doom-p) (my/doom-color color))
|
||
((my/modus-p) (my/modus-color color))
|
||
((my/ef-p) (my/ef-color color))))
|
||
#+end_src
|
||
|
||
And a few more functions
|
||
**** Custom theme
|
||
So, the custom theme:
|
||
#+begin_src emacs-lisp
|
||
(deftheme my-theme-1)
|
||
#+end_src
|
||
|
||
A macro to simplify defining custom colors.
|
||
#+begin_src emacs-lisp
|
||
(defvar my/my-theme-update-color-params nil)
|
||
|
||
(defmacro my/use-colors (&rest data)
|
||
`(progn
|
||
,@(cl-loop for i in data collect
|
||
`(setf (alist-get ',(car i) my/my-theme-update-color-params)
|
||
(list ,@(cl-loop for (key value) on (cdr i) by #'cddr
|
||
append `(,key ',value)))))
|
||
(when (and (or (my/doom-p) (my/modus-p)) my/emacs-started)
|
||
(my/update-my-theme))))
|
||
#+end_src
|
||
|
||
This macro puts lambdas to =my/my-theme-update-colors-hook= that updates faces in =my-theme-1=. Now I have to call this hook:
|
||
#+begin_src emacs-lisp
|
||
(defun my/update-my-theme (&rest _)
|
||
(interactive)
|
||
(cl-loop for (face . values) in my/my-theme-update-color-params
|
||
do (custom-theme-set-faces
|
||
'my-theme-1
|
||
`(,face ((t ,@(cl-loop for (key value) on values by #'cddr
|
||
collect key
|
||
collect (eval value)))))))
|
||
(enable-theme 'my-theme-1))
|
||
|
||
(unless my/is-termux
|
||
(advice-add 'load-theme :after #'my/update-my-theme)
|
||
(add-hook 'emacs-startup-hook #'my/update-my-theme))
|
||
#+end_src
|
||
|
||
Defining colors for =tab-bar.el=:
|
||
#+begin_src emacs-lisp
|
||
(my/use-colors
|
||
(tab-bar-tab :background (my/color-value 'bg)
|
||
:foreground (my/color-value 'yellow)
|
||
:underline (my/color-value 'yellow))
|
||
(tab-bar :background 'unspecified :foreground 'unspecified)
|
||
(magit-section-secondary-heading :foreground (my/color-value 'blue)
|
||
:weight 'bold))
|
||
#+end_src
|
||
**** Switch theme
|
||
The built-in =load-theme= does not deactivate the previous theme, so here's a function that does that:
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/switch-theme (theme)
|
||
(interactive
|
||
(list (intern (completing-read "Load custom theme: "
|
||
(mapcar #'symbol-name
|
||
(custom-available-themes))))))
|
||
(cl-loop for enabled-theme in custom-enabled-themes
|
||
if (not (or (eq enabled-theme 'my-theme-1)
|
||
(eq enabled-theme theme)))
|
||
do (disable-theme enabled-theme))
|
||
(load-theme theme t)
|
||
(when current-prefix-arg
|
||
(my/regenerate-desktop)))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(if my/is-termux
|
||
(progn
|
||
(my/switch-theme 'modus-operandi-tinted))
|
||
(my/switch-theme 'ef-duo-light))
|
||
#+end_src
|
||
**** Extending current theme
|
||
Colors that aren't set in themes.
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'transient
|
||
(my/use-colors
|
||
(transient-key-exit :foreground (my/color-value 'dark-red))
|
||
(transient-key-noop :foreground (my/color-value 'grey))
|
||
(transient-key-return :foreground (my/color-value 'yellow))
|
||
(transient-key-stay :foreground (my/color-value 'green))))
|
||
#+end_src
|
||
|
||
*** Dim inactive buffers
|
||
Dim inactive buffers.
|
||
#+begin_src emacs-lisp
|
||
(use-package auto-dim-other-buffers
|
||
:straight t
|
||
:if (display-graphic-p)
|
||
:config
|
||
(auto-dim-other-buffers-mode t)
|
||
(my/use-colors
|
||
(auto-dim-other-buffers-face
|
||
:background (my/color-value 'bg-other))))
|
||
#+end_src
|
||
*** ANSI colors
|
||
=ansi-color.el= is a built-in Emacs package that translates ANSI color escape codes into faces.
|
||
|
||
It is used by many other packages but doesn't seem to have an integration with =doom-themes=, so here is one.
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'ansi-color
|
||
(my/use-colors
|
||
(ansi-color-black
|
||
:foreground (my/color-value 'base2) :background (my/color-value 'base0))
|
||
(ansi-color-red
|
||
:foreground (my/color-value 'red) :background (my/color-value 'red))
|
||
(ansi-color-green
|
||
:foreground (my/color-value 'green) :background (my/color-value 'green))
|
||
(ansi-color-yellow
|
||
:foreground (my/color-value 'yellow) :background (my/color-value 'yellow))
|
||
(ansi-color-blue
|
||
:foreground (my/color-value 'dark-blue) :background (my/color-value 'dark-blue))
|
||
(ansi-color-magenta
|
||
:foreground (my/color-value 'violet) :background (my/color-value 'violet))
|
||
(ansi-color-cyan
|
||
:foreground (my/color-value 'dark-cyan) :background (my/color-value 'dark-cyan))
|
||
(ansi-color-white
|
||
:foreground (my/color-value 'base8) :background (my/color-value 'base8))
|
||
(ansi-color-bright-black
|
||
:foreground (my/color-value 'base5) :background (my/color-value 'base5))
|
||
(ansi-color-bright-red
|
||
:foreground (my/color-value 'orange) :background (my/color-value 'orange))
|
||
(ansi-color-bright-green
|
||
:foreground (my/color-value 'teal) :background (my/color-value 'teal))
|
||
(ansi-color-bright-yellow
|
||
:foreground (my/color-value 'yellow) :background (my/color-value 'yellow))
|
||
(ansi-color-bright-blue
|
||
:foreground (my/color-value 'blue) :background (my/color-value 'blue))
|
||
(ansi-color-bright-magenta
|
||
:foreground (my/color-value 'magenta) :background (my/color-value 'magenta))
|
||
(ansi-color-bright-cyan
|
||
:foreground (my/color-value 'cyan) :background (my/color-value 'cyan))
|
||
(ansi-color-bright-white
|
||
:foreground (my/color-value 'fg) :background (my/color-value 'fg))))
|
||
#+end_src
|
||
** Fonts
|
||
:PROPERTIES:
|
||
:MODULE_NAME: general-ui
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-general-ui.el :comments links
|
||
:END:
|
||
*** Frame font
|
||
To install a font, download the font and unpack it into the =.local/share/fonts= directory. Create one if it doesn't exist.
|
||
|
||
As I use nerd fonts elsewhere, I use one in Emacs as well.
|
||
|
||
References:
|
||
- [[https://nerdfonts.com][nerd fonts homepage]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(when (display-graphic-p)
|
||
(if (x-list-fonts "JetBrainsMono Nerd Font")
|
||
(let ((font "-JB -JetBrainsMono Nerd Font-medium-normal-normal-*-17-*-*-*-m-0-iso10646-1"))
|
||
(set-frame-font font nil t)
|
||
(add-to-list 'default-frame-alist `(font . ,font)))
|
||
(message "Install JetBrainsMono Nerd Font!")))
|
||
#+end_src
|
||
|
||
To make the icons work (e.g. in the Doom Modeline), run =M-x all-the-icons-install-fonts=. The package definition is somewhere later in the config.
|
||
*** Other fonts
|
||
#+begin_src emacs-lisp
|
||
(when (display-graphic-p)
|
||
(set-face-attribute 'variable-pitch nil :family "Cantarell" :height 1.0)
|
||
(set-face-attribute
|
||
'italic nil
|
||
:family "JetBrainsMono Nerd Font"
|
||
:weight 'regular
|
||
:slant 'italic))
|
||
#+end_src
|
||
*** Ligatures
|
||
Ligature setup for the JetBrainsMono font.
|
||
#+begin_src emacs-lisp
|
||
(use-package ligature
|
||
:straight (:host github :repo "mickeynp/ligature.el")
|
||
:if (display-graphic-p)
|
||
:config
|
||
(ligature-set-ligatures
|
||
'(
|
||
typescript-mode
|
||
typescript-ts-mode
|
||
js2-mode
|
||
javascript-ts-mode
|
||
vue-mode
|
||
svelte-mode
|
||
scss-mode
|
||
php-mode
|
||
python-mode
|
||
python-ts-mode
|
||
js-mode
|
||
markdown-mode
|
||
clojure-mode
|
||
go-mode
|
||
sh-mode
|
||
haskell-mode
|
||
web-mode)
|
||
'("--" "---" "==" "===" "!=" "!==" "=!=" "=:=" "=/=" "<="
|
||
">=" "&&" "&&&" "&=" "++" "+++" "***" ";;" "!!" "??"
|
||
"?:" "?." "?=" "<:" ":<" ":>" ">:" "<>" "<<<" ">>>"
|
||
"<<" ">>" "||" "-|" "_|_" "|-" "||-" "|=" "||=" "##"
|
||
"###" "####" "#{" "#[" "]#" "#(" "#?" "#_" "#_(" "#:"
|
||
"#!" "#=" "^=" "<$>" "<$" "$>" "<+>" "<+" "+>" "<*>"
|
||
"<*" "*>" "</" "</>" "/>" "<!--" "<#--" "-->" "->" "->>"
|
||
"<<-" "<-" "<=<" "=<<" "<<=" "<==" "<=>" "<==>" "==>" "=>"
|
||
"=>>" ">=>" ">>=" ">>-" ">-" ">--" "-<" "-<<" ">->" "<-<"
|
||
"<-|" "<=|" "|=>" "|->" "<->" "<~~" "<~" "<~>" "~~" "~~>"
|
||
"~>" "~-" "-~" "~@" "[||]" "|]" "[|" "|}" "{|" "[<"
|
||
">]" "|>" "<|" "||>" "<||" "|||>" "<|||" "<|>" "..." ".."
|
||
".=" ".-" "..<" ".?" "::" ":::" ":=" "::=" ":?" ":?>"
|
||
"//" "///" "/*" "*/" "/=" "//=" "/==" "@_" "__"))
|
||
(global-ligature-mode t))
|
||
#+end_src
|
||
*** Icons
|
||
I switched to [[https://github.com/rainstormstudio/nerd-icons.el][nerd-icons]] from [[https://github.com/domtronn/all-the-icons.el][all-the-icons]].
|
||
|
||
Run =M-x all-the-icons-install-fonts= at first setup.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package nerd-icons
|
||
:straight t)
|
||
#+end_src
|
||
** Text highlight
|
||
:PROPERTIES:
|
||
:MODULE_NAME: general-ui
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-general-ui.el :comments links
|
||
:END:
|
||
Highlight indent guides. I used [[https://github.com/DarthFennec/highlight-indent-guides][highlight-indent-guides]] before but [[https://github.com/jdtsmith/indent-bars][indent-bars]] seems to work better, and it doesn't break with =treesit-fold=.
|
||
#+begin_src emacs-lisp
|
||
(use-package indent-bars
|
||
:straight (:host github :repo "jdtsmith/indent-bars")
|
||
:if (display-graphic-p)
|
||
:hook ((prog-mode . indent-bars-mode)
|
||
(LaTeX-mode . indent-bars-mode))
|
||
:config
|
||
(require 'indent-bars-ts)
|
||
(setopt indent-bars-no-descend-lists t
|
||
indent-bars-treesit-support t
|
||
indent-bars-width-frac 0.3))
|
||
#+end_src
|
||
|
||
Rainbow parentheses.
|
||
#+begin_src emacs-lisp
|
||
(use-package rainbow-delimiters
|
||
:straight t
|
||
:hook ((prog-mode . rainbow-delimiters-mode)))
|
||
#+end_src
|
||
|
||
Highlight colors
|
||
#+begin_src emacs-lisp
|
||
(use-package rainbow-mode
|
||
:commands (rainbow-mode)
|
||
:straight t)
|
||
#+end_src
|
||
|
||
Highlight TODOs and stuff
|
||
#+begin_src emacs-lisp
|
||
(use-package hl-todo
|
||
:hook (prog-mode . hl-todo-mode)
|
||
:straight t)
|
||
#+end_src
|
||
** Doom Modeline
|
||
:PROPERTIES:
|
||
:MODULE_NAME: doom-modeline
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-doom-modeline.el :comments links
|
||
:END:
|
||
A modeline from Doom Emacs. A big advantage of this package is that it just works out of the box and does not require much customization.
|
||
|
||
I tried a bunch of other options, including [[https://github.com/TheBB/spaceline][spaceline]], but in the end, decided that Doom Modeline works best for me.
|
||
|
||
References:
|
||
- [[https://github.com/seagle0128/doom-modeline][Doom Modeline]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package doom-modeline
|
||
:straight t
|
||
;; :if (not (display-graphic-p))
|
||
:init
|
||
(setq doom-modeline-env-enable-python nil)
|
||
(setq doom-modeline-env-enable-go nil)
|
||
(setq doom-modeline-buffer-encoding 'nondefault)
|
||
(setq doom-modeline-hud t)
|
||
(setq doom-modeline-persp-icon nil)
|
||
(setq doom-modeline-persp-name nil)
|
||
(setq doom-modeline-display-misc-in-all-mode-lines nil)
|
||
(when my/is-termux
|
||
(setopt doom-modeline-icon nil))
|
||
:config
|
||
(setq doom-modeline-minor-modes nil)
|
||
(setq doom-modeline-irc nil)
|
||
(setq doom-modeline-buffer-state-icon nil)
|
||
(doom-modeline-mode 1))
|
||
#+end_src
|
||
*** Doom Modeline as Tab Bar
|
||
#+begin_src emacs-lisp
|
||
(defun my/tab-bar-mode-line--format ()
|
||
(unless (derived-mode-p 'company-box-mode)
|
||
(cl-letf (((symbol-function 'window-pixel-width)
|
||
'frame-pixel-width)
|
||
((symbol-function 'window-margins)
|
||
(lambda (&rest _)
|
||
(list nil))))
|
||
(let ((doom-modeline-window-width-limit nil)
|
||
(doom-modeline--limited-width-p nil))
|
||
(format-mode-line
|
||
'("%e"
|
||
(:eval
|
||
(doom-modeline-format--main))))))))
|
||
|
||
(defun my/hide-mode-line-if-only-window ()
|
||
(let* ((windows (window-list))
|
||
(hide-mode-line-p (length= windows 1)))
|
||
(dolist (win windows)
|
||
(with-current-buffer (window-buffer win)
|
||
(unless (eq hide-mode-line-p hide-mode-line-mode)
|
||
(hide-mode-line-mode
|
||
(if hide-mode-line-p +1 -1)))))))
|
||
|
||
(define-minor-mode my/tab-bar-mode-line-mode
|
||
"Use tab-bar as mode line mode."
|
||
:global t
|
||
(if my/tab-bar-mode-line-mode
|
||
(progn
|
||
(tab-bar-mode +1)
|
||
(setq tab-bar-format '(my/tab-bar-mode-line--format))
|
||
(set-face-attribute 'tab-bar nil :inherit 'mode-line)
|
||
(add-hook 'window-configuration-change-hook #'my/hide-mode-line-if-only-window)
|
||
|
||
(dolist (buf (buffer-list))
|
||
(with-current-buffer buf
|
||
(doom-modeline-set-modeline 'minimal)))
|
||
(doom-modeline-set-modeline 'minimal 'default)
|
||
|
||
(dolist (frame (frame-list))
|
||
(with-selected-frame frame
|
||
(my/hide-mode-line-if-only-window))
|
||
(when-let (cb-frame (company-box--get-frame frame))
|
||
(set-frame-parameter cb-frame 'tab-bar-lines 0)))
|
||
(setenv "POLYBAR_BOTTOM" "false")
|
||
(when (fboundp #'my/exwm-run-polybar)
|
||
(my/exwm-run-polybar)))
|
||
(tab-bar-mode -1)
|
||
(setq tab-bar-format
|
||
'(tab-bar-format-history tab-bar-format-tabs tab-bar-separator tab-bar-format-add-tab))
|
||
(set-face-attribute 'tab-bar nil :inherit 'default)
|
||
(remove-hook 'window-configuration-change-hook #'my/hide-mode-line-if-only-window)
|
||
(global-hide-mode-line-mode -1)
|
||
(dolist (buf (buffer-list))
|
||
(with-current-buffer buf
|
||
(doom-modeline-set-modeline 'main)))
|
||
(doom-modeline-set-modeline 'main 'default)
|
||
(setenv "POLYBAR_BOTTOM" "true")
|
||
(when (fboundp #'my/exwm-run-polybar)
|
||
(my/exwm-run-polybar))))
|
||
#+end_src
|
||
|
||
** perspective.el
|
||
:PROPERTIES:
|
||
:MODULE_NAME: perspective
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-perspective.el :comments links
|
||
:END:
|
||
|
||
[[https://github.com/nex3/perspective-el][perspective.el]] is a package that groups buffers in "perspectives".
|
||
|
||
=tab-bar.el= can be configured to behave in a similar way, but I'm too invested in this package already.
|
||
|
||
One thing I don't like is that the list perspectives is displayed in the modeline, but I'll probably look how to move them to the bar at the top of the frame at some point.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package perspective
|
||
:straight t
|
||
:init
|
||
;; (setq persp-show-modestring 'header)
|
||
(setq persp-sort 'created)
|
||
(setq persp-suppress-no-prefix-key-warning t)
|
||
:config
|
||
(persp-mode)
|
||
(my-leader-def "x" '(:keymap perspective-map :which-key "perspective"))
|
||
(general-define-key
|
||
:keymaps 'override
|
||
:states '(normal emacs)
|
||
"gt" 'persp-next
|
||
"gT" 'persp-prev
|
||
"gn" 'persp-switch
|
||
"gN" 'persp-kill)
|
||
(general-define-key
|
||
:keymaps 'perspective-map
|
||
"b" 'persp-switch-to-buffer
|
||
"x" 'persp-switch-to-buffer*
|
||
"u" 'persp-ibuffer))
|
||
#+end_src
|
||
*** Functions to manage buffers
|
||
Move the current buffer to a perspective and switch to it.
|
||
#+begin_src emacs-lisp
|
||
(defun my/persp-move-window-and-switch ()
|
||
(interactive)
|
||
(let* ((buffer (current-buffer)))
|
||
(call-interactively #'persp-switch)
|
||
(persp-set-buffer (buffer-name buffer))
|
||
(switch-to-buffer buffer)))
|
||
#+end_src
|
||
|
||
Copy the current buffer to a perspective and switch to it.
|
||
#+begin_src emacs-lisp
|
||
(defun my/persp-copy-window-and-switch ()
|
||
(interactive)
|
||
(let* ((buffer (current-buffer)))
|
||
(call-interactively #'persp-switch)
|
||
(persp-add-buffer (buffer-name buffer))
|
||
(switch-to-buffer buffer)))
|
||
#+end_src
|
||
|
||
Add keybindings to the default map.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'perspective
|
||
(general-define-key
|
||
:keymaps 'perspective-map
|
||
"m" #'my/persp-move-window-and-switch
|
||
"f" #'my/persp-copy-window-and-switch))
|
||
#+end_src
|
||
*** Automating perspectives
|
||
Out-of-the-box, =perspective.el= doesn't feature much (or any) capacity for automation. We're supposed to manually assign buffers to perspectives, which kinda makes sense... But I still want automation.
|
||
|
||
First, let's define a variable with "rules":
|
||
#+begin_src emacs-lisp
|
||
(setq my/perspective-assign-alist '())
|
||
#+end_src
|
||
|
||
One rule looks as follows:
|
||
#+begin_example
|
||
(major-mode workspace-index persp-name)
|
||
#+end_example
|
||
|
||
And a function to act on these rules.
|
||
#+begin_src emacs-lisp
|
||
(defvar my/perspective-assign-ignore nil
|
||
"If non-nil, ignore `my/perspective-assign'")
|
||
|
||
(defun my/perspective-assign ()
|
||
(when-let* ((_ (not my/perspective-assign-ignore))
|
||
(rule (alist-get major-mode my/perspective-assign-alist)))
|
||
(let ((workspace-index (car rule))
|
||
(persp-name (cadr rule))
|
||
(buffer (current-buffer)))
|
||
(if (fboundp #'perspective-exwm-assign-window)
|
||
(progn
|
||
(perspective-exwm-assign-window
|
||
:workspace-index workspace-index
|
||
:persp-name persp-name)
|
||
(when workspace-index
|
||
(exwm-workspace-switch workspace-index))
|
||
(when persp-name
|
||
(persp-switch persp-name)))
|
||
(with-perspective persp-name
|
||
(persp-set-buffer buffer))
|
||
(persp-switch-to-buffer buffer)))))
|
||
#+end_src
|
||
|
||
Also advise to ignore the assignment:
|
||
#+begin_src emacs-lisp
|
||
(defun my/perspective-assign-ignore-advice (fun &rest args)
|
||
(let ((my/perspective-assign-ignore t))
|
||
(apply fun args)))
|
||
#+end_src
|
||
|
||
If EXWM is available, then so is mine =perspective-exwm= package, which features a convenient procedure called =perspective-exwm-assign-window=. Otherwise, we just work with perspectives.
|
||
|
||
Now, we have to put this function somewhere, and =after-change-major-mode-hook= seems like a perfect place for it.
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'after-change-major-mode-hook #'my/perspective-assign)
|
||
#+end_src
|
||
|
||
And here is a simple macro to add rules to the list.
|
||
#+begin_src emacs-lisp
|
||
(defmacro my/persp-add-rule (&rest body)
|
||
(declare (indent 0))
|
||
(unless (= (% (length body) 3) 0)
|
||
(error "Malformed body in my/persp-add-rule"))
|
||
(let (result)
|
||
(while body
|
||
(let ((major-mode (pop body))
|
||
(workspace-index (pop body))
|
||
(persp-name (pop body)))
|
||
(push
|
||
`(add-to-list 'my/perspective-assign-alist
|
||
'(,major-mode . (,workspace-index ,persp-name)))
|
||
result)))
|
||
`(progn
|
||
,@result)))
|
||
#+end_src
|
||
|
||
Also, the logic above works only for cases when the buffer is created. Occasionally, packages run =switch-to-buffer=, which screws both EXWM workspaces and perspectives; to work around that, I define a macro that runs a command in the context of a given perspective and workspace.
|
||
#+begin_src emacs-lisp
|
||
(defmacro my/command-in-persp (command-name persp-name workspace-index &rest args)
|
||
`'((lambda ()
|
||
(interactive)
|
||
(when (and ,workspace-index (fboundp #'exwm-workspace-switch-create))
|
||
(exwm-workspace-switch-create ,workspace-index))
|
||
(persp-switch ,persp-name)
|
||
(delete-other-windows)
|
||
,@args)
|
||
:wk ,command-name))
|
||
#+end_src
|
||
|
||
This is meant to be used in the definitions of =general.el=.
|
||
* Programming
|
||
** General setup
|
||
*** Treemacs
|
||
:PROPERTIES:
|
||
:MODULE_NAME: treemacs
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-treemacs.el :comments links
|
||
:END:
|
||
[[https://github.com/Alexander-Miller/treemacs][Treemacs]] is a rather large & powerful package, but as of now I've replaced it with dired. I still have a small configuration because lsp-mode and dap-mode depend on it.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package treemacs
|
||
:straight t
|
||
:defer t
|
||
:config
|
||
;; (setq treemacs-follow-mode nil)
|
||
;; (setq treemacs-follow-after-init nil)
|
||
(setq treemacs-space-between-root-nodes nil)
|
||
;; (treemacs-git-mode 'extended)
|
||
;; (add-to-list 'treemacs-pre-file-insert-predicates #'treemacs-is-file-git-ignored?)
|
||
(general-define-key
|
||
:keymaps 'treemacs-mode-map
|
||
[mouse-1] #'treemacs-single-click-expand-action
|
||
"M-l" #'treemacs-root-down
|
||
"M-h" #'treemacs-root-up
|
||
"q" #'treemacs-quit)
|
||
(general-define-key
|
||
:keymaps 'treemacs-mode-map
|
||
:states '(normal emacs)
|
||
"q" 'treemacs-quit))
|
||
|
||
(use-package treemacs-evil
|
||
:after (treemacs evil)
|
||
:straight t)
|
||
#+end_src
|
||
*** LSP
|
||
:PROPERTIES:
|
||
:MODULE_NAME: lsp
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-lsp.el :comments links
|
||
:END:
|
||
LSP-mode provides an IDE-like experience for Emacs - real-time diagnostics, code actions, intelligent autocompletion, etc.
|
||
|
||
References:
|
||
- [[https://emacs-lsp.github.io/lsp-mode/][lsp-mode homepage]]
|
||
|
||
**** Setup
|
||
#+begin_src emacs-lisp
|
||
(use-package lsp-mode
|
||
:straight t
|
||
:hook (
|
||
(typescript-mode . lsp)
|
||
(js-mode . lsp)
|
||
(vue-mode . lsp)
|
||
(go-mode . lsp)
|
||
(svelte-mode . lsp)
|
||
;; (python-mode . lsp)
|
||
(json-mode . lsp)
|
||
(haskell-mode . lsp)
|
||
(haskell-literate-mode . lsp)
|
||
(java-mode . lsp)
|
||
;; (csharp-mode . lsp)
|
||
)
|
||
:commands lsp
|
||
:init
|
||
(setq lsp-keymap-prefix nil)
|
||
:config
|
||
(setq lsp-idle-delay 1)
|
||
(setq lsp-eslint-server-command '("node" "/home/pavel/.emacs.d/.cache/lsp/eslint/unzipped/extension/server/out/eslintServer.js" "--stdio"))
|
||
(setq lsp-eslint-run "onSave")
|
||
(setq lsp-signature-render-documentation nil)
|
||
;; (lsp-headerline-breadcrumb-mode nil)
|
||
(setq lsp-headerline-breadcrumb-enable nil)
|
||
(setq lsp-modeline-code-actions-enable nil)
|
||
(setq lsp-modeline-diagnostics-enable nil)
|
||
(setq lsp-volar-take-over-mode nil)
|
||
(add-to-list 'lsp-language-id-configuration '(svelte-mode . "svelte")))
|
||
|
||
(use-package lsp-ui
|
||
:straight t
|
||
:commands lsp-ui-mode
|
||
:config
|
||
(setq lsp-ui-doc-delay 2)
|
||
(setq lsp-ui-sideline-show-hover nil))
|
||
|
||
(use-package all-the-icons
|
||
:straight t)
|
||
#+end_src
|
||
|
||
**** Keybindings
|
||
#+begin_src emacs-lisp
|
||
(my-leader-def
|
||
:infix "l"
|
||
"" '(:which-key "lsp")
|
||
"d" 'lsp-ui-peek-find-definitions
|
||
"r" 'lsp-rename
|
||
"u" 'lsp-ui-peek-find-references
|
||
"s" 'lsp-ui-find-workspace-symbol
|
||
"l" 'lsp-execute-code-action
|
||
"e" 'list-flycheck-errors)
|
||
#+end_src
|
||
**** UI
|
||
I don't like how some language servers print the full filename in the progress indicator.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/lsp--progress-status ()
|
||
"Returns the status of the progress for the current workspaces."
|
||
(-let ((progress-status
|
||
(s-join
|
||
"|"
|
||
(-keep
|
||
(lambda (workspace)
|
||
(let ((tokens (lsp--workspace-work-done-tokens workspace)))
|
||
(unless (ht-empty? tokens)
|
||
(mapconcat
|
||
(-lambda ((&WorkDoneProgressBegin :message? :title :percentage?))
|
||
(concat (if percentage?
|
||
(if (numberp percentage?)
|
||
(format "%.0f%%%% " percentage?)
|
||
(format "%s%%%% " percentage?))
|
||
"")
|
||
(let ((msg (url-unhex-string (or message\? title))))
|
||
(if (string-match-p "\\`file:///" msg)
|
||
(file-name-nondirectory msg)))))
|
||
(ht-values tokens)
|
||
"|"))))
|
||
(lsp-workspaces)))))
|
||
(unless (s-blank? progress-status)
|
||
(concat lsp-progress-prefix progress-status))))
|
||
|
||
(with-eval-after-load 'lsp-mode
|
||
(advice-add 'lsp--progress-status :override #'my/lsp--progress-status))
|
||
#+end_src
|
||
**** Fix vue-semantic-server
|
||
Somehow, for me [[https://github.com/vuejs/language-tools][vue-semantic-server]] sends an empty =textDocument/publishDiagnostics= message a second or two after the real one, causing the diagnostics to disappear.
|
||
|
||
For now, I've advised this away by ignoring empty messages unless they show up more than 5 seconds after a non-empty message.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq my/lsp--vue-diagnostics-last-update (make-hash-table :test #'equal))
|
||
|
||
(defun my/lsp--on-diagnostics (fn workspace params)
|
||
(if (equal (gethash 'vue-semantic-server lsp-clients)
|
||
(lsp--workspace-client workspace))
|
||
(progn
|
||
(let* ((is-empty (seq-empty-p (gethash "diagnostics" params)))
|
||
(uri (gethash "uri" params))
|
||
(last-update (gethash uri my/lsp--vue-diagnostics-last-update))
|
||
(current-update (time-convert nil #'integer)))
|
||
(unless is-empty
|
||
(puthash uri current-update my/lsp--vue-diagnostics-last-update))
|
||
(when (or (not is-empty)
|
||
(not last-update)
|
||
(> (- current-update (or last-update 0)) 5))
|
||
(funcall fn workspace params))))
|
||
(funcall fn workspace params)))
|
||
|
||
(with-eval-after-load 'lsp
|
||
(advice-add #'lsp--on-diagnostics :around #'my/lsp--on-diagnostics))
|
||
#+end_src
|
||
*** Flycheck
|
||
:PROPERTIES:
|
||
:MODULE_NAME: flycheck
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-flycheck.el :comments links
|
||
:END:
|
||
A syntax checking extension for Emacs. Integrates with LSP-mode, but can also use various standalone checkers.
|
||
|
||
References:
|
||
- [[https://www.flycheck.org/en/latest/][Flycheck homepage]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package flycheck
|
||
:straight t
|
||
:config
|
||
(global-flycheck-mode)
|
||
(setq flycheck-check-syntax-automatically '(save idle-buffer-switch mode-enabled))
|
||
(advice-add 'flycheck-eslint-config-exists-p :override (lambda() t))
|
||
(add-to-list 'display-buffer-alist
|
||
`(,(rx bos "*Flycheck errors*" eos)
|
||
(display-buffer-reuse-window
|
||
display-buffer-in-side-window)
|
||
(side . bottom)
|
||
(reusable-frames . visible)
|
||
(window-height . 0.33))))
|
||
#+end_src
|
||
*** Tree-sitter
|
||
:PROPERTIES:
|
||
:MODULE_NAME: tree-sitter
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-tree-sitter.el :comments links
|
||
:END:
|
||
References:
|
||
- [[https://tree-sitter.github.io/tree-sitter/using-parsers#query-syntax][Tree sitter query syntax]]
|
||
- [[https://www.gnu.org/software/emacs//manual/html_node/elisp/Parsing-Program-Source.html][treesit.el documentation]]
|
||
|
||
**** treesit.el
|
||
Tree-Sitter integration with Emacs 29.
|
||
|
||
References:
|
||
- [[https://www.masteringemacs.org/article/how-to-get-started-tree-sitter][How to Get Started with Tree-Sitter - Mastering Emacs]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package treesit
|
||
:straight (:type built-in)
|
||
:if (featurep 'treesit)
|
||
:config
|
||
(setq treesit-language-source-alist
|
||
(mapcar
|
||
(lambda (item)
|
||
(let ((lang (nth 0 item))
|
||
(url (nth 1 item))
|
||
(rev (nth 2 item))
|
||
(source-dir (nth 3 item)))
|
||
`(,lang ,url ,rev ,source-dir
|
||
,(executable-find "gcc") ,(executable-find "c++"))))
|
||
'((bash "https://github.com/tree-sitter/tree-sitter-bash")
|
||
(cmake "https://github.com/uyha/tree-sitter-cmake")
|
||
(css "https://github.com/tree-sitter/tree-sitter-css")
|
||
(elisp "https://github.com/Wilfred/tree-sitter-elisp")
|
||
(go "https://github.com/tree-sitter/tree-sitter-go")
|
||
(html "https://github.com/tree-sitter/tree-sitter-html")
|
||
(javascript "https://github.com/tree-sitter/tree-sitter-javascript" "master" "src")
|
||
(json "https://github.com/tree-sitter/tree-sitter-json")
|
||
(make "https://github.com/alemuller/tree-sitter-make")
|
||
(markdown "https://github.com/ikatyang/tree-sitter-markdown")
|
||
(python "https://github.com/tree-sitter/tree-sitter-python")
|
||
(toml "https://github.com/tree-sitter/tree-sitter-toml")
|
||
(tsx "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src")
|
||
(typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src")
|
||
(yaml "https://github.com/ikatyang/tree-sitter-yaml"))))
|
||
(setq treesit-font-lock-level 4)
|
||
(setq major-mode-remap-alist
|
||
'((typescript-mode . typescript-ts-mode)
|
||
(js-mode . javascript-ts-mode)
|
||
(python-mode . python-ts-mode)
|
||
(json-mode . json-ts-mode)))
|
||
(cl-loop for (old-mode . new-mode) in major-mode-remap-alist
|
||
do (my/set-smartparens-indent new-mode)
|
||
do (set (intern (concat (symbol-name new-mode) "-hook"))
|
||
(list
|
||
(eval `(lambda ()
|
||
(run-hooks
|
||
',(intern (concat (symbol-name old-mode) "-hook")))))))))
|
||
#+end_src
|
||
**** treesit-fold
|
||
[[https://github.com/emacs-tree-sitter/treesit-fold][treesit-fold]] uses treesit.el for folding.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package treesit-fold
|
||
:straight (treesit-fold :type git :host github :repo "emacs-tree-sitter/treesit-fold")
|
||
:commands (treesit-fold-mode))
|
||
#+end_src
|
||
|
||
***** Hide children of current node
|
||
A function to hide children of the current foldable node. This is somewhat similar to =hs-hide-level=, which I got used to.
|
||
|
||
First, find all foldable nodes:
|
||
#+begin_src emacs-lisp
|
||
(defun my/treesit-fold--get-nodes-to-fold ()
|
||
(when-let*
|
||
((node (ignore-errors (treesit-buffer-root-node)))
|
||
(patterns (seq-mapcat (lambda (fold-range) `((,(car fold-range)) @name))
|
||
(alist-get major-mode treesit-fold-range-alist)))
|
||
(query (ignore-errors
|
||
(treesit-query-compile (treesit-node-language node)
|
||
patterns)))
|
||
(nodes-to-fold (treesit-query-capture node query))
|
||
(mode-ranges (alist-get major-mode treesit-fold-range-alist))
|
||
(nodes-to-fold
|
||
(cl-remove-if (lambda (node)
|
||
(treesit-fold--non-foldable-node-p (cdr node) mode-ranges))
|
||
nodes-to-fold)))
|
||
nodes-to-fold))
|
||
#+end_src
|
||
|
||
Then filter children of the current foldable node and fold them:
|
||
#+begin_src emacs-lisp
|
||
(defun my/treesit-fold-hide-children ()
|
||
(interactive)
|
||
(let* ((current-node (treesit-fold--foldable-node-at-pos))
|
||
(all-nodes-to-fold (my/treesit-fold--get-nodes-to-fold))
|
||
;; Find foldable children of `current-node'
|
||
(target-nodes-to-fold
|
||
(seq-filter
|
||
(lambda (n)
|
||
(cl-block tree-iter
|
||
(while n
|
||
(setq n (treesit-node-parent n))
|
||
(when (equal n current-node)
|
||
(cl-return-from tree-iter t)))))
|
||
(mapcar #'cdr all-nodes-to-fold))))
|
||
(dolist (node target-nodes-to-fold)
|
||
(treesit-fold-close node))))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/evil-fold-hide-level ()
|
||
(interactive)
|
||
(cond
|
||
(hs-minor-mode (hs-hide-level))
|
||
(treesit-fold-mode (my/treesit-fold-hide-children))))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'treesit-fold
|
||
(general-define-key
|
||
:states '(normal)
|
||
"ze" #'my/evil-fold-hide-level)
|
||
(keymap-unset evil-motion-state-map "z e" t))
|
||
#+end_src
|
||
|
||
**** combobulate
|
||
[[https://github.com/mickeynp/combobulate][combobulate]] is Mickey Peterson's package that uses =tree-sitter= for structural navigation.
|
||
|
||
I'm not actually using that, but its query editor is pretty useful to debug =tree-sitter= queries.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package combobulate
|
||
:straight (:host github :repo "mickeynp/combobulate")
|
||
:commands (combobulate))
|
||
#+end_src
|
||
*** DAP
|
||
:PROPERTIES:
|
||
:MODULE_NAME: dap
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-dap.el :comments links
|
||
:END:
|
||
An Emacs client for Debugger Adapter Protocol.
|
||
|
||
Okay, so, I tried to use it many times... Chrome DevTools and ipdb / pudb are just better for me. Maybe I'll check out RealGUD instead... Will see.
|
||
|
||
References:
|
||
- [[https://emacs-lsp.github.io/dap-mode/][dap-mode homepage]]
|
||
#+begin_src emacs-lisp
|
||
(use-package dap-mode
|
||
:straight t
|
||
:if (not (or my/remote-server my/is-termux))
|
||
:commands (dap-debug)
|
||
:init
|
||
(setq lsp-enable-dap-auto-configure nil)
|
||
:config
|
||
|
||
(setq dap-ui-variable-length 100)
|
||
(setq dap-auto-show-output nil)
|
||
(require 'dap-node)
|
||
(dap-node-setup)
|
||
|
||
(require 'dap-chrome)
|
||
(dap-chrome-setup)
|
||
|
||
(require 'dap-python)
|
||
(require 'dap-php)
|
||
|
||
(dap-mode 1)
|
||
(dap-ui-mode 1)
|
||
(dap-tooltip-mode 1)
|
||
(tooltip-mode 1))
|
||
#+end_src
|
||
|
||
**** Controls
|
||
I don't like some keybindings in the built-in hydra, and there seems to be no easy way to modify the existing hydra, so I create my own. I tried to use transient, but the transient buffer seems to conflict with special buffers of DAP, and hydra does not.
|
||
|
||
Also, I want the hydra to toggle UI windows instead of just opening them, so here is a macro that defines such functions:
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'dap-mode
|
||
(defmacro my/define-dap-ui-window-toggler (name)
|
||
`(defun ,(intern (concat "my/dap-ui-toggle-" name)) ()
|
||
,(concat "Toggle DAP " name "buffer")
|
||
(interactive)
|
||
(if-let (window (get-buffer-window ,(intern (concat "dap-ui--" name "-buffer"))))
|
||
(quit-window nil window)
|
||
(,(intern (concat "dap-ui-" name))))))
|
||
|
||
(my/define-dap-ui-window-toggler "locals")
|
||
(my/define-dap-ui-window-toggler "expressions")
|
||
(my/define-dap-ui-window-toggler "sessions")
|
||
(my/define-dap-ui-window-toggler "breakpoints")
|
||
(my/define-dap-ui-window-toggler "repl"))
|
||
#+end_src
|
||
|
||
And here is the hydra:
|
||
#+begin_src emacs-lisp
|
||
(defhydra my/dap-hydra (:color pink :hint nil :foreign-keys run)
|
||
"
|
||
^Stepping^ ^UI^ ^Switch^ ^Breakpoints^ ^Debug^ ^Expressions
|
||
^^^^^^^^------------------------------------------------------------------------------------------------------------------------------------------
|
||
_n_: Next _uc_: Controls _ss_: Session _bb_: Toggle _dd_: Debug _ee_: Eval
|
||
_i_: Step in _ue_: Expressions _st_: Thread _bd_: Delete _dr_: Debug recent _er_: Eval region
|
||
_o_: Step out _ul_: Locals _sf_: Stack frame _ba_: Add _dl_: Debug last _es_: Eval thing at point
|
||
_c_: Continue _ur_: REPL _su_: Up stack frame _bc_: Set condition _de_: Edit debug template _ea_: Add expression
|
||
_r_: Restart frame _uo_: Output _sd_: Down stack frame _bh_: Set hit count _Q_: Disconnect _ed_: Remove expression
|
||
_us_: Sessions _sF_: Stack frame filtered _bl_: Set log message _eu_: Refresh expressions
|
||
_ub_: Breakpoints "
|
||
|
||
("n" dap-next)
|
||
("i" dap-step-in)
|
||
("o" dap-step-out)
|
||
("c" dap-continue)
|
||
("r" dap-restart-frame)
|
||
("uc" dap-ui-controls-mode)
|
||
("ue" my/dap-ui-toggle-expressions)
|
||
("ul" my/dap-ui-toggle-locals)
|
||
("ur" my/dap-ui-toggle-repl)
|
||
("uo" dap-go-to-output-buffer)
|
||
("us" my/dap-ui-toggle-sessions)
|
||
("ub" my/dap-ui-toggle-breakpoints)
|
||
("ss" dap-switch-session)
|
||
("st" dap-switch-thread)
|
||
("sf" dap-switch-stack-frame)
|
||
("sF" my/dap-switch-stack-frame)
|
||
("su" dap-up-stack-frame)
|
||
("sd" dap-down-stack-frame)
|
||
("bb" dap-breakpoint-toggle)
|
||
("ba" dap-breakpoint-add)
|
||
("bd" dap-breakpoint-delete)
|
||
("bc" dap-breakpoint-condition)
|
||
("bh" dap-breakpoint-hit-condition)
|
||
("bl" dap-breakpoint-log-message)
|
||
("dd" dap-debug)
|
||
("dr" dap-debug-recent)
|
||
("dl" dap-debug-last)
|
||
("de" dap-debug-edit-template)
|
||
("ee" dap-eval)
|
||
("ea" dap-ui-expressions-add)
|
||
("er" dap-eval-region)
|
||
("es" dap-eval-thing-at-point)
|
||
("ed" dap-ui-expressions-remove)
|
||
("eu" dap-ui-expressions-refresh)
|
||
("q" nil "quit" :color blue)
|
||
("Q" dap-disconnect :color red))
|
||
|
||
(my-leader-def "d" #'my/dap-hydra/body)
|
||
#+end_src
|
||
**** UI Fixes
|
||
There are some problems with DAP UI in my setup.
|
||
|
||
First, DAP uses Treemacs buffers quite extensively, and they hide the doom modeline for some reason, so I can't tell which buffer is active and can't see borders between buffers.
|
||
|
||
Second, lines are truncated in some strange way, but calling =toggle-truncate-lines= seems to fix that.
|
||
|
||
So I define a macro that creates a function that I can further use in advices.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defvar my/dap-mode-buffer-fixed nil)
|
||
|
||
(with-eval-after-load 'dap-mode
|
||
(defmacro my/define-dap-tree-buffer-fixer (buffer-var buffer-name)
|
||
`(defun ,(intern (concat "my/fix-dap-ui-" buffer-name "-buffer")) (&rest _)
|
||
(with-current-buffer ,buffer-var
|
||
(unless my/dap-mode-buffer-fixed
|
||
(toggle-truncate-lines 1)
|
||
(doom-modeline-set-modeline 'info)
|
||
(setq-local my/dap-mode-buffer-fixed t)))))
|
||
|
||
(my/define-dap-tree-buffer-fixer dap-ui--locals-buffer "locals")
|
||
(my/define-dap-tree-buffer-fixer dap-ui--expressions-buffer "expressions")
|
||
(my/define-dap-tree-buffer-fixer dap-ui--sessions-buffer "sessions")
|
||
(my/define-dap-tree-buffer-fixer dap-ui--breakpoints-buffer "breakpoints")
|
||
|
||
(advice-add 'dap-ui-locals :after #'my/fix-dap-ui-locals-buffer)
|
||
(advice-add 'dap-ui-expressions :after #'my/fix-dap-ui-expressions-buffer)
|
||
(advice-add 'dap-ui-sessions :after #'my/fix-dap-ui-sessions-buffer)
|
||
(advice-add 'dap-ui-breakpoints :after #'my/fix-dap-ui-breakpoints-buffer))
|
||
#+end_src
|
||
**** Helper functions
|
||
Some helper functions that make debugging with DAP easier.
|
||
|
||
DAP seems to mess with window parameters from time to time. This function clears "bad" window parameters.
|
||
#+begin_src emacs-lisp
|
||
(defun my/clear-bad-window-parameters ()
|
||
"Clear window parameters that interrupt my workflow."
|
||
(interactive)
|
||
(let ((window (get-buffer-window (current-buffer))))
|
||
(set-window-parameter window 'no-delete-other-windows nil)))
|
||
#+end_src
|
||
|
||
A function to kill a value from a treemacs node.
|
||
#+begin_src emacs-lisp
|
||
(defun my/dap-yank-value-at-point (node)
|
||
(interactive (list (treemacs-node-at-point)))
|
||
(kill-new (message (plist-get (button-get node :item) :value))))
|
||
#+end_src
|
||
|
||
A function to open a value from a treemacs node in a new buffer.
|
||
#+begin_src emacs-lisp
|
||
(defun my/dap-display-value (node)
|
||
(interactive (list (treemacs-node-at-point)))
|
||
(let ((value (plist-get (button-get node :item) :value)))
|
||
(when value
|
||
(let ((buffer (generate-new-buffer "dap-value")))
|
||
(with-current-buffer buffer
|
||
(insert value))
|
||
(select-window (display-buffer buffer))))))
|
||
#+end_src
|
||
**** Switch to stack frame with filter
|
||
One significant improvement over Chrome Inspector for my particular stack is an ability to filter the stack frame list, for instance, to see only frames that relate to my current project.
|
||
|
||
So, here are functions that customize the filters:
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'dap-mode
|
||
(setq my/dap-stack-frame-filters
|
||
`(("node_modules,node:internal" . ,(rx (or "node_modules" "node:internal")))
|
||
("node_modules" . ,(rx (or "node_modules")))
|
||
("node:internal" . ,(rx (or "node:internal")))))
|
||
|
||
(setq my/dap-stack-frame-current-filter (cdar my/dap-stack-frame-filters))
|
||
|
||
(defun my/dap-stack-frame-filter-set ()
|
||
(interactive)
|
||
(setq my/dap-stack-frame-current-filter
|
||
(cdr
|
||
(assoc
|
||
(completing-read "Filter: " my/dap-stack-frame-filters)
|
||
my/dap-stack-frame-filters))))
|
||
|
||
(defun my/dap-stack-frame-filter (frame)
|
||
(when-let (path (dap--get-path-for-frame frame))
|
||
(not (string-match my/dap-stack-frame-current-filter path)))))
|
||
#+end_src
|
||
|
||
And here is a version of =dap-switch-stack-frame= that uses the said filter.
|
||
#+begin_src emacs-lisp
|
||
(defun my/dap-switch-stack-frame ()
|
||
"Switch stackframe by selecting another stackframe stackframes from current thread."
|
||
(interactive)
|
||
(when (not (dap--cur-session))
|
||
(error "There is no active session"))
|
||
|
||
(-if-let (thread-id (dap--debug-session-thread-id (dap--cur-session)))
|
||
(-if-let (stack-frames
|
||
(gethash
|
||
thread-id
|
||
(dap--debug-session-thread-stack-frames (dap--cur-session))))
|
||
(let* ((index 0)
|
||
(stack-framces-filtered
|
||
(-filter
|
||
#'my/dap-stack-frame-filter
|
||
stack-frames))
|
||
(new-stack-frame
|
||
(dap--completing-read
|
||
"Select active frame: "
|
||
stack-framces-filtered
|
||
(-lambda ((frame &as &hash "name"))
|
||
(if-let (frame-path (dap--get-path-for-frame frame))
|
||
(format "%s: %s (in %s)"
|
||
(cl-incf index) name frame-path)
|
||
(format "%s: %s" (cl-incf index) name)))
|
||
nil
|
||
t)))
|
||
(dap--go-to-stack-frame (dap--cur-session) new-stack-frame))
|
||
(->> (dap--cur-session)
|
||
dap--debug-session-name
|
||
(format "Current session %s is not stopped")
|
||
error))
|
||
(error "No thread is currently active %s" (dap--debug-session-name (dap--cur-session)))))
|
||
#+end_src
|
||
**** Smarter switch to stack frame
|
||
- *CREDIT*: Thanks @yyoncho on the Emacs LSP Discord for helping me with this!
|
||
|
||
By default, when a breakpoint is hit, dap always pop us the buffer in the active EXWM workspace and in the active perspective. I'd like it to switch to an existing buffer instead.
|
||
|
||
So first we need to locate EXWM workspace for the file with =path=:
|
||
#+begin_src emacs-lisp
|
||
(defun my/exwm-perspective-find-buffer (path)
|
||
"Find a buffer with PATH in all EXWM perspectives.
|
||
|
||
Returns (<buffer> . <workspace-index>) or nil."
|
||
(let* ((buf (cl-loop for buf being buffers
|
||
if (and (buffer-file-name buf)
|
||
(f-equal-p (buffer-file-name buf) path))
|
||
return buf))
|
||
(target-workspace
|
||
(and buf
|
||
(cl-loop for frame in exwm-workspace--list
|
||
if (with-selected-frame frame
|
||
(cl-loop for persp-name being the hash-keys of (perspectives-hash)
|
||
if (member buf (persp-buffers
|
||
(gethash persp-name (perspectives-hash))))
|
||
return persp-name))
|
||
return (cl-position frame exwm-workspace--list)))))
|
||
(when target-workspace (cons buf target-workspace))))
|
||
#+end_src
|
||
|
||
And override =dap--go-to-stack-frame= to take that into account. For some reason, evaluating this before =dap-mode= doesn't work.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/dap--go-to-stack-frame-override (debug-session stack-frame)
|
||
"Make STACK-FRAME the active STACK-FRAME of DEBUG-SESSION."
|
||
(with-lsp-workspace (dap--debug-session-workspace debug-session)
|
||
(when stack-frame
|
||
(-let* (((&hash "line" line "column" column "name" name) stack-frame)
|
||
(path (dap--get-path-for-frame stack-frame)))
|
||
(setf (dap--debug-session-active-frame debug-session) stack-frame)
|
||
;; If we have a source file with path attached, open it and
|
||
;; position the point in the line/column referenced in the
|
||
;; stack trace.
|
||
(if (and path (file-exists-p path))
|
||
(progn
|
||
(let ((exwm-target (my/exwm-perspective-find-buffer path)))
|
||
(if exwm-target
|
||
(progn
|
||
(unless (= (cdr exwm-target) exwm-workspace-current-index)
|
||
(exwm-workspace-switch (cdr exwm-target)))
|
||
(persp-switch-to-buffer (car exwm-target)))
|
||
(select-window (get-mru-window (selected-frame) nil))
|
||
(find-file path)))
|
||
(goto-char (point-min))
|
||
(forward-line (1- line))
|
||
(forward-char column))
|
||
(message "No source code for %s. Cursor at %s:%s." name line column))))
|
||
(run-hook-with-args 'dap-stack-frame-changed-hook debug-session)))
|
||
|
||
(with-eval-after-load 'exwm
|
||
(with-eval-after-load 'dap-mode
|
||
(advice-add #'dap--go-to-stack-frame :override #'my/dap--go-to-stack-frame-override)))
|
||
|
||
;; (advice-remove #'dap--go-to-stack-frame #'my/dap--go-to-stack-frame-override)
|
||
#+end_src
|
||
**** Debug templates
|
||
Some debug templates I frequently use.
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'dap-mode
|
||
(dap-register-debug-template
|
||
"Node::Nest.js"
|
||
(list :type "node"
|
||
:request "attach"
|
||
:name "Node::Attach"
|
||
:port 9229
|
||
:outFiles ["${workspaceFolder}/dist/**/*.js"]
|
||
:sourceMaps t
|
||
:program "${workspaceFolder}/src/app.ts"))
|
||
(dap-register-debug-template
|
||
"Node::Babel"
|
||
(list :type "node"
|
||
:request "attach"
|
||
:name "Node::Attach"
|
||
:port 9229
|
||
:program "${workspaceFolder}/dist/bin/www.js")))
|
||
#+end_src
|
||
*** Reformatter
|
||
:PROPERTIES:
|
||
:MODULE_NAME: reformatter
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-reformatter.el :comments links
|
||
:END:
|
||
A general-purpose package to run formatters on files. While the most popular formatters are already packaged for Emacs, those that aren't can be invoked with this package.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package reformatter
|
||
:straight t)
|
||
#+end_src
|
||
*** copilot
|
||
:PROPERTIES:
|
||
:MODULE_NAME: copilot
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-copilot.el :comments links
|
||
:END:
|
||
[[https://copilot.github.com/][GitHub Copilot]] is a project of GitHub and OpenAI that provides code completions.
|
||
|
||
| Type | Note |
|
||
|--------+-----------------------------------------------|
|
||
| *TODO* | Figure out how to bypass U.S. export controls |
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/copilot-tab ()
|
||
(interactive)
|
||
(or (copilot-accept-completion)
|
||
(when (my/should-run-emmet-p) (my/emmet-or-tab))
|
||
(when (and (eq evil-state 'normal)
|
||
(or hs-minor-mode treesit-fold-mode outline-minor-mode))
|
||
(evil-toggle-fold)
|
||
t)
|
||
(indent-for-tab-command)))
|
||
|
||
(use-package copilot
|
||
:straight (:host github :repo "copilot-emacs/copilot.el")
|
||
:commands (copilot-mode)
|
||
:disabled t
|
||
:init
|
||
(add-hook 'emacs-startup-hook
|
||
(lambda ()
|
||
(add-hook 'prog-mode-hook #'copilot-mode)))
|
||
:config
|
||
(push '(copilot) warning-suppress-types)
|
||
(setq copilot-node-executable "/home/pavel/.guix-extra-profiles/dev/dev/bin/node")
|
||
(general-define-key
|
||
:keymaps 'company-active-map
|
||
"<backtab>" #'my/copilot-tab)
|
||
(general-define-key
|
||
:keymaps 'copilot-mode-map
|
||
"<tab>" #'my/copilot-tab
|
||
"M-j" #'copilot-accept-completion-by-line
|
||
"M-l" #'copilot-accept-completion-by-word))
|
||
#+end_src
|
||
** Web development
|
||
:PROPERTIES:
|
||
:MODULE_NAME: web
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-web.el :comments links
|
||
:END:
|
||
Configs for various web development technologies I'm using.
|
||
*** Emmet
|
||
[[Https://emmet.io/][Emmet]] is a toolkit which greatly speeds up typing HTML & CSS.
|
||
|
||
| Type | Note |
|
||
|------+---------------------------------------------------|
|
||
| TODO | make expand div[disabled] as <div disabled></div> |
|
||
|
||
My bit of config here:
|
||
- makes =TAB= the only key I have to use
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/should-run-emmet-p ()
|
||
(and (bound-and-true-p emmet-mode)
|
||
(or (and (derived-mode-p 'web-mode)
|
||
(member (web-mode-language-at-pos) '("html" "css")))
|
||
(not (derived-mode-p 'web-mode)))))
|
||
|
||
(use-package emmet-mode
|
||
:straight t
|
||
:hook ((vue-html-mode . emmet-mode)
|
||
(svelte-mode . emmet-mode)
|
||
(web-mode . emmet-mode)
|
||
(html-mode . emmet-mode)
|
||
(css-mode . emmet-mode)
|
||
(scss-mode . emmet-mode))
|
||
:config
|
||
(defun my/emmet-or-tab (&optional arg)
|
||
(interactive)
|
||
(if (my/should-run-emmet-p)
|
||
(or (emmet-expand-line arg)
|
||
(emmet-go-to-edit-point 1)
|
||
(indent-for-tab-command arg))
|
||
(indent-for-tab-command arg)))
|
||
(general-imap :keymaps 'emmet-mode-keymap
|
||
"TAB" 'my/emmet-or-tab
|
||
"<backtab>" 'emmet-prev-edit-point))
|
||
#+end_src
|
||
*** Prettier
|
||
#+begin_src emacs-lisp
|
||
(use-package prettier
|
||
:commands (prettier-prettify)
|
||
:straight t
|
||
:init
|
||
(my-leader-def
|
||
:keymaps '(js-mode-map
|
||
web-mode-map
|
||
typescript-mode-map
|
||
typescript-ts-mode-map
|
||
vue-mode-map
|
||
svelte-mode-map)
|
||
"rr" #'prettier-prettify))
|
||
#+end_src
|
||
*** TypeScript
|
||
#+begin_src emacs-lisp
|
||
(use-package typescript-mode
|
||
:straight t
|
||
:mode "\\.ts\\'"
|
||
:init
|
||
(add-hook 'typescript-mode-hook #'smartparens-mode)
|
||
(add-hook 'typescript-mode-hook #'rainbow-delimiters-mode)
|
||
(add-hook 'typescript-mode-hook #'treesit-fold-mode)
|
||
:config
|
||
(my/set-smartparens-indent 'typescript-mode))
|
||
#+end_src
|
||
*** JavaScript
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'js-mode-hook #'smartparens-mode)
|
||
(add-hook 'js-mode-hook #'treesit-fold-mode)
|
||
(my/set-smartparens-indent 'js-mode)
|
||
#+end_src
|
||
*** Jest
|
||
#+begin_src emacs-lisp
|
||
(use-package jest-test-mode
|
||
:straight t
|
||
:hook ((typescript-mode . jest-test-mode)
|
||
(js-mode . jest-test-mode))
|
||
:config
|
||
(my-leader-def
|
||
:keymaps 'jest-test-mode-map
|
||
:infix "t"
|
||
"t" #'jest-test-run-at-point
|
||
"d" #'jest-test-debug-run-at-point
|
||
"r" #'jest-test-run
|
||
"a" #'jest-test-run-all-tests)
|
||
(defmacro my/jest-test-with-debug-flags (form)
|
||
"Execute FORM with debugger flags set."
|
||
(declare (indent 0))
|
||
`(let ((jest-test-options (seq-concatenate 'list jest-test-options (list "--runInBand") ))
|
||
(jest-test-npx-options (seq-concatenate 'list jest-test-npx-options (list "--node-options" "--inspect-brk"))))
|
||
,form))
|
||
(defun my/jest-test-debug ()
|
||
"Run the test with an inline debugger attached."
|
||
(interactive)
|
||
(my/jest-test-with-debug-flags
|
||
(jest-test-run)))
|
||
(defun my/jest-test-debug-rerun-test ()
|
||
"Run the test with an inline debugger attached."
|
||
(interactive)
|
||
(my/jest-test-with-debug-flags
|
||
(jest-test-rerun-test)))
|
||
(defun my/jest-test-debug-run-at-point ()
|
||
"Run the test with an inline debugger attached."
|
||
(interactive)
|
||
(my/jest-test-with-debug-flags
|
||
(jest-test-run-at-point)))
|
||
(advice-add #'jest-test-debug :override #'my/jest-test-debug)
|
||
(advice-add #'jest-test-debug-rerun-test :override #'my/jest-test-debug-rerun-test)
|
||
(advice-add #'jest-test-debug-run-at-point
|
||
:override #'my/jest-test-debug-run-at-point))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/jest-test-run-at-point-copy ()
|
||
"Run the top level describe block of the current buffer's point."
|
||
(interactive)
|
||
(let ((filename (jest-test-find-file))
|
||
(example (jest-test-unit-at-point)))
|
||
(if (and filename example)
|
||
(jest-test-from-project-directory filename
|
||
(let ((jest-test-options (seq-concatenate 'list jest-test-options (list "-t" example))))
|
||
(kill-new (jest-test-command filename))))
|
||
(message jest-test-not-found-message))))
|
||
#+end_src
|
||
|
||
*** web-mode
|
||
[[https://web-mode.org/][web-mode.el]] is a major mode to edit various web templates.
|
||
|
||
Trying this one out instead of vue-mode and svelte-mode, because this one seems to have better support for tree-sitter and generally less problems.
|
||
|
||
Set =web-mode-auto-pairs= not =nil= because smartparens already fulfills that role.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package web-mode
|
||
:straight t
|
||
:commands (web-mode)
|
||
:init
|
||
(add-to-list 'auto-mode-alist '("\\.svelte\\'" . web-mode))
|
||
(add-to-list 'auto-mode-alist '("\\.vue\\'" . web-mode))
|
||
:config
|
||
(add-hook 'web-mode-hook 'smartparens-mode)
|
||
(add-hook 'web-mode-hook 'hs-minor-mode)
|
||
(my/set-smartparens-indent 'web-mode)
|
||
(with-eval-after-load 'editorconfig
|
||
(push
|
||
'standard-indent
|
||
(alist-get 'web-mode editorconfig-indentation-alist)))
|
||
(setq web-mode-auto-pairs nil))
|
||
#+end_src
|
||
|
||
Hooking this up with lsp.
|
||
#+begin_src emacs-lisp
|
||
(setq my/web-mode-lsp-extensions
|
||
`(,(rx ".svelte" eos)
|
||
,(rx ".vue" eos)))
|
||
|
||
(defun my/web-mode-lsp ()
|
||
(when (seq-some
|
||
(lambda (regex) (string-match-p regex (buffer-file-name)))
|
||
my/web-mode-lsp-extensions)
|
||
(lsp-deferred)))
|
||
|
||
(add-hook 'web-mode-hook #'my/web-mode-lsp)
|
||
#+end_src
|
||
|
||
Vue settings
|
||
#+begin_src emacs-lisp
|
||
(defun my/web-mode-vue-setup (&rest _)
|
||
(let ((filename (buffer-file-name)))
|
||
(when (and (stringp filename)
|
||
(string-match-p (rx ".vue" eos) filename))
|
||
(setq-local web-mode-script-padding 0)
|
||
(setq-local web-mode-style-padding 0)
|
||
(setq-local create-lockfiles nil)
|
||
(setq-local web-mode-enable-auto-pairing nil))))
|
||
|
||
(add-hook 'web-mode-hook 'my/web-mode-vue-setup)
|
||
(add-hook 'editorconfig-after-apply-functions 'my/web-mode-vue-setup)
|
||
#+end_src
|
||
*** SCSS
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'scss-mode-hook #'smartparens-mode)
|
||
(add-hook 'scss-mode-hook #'hs-minor-mode)
|
||
(my/set-smartparens-indent 'scss-mode)
|
||
#+end_src
|
||
*** PHP
|
||
#+begin_src emacs-lisp
|
||
(use-package php-mode
|
||
:straight t
|
||
:mode "\\.php\\'"
|
||
:config
|
||
(add-hook 'php-mode-hook #'smartparens-mode)
|
||
(add-hook 'php-mode-hook #'lsp)
|
||
(my/set-smartparens-indent 'php-mode))
|
||
#+end_src
|
||
** LaTeX
|
||
:PROPERTIES:
|
||
:MODULE_NAME: latex
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-latex.el :comments links
|
||
:END:
|
||
*** AUCTeX
|
||
The best LaTeX editing environment I've found so far.
|
||
|
||
References:
|
||
- [[https://www.gnu.org/software/auctex/][AUCTeX homepage]]
|
||
|
||
#+begin_src emacs-lisp :noweb yes
|
||
(use-package tex
|
||
:straight auctex
|
||
:defer t
|
||
:config
|
||
(setq-default TeX-auto-save t)
|
||
(setq-default TeX-parse-self t)
|
||
(TeX-PDF-mode)
|
||
;; Use XeLaTeX & stuff
|
||
(setq-default TeX-engine 'xetex)
|
||
(setq-default TeX-command-extra-options "-shell-escape")
|
||
(setq-default TeX-source-correlate-method 'synctex)
|
||
(TeX-source-correlate-mode)
|
||
(setq-default TeX-source-correlate-start-server t)
|
||
(setq-default LaTeX-math-menu-unicode t)
|
||
|
||
(setq-default font-latex-fontify-sectioning 1.3)
|
||
|
||
;; Scale preview for my DPI
|
||
(setq-default preview-scale-function 1.4)
|
||
(when (boundp 'tex--prettify-symbols-alist)
|
||
(assoc-delete-all "--" tex--prettify-symbols-alist)
|
||
(assoc-delete-all "---" tex--prettify-symbols-alist))
|
||
|
||
(add-hook 'LaTeX-mode-hook
|
||
(lambda ()
|
||
(TeX-fold-mode 1)
|
||
(outline-minor-mode)))
|
||
|
||
(add-to-list 'TeX-view-program-selection
|
||
'(output-pdf "Zathura"))
|
||
|
||
;; Do not run lsp within templated TeX files
|
||
(add-hook 'LaTeX-mode-hook
|
||
(lambda ()
|
||
(unless (string-match "\.hogan\.tex$" (buffer-name))
|
||
(lsp))
|
||
(setq-local lsp-diagnostic-package :none)
|
||
(setq-local flycheck-checker 'tex-chktex)))
|
||
|
||
(add-hook 'LaTeX-mode-hook #'rainbow-delimiters-mode)
|
||
(add-hook 'LaTeX-mode-hook #'smartparens-mode)
|
||
(add-hook 'LaTeX-mode-hook #'prettify-symbols-mode)
|
||
|
||
(my/set-smartparens-indent 'LaTeX-mode)
|
||
(require 'smartparens-latex)
|
||
|
||
(general-nmap
|
||
:keymaps '(LaTeX-mode-map latex-mode-map)
|
||
"RET" 'TeX-command-run-all
|
||
"C-c t" 'orgtbl-mode)
|
||
|
||
<<init-greek-latex-snippets>>
|
||
<<init-english-latex-snippets>>
|
||
<<init-math-latex-snippets>>
|
||
<<init-section-latex-snippets>>)
|
||
#+end_src
|
||
*** Import *.sty
|
||
A function to import =.sty= files to the LaTeX document.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/list-sty ()
|
||
(reverse
|
||
(sort
|
||
(seq-filter
|
||
(lambda (file) (if (string-match ".*\.sty$" file) 1 nil))
|
||
(directory-files
|
||
(seq-some
|
||
(lambda (dir)
|
||
(if (and
|
||
(f-directory-p dir)
|
||
(seq-some
|
||
(lambda (file) (string-match ".*\.sty$" file))
|
||
(directory-files dir))
|
||
) dir nil))
|
||
(list "./styles" "../styles/" "." "..")) :full))
|
||
(lambda (f1 f2)
|
||
(let ((f1b (file-name-base f1))
|
||
(f1b (file-name-base f2)))
|
||
(cond
|
||
((string-match-p ".*BibTex" f1) t)
|
||
((and (string-match-p ".*Locale" f1) (not (string-match-p ".*BibTex" f2))) t)
|
||
((string-match-p ".*Preamble" f2) t)
|
||
(t (string-lessp f1 f2))))))))
|
||
|
||
(defun my/import-sty ()
|
||
(interactive)
|
||
(insert
|
||
(apply #'concat
|
||
(cl-mapcar
|
||
(lambda (file) (concat "\\usepackage{" (file-name-sans-extension (file-relative-name file default-directory)) "}\n"))
|
||
(my/list-sty)))))
|
||
|
||
(defun my/import-sty-org ()
|
||
(interactive)
|
||
(insert
|
||
(apply #'concat
|
||
(cl-mapcar
|
||
(lambda (file) (concat "#+LATEX_HEADER: \\usepackage{" (file-name-sans-extension (file-relative-name file default-directory)) "}\n"))
|
||
(my/list-sty)))))
|
||
#+end_src
|
||
*** Snippets
|
||
| Note | Type |
|
||
|------+-----------------------------------------------------------------|
|
||
| TODO | Move yasnippet snippets here? Maybe extract to a separate file? |
|
||
**** Greek letters
|
||
Autogenerate snippets for greek letters. I have a few blocks like this because it's faster & more flexible than usual yasnippet snippets.
|
||
|
||
Noweb points to the AUCTeX config block.
|
||
|
||
#+begin_src emacs-lisp :noweb-ref init-greek-latex-snippets
|
||
(setq my/greek-alphabet
|
||
'(("a" . "\\alpha")
|
||
("b" . "\\beta" )
|
||
("g" . "\\gamma")
|
||
("d" . "\\delta")
|
||
("e" . "\\epsilon")
|
||
("z" . "\\zeta")
|
||
("h" . "\\eta")
|
||
("o" . "\\theta")
|
||
("i" . "\\iota")
|
||
("k" . "\\kappa")
|
||
("l" . "\\lambda")
|
||
("m" . "\\mu")
|
||
("n" . "\\nu")
|
||
("x" . "\\xi")
|
||
("p" . "\\pi")
|
||
("r" . "\\rho")
|
||
("s" . "\\sigma")
|
||
("t" . "\\tau")
|
||
("u" . "\\upsilon")
|
||
("f" . "\\phi")
|
||
("c" . "\\chi")
|
||
("v" . "\\psi")
|
||
("g" . "\\omega")))
|
||
|
||
(setq my/latex-greek-prefix "'")
|
||
|
||
;; The same for capitalized letters
|
||
(dolist (elem my/greek-alphabet)
|
||
(let ((key (car elem))
|
||
(value (cdr elem)))
|
||
(when (string-equal key (downcase key))
|
||
(add-to-list 'my/greek-alphabet
|
||
(cons
|
||
(capitalize (car elem))
|
||
(concat
|
||
(substring value 0 1)
|
||
(capitalize (substring value 1 2))
|
||
(substring value 2)))))))
|
||
|
||
(yas-define-snippets
|
||
'latex-mode
|
||
(mapcar
|
||
(lambda (elem)
|
||
(list (concat my/latex-greek-prefix (car elem)) (cdr elem) (concat "Greek letter " (car elem))))
|
||
my/greek-alphabet))
|
||
#+end_src
|
||
**** English letters
|
||
#+begin_src emacs-lisp :noweb-ref init-english-latex-snippets
|
||
(setq my/english-alphabet
|
||
'("a" "b" "c" "d" "e" "f" "g" "h" "i" "j" "k" "l" "m" "n" "o" "p" "q" "r" "s" "t" "u" "v" "w" "x" "y" "z"))
|
||
|
||
(dolist (elem my/english-alphabet)
|
||
(when (string-equal elem (downcase elem))
|
||
(add-to-list 'my/english-alphabet (upcase elem))))
|
||
|
||
(setq my/latex-mathbb-prefix "`")
|
||
|
||
(yas-define-snippets
|
||
'latex-mode
|
||
(mapcar
|
||
(lambda (elem)
|
||
(list (concat my/latex-mathbb-prefix elem) (concat "\\mathbb{" elem "}") (concat "Mathbb letter " elem)))
|
||
my/english-alphabet))
|
||
#+end_src
|
||
**** Math symbols
|
||
#+begin_src emacs-lisp :noweb-ref init-math-latex-snippets
|
||
(setq my/latex-math-symbols
|
||
'(("x" . "\\times")
|
||
("." . "\\cdot")
|
||
("v" . "\\forall")
|
||
("s" . "\\sum_{$1}^{$2}$0")
|
||
("p" . "\\prod_{$1}^{$2}$0")
|
||
("d" . "\\partial")
|
||
("e" . "\\exists")
|
||
("i" . "\\int_{$1}^{$2}$0")
|
||
("c" . "\\cap")
|
||
("u" . "\\cup")
|
||
("0" . "\\emptyset")
|
||
("^" . "\\widehat{$1}$0")
|
||
("_" . "\\overline{$1}$0")
|
||
("~" . "\\sim")
|
||
("|" . "\\mid")
|
||
("_|" . "\\perp")))
|
||
|
||
(setq my/latex-math-prefix ";")
|
||
|
||
(yas-define-snippets
|
||
'latex-mode
|
||
(mapcar
|
||
(lambda (elem)
|
||
(let ((key (car elem))
|
||
(value (cdr elem)))
|
||
(list (concat my/latex-math-prefix key) value (concat "Math symbol " value))))
|
||
my/latex-math-symbols))
|
||
#+end_src
|
||
**** Section snippets
|
||
Section snippets. The code turned out to be more complicated than just writing the snippets by hand.
|
||
|
||
#+begin_src emacs-lisp :noweb-ref init-section-latex-snippets
|
||
(setq my/latex-section-snippets
|
||
'(("ch" . "\\chapter{$1}")
|
||
("sec" . "\\section{$1}")
|
||
("ssec" . "\\subsection{$1}")
|
||
("sssec" . "\\subsubsection{$1}")
|
||
("par" . "\\paragraph{$1}}")))
|
||
|
||
(setq my/latex-section-snippets
|
||
(mapcar
|
||
(lambda (elem)
|
||
`(,(car elem)
|
||
,(cdr elem)
|
||
,(progn
|
||
(string-match "[a-z]+" (cdr elem))
|
||
(match-string 0 (cdr elem)))))
|
||
my/latex-section-snippets))
|
||
|
||
(dolist (elem my/latex-section-snippets)
|
||
(let* ((key (nth 0 elem))
|
||
(value (nth 1 elem))
|
||
(desc (nth 2 elem))
|
||
(star-index (string-match "\{\$1\}" value)))
|
||
(add-to-list 'my/latex-section-snippets
|
||
`(,(concat key "*")
|
||
,(concat
|
||
(substring value 0 star-index)
|
||
"*"
|
||
(substring value star-index))
|
||
,(concat desc " with *")))
|
||
(add-to-list 'my/latex-section-snippets
|
||
`(,(concat key "l")
|
||
,(concat value "%\n\\label{sec:$2}")
|
||
,(concat desc " with label")))))
|
||
|
||
(dolist (elem my/latex-section-snippets)
|
||
(setf (nth 1 elem) (concat (nth 1 elem) "\n$0")))
|
||
|
||
(yas-define-snippets
|
||
'latex-mode
|
||
my/latex-section-snippets)
|
||
#+end_src
|
||
** Markup & natural languages
|
||
:PROPERTIES:
|
||
:MODULE_NAME: markup
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-markup.el :comments links
|
||
:END:
|
||
*** Markdown
|
||
#+begin_src emacs-lisp
|
||
(use-package markdown-mode
|
||
:straight t
|
||
:mode "\\.md\\'"
|
||
:config
|
||
(setq markdown-command
|
||
(concat
|
||
"pandoc"
|
||
" --from=markdown --to=html"
|
||
" --standalone --mathjax --highlight-style=pygments"
|
||
" --css=pandoc.css"
|
||
" --quiet"
|
||
))
|
||
(setq markdown-live-preview-delete-export 'delete-on-export)
|
||
(setq markdown-asymmetric-header t)
|
||
(setq markdown-open-command "/home/pavel/bin/scripts/chromium-sep")
|
||
(add-hook 'markdown-mode-hook #'smartparens-mode)
|
||
(general-define-key
|
||
:keymaps 'markdown-mode-map
|
||
"M-<left>" 'markdown-promote
|
||
"M-<right>" 'markdown-demote))
|
||
|
||
;; (use-package livedown
|
||
;; :straight (:host github :repo "shime/emacs-livedown")
|
||
;; :commands livedown-preview
|
||
;; :config
|
||
;; (setq livedown-browser "qutebrowser"))
|
||
|
||
#+end_src
|
||
*** Ascii Doc
|
||
#+begin_src emacs-lisp
|
||
(use-package adoc-mode
|
||
:mode (rx (| ".asciidoc") eos)
|
||
:straight t)
|
||
#+end_src
|
||
*** PlantUML
|
||
| Guix dependency |
|
||
|-----------------|
|
||
| plantuml |
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package plantuml-mode
|
||
:straight t
|
||
:mode "(\\.\\(plantuml?\\|uml\\|puml\\)\\'"
|
||
:config
|
||
(setq plantuml-executable-path "/home/pavel/.guix-extra-profiles/emacs/emacs/bin/plantuml")
|
||
(setq plantuml-default-exec-mode 'executable)
|
||
(setq plantuml-indent-level 2)
|
||
(setq my/plantuml-indent-regexp-return "^\s*return\s+.+$")
|
||
(;; (add-to-list
|
||
;; 'plantuml-indent-regexp-end
|
||
;; my/plantuml-indent-regexp-return)
|
||
)
|
||
(add-to-list 'auto-mode-alist '("\\.plantuml\\'" . plantuml-mode))
|
||
(add-to-list 'auto-mode-alist '("\\.uml\\'" . plantuml-mode))
|
||
(add-hook 'plantuml-mode-hook #'smartparens-mode)
|
||
(general-nmap
|
||
:keymaps 'plantuml-mode-map
|
||
"RET" 'plantuml-preview))
|
||
#+end_src
|
||
*** Subtitles
|
||
A major mode to work with subtitles.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package subed
|
||
:straight (:host github :repo "rndusr/subed" :files ("subed/*.el")
|
||
:build (:not native-compile))
|
||
;; (cons (rx (| "srt" "vtt" "ass") eos) #'subed-mode)
|
||
:mode ("\\(?:ass\\|\\(?:sr\\|vt\\)t\\)\\'" . subed-mode)
|
||
:config
|
||
(general-define-key
|
||
:keymaps '(subed-mode-map subed-vtt-mode-map)
|
||
:states '(normal)
|
||
"gp" #'subed-mpv-toggle-pause))
|
||
#+end_src
|
||
*** LTeX
|
||
[[https://github.com/valentjn/ltex-ls][ltex-ls]] is a tool that wraps LanguageTool into a language server.
|
||
|
||
It takes maybe 10 seconds to run on my Master's thesis file (=M-x count words=: 13453 words and 117566 characters), but it's totally worth it. And it's much faster on smaller files. The good thing is that it supports markup syntaxes like Org and Markdown, whereas LanguageTool by itself produces a lot of false positives on these files.
|
||
|
||
It shouldn't be too hard to package that for guix, but I've installed the nix version for now.
|
||
#+begin_src emacs-lisp
|
||
(use-package lsp-ltex
|
||
:straight t
|
||
:after (lsp)
|
||
:init
|
||
(setq lsp-ltex-version "15.2.0")
|
||
(setq lsp-ltex-check-frequency "save"))
|
||
#+end_src
|
||
|
||
A function to switch the current language.
|
||
#+begin_src emacs-lisp
|
||
(defun my/ltex-lang ()
|
||
(interactive)
|
||
(setq lsp-ltex-language (completing-read
|
||
"Language: "
|
||
'("en-GB" "ru-RU" "de-DE")))
|
||
(lsp-workspace-restart (lsp--read-workspace)))
|
||
#+end_src
|
||
|
||
Check whether it's necessary to run LTeX:
|
||
#+begin_src emacs-lisp
|
||
(defun my/ltex-need-p ()
|
||
(let ((file-name (buffer-file-name)))
|
||
(cond
|
||
(my/is-termux nil)
|
||
((null file-name) nil)
|
||
((string-match-p (rx "/home/pavel/" (+ alnum) ".org" eos) file-name) nil)
|
||
((string-match-p (rx (literal org-directory) "/" (or "roam" "inbox-notes" "literature-notes" "journal")) file-name) t)
|
||
((string-match-p (rx (literal org-directory)) file-name) nil)
|
||
((string-match-p (rx (literal (expand-file-name user-emacs-directory))) file-name) nil)
|
||
(t t))))
|
||
#+end_src
|
||
|
||
To use it in =text-mode-hook=
|
||
#+begin_src emacs-lisp
|
||
(defun my/text-mode-lsp-maybe ()
|
||
(when (my/ltex-need-p)
|
||
(lsp)))
|
||
|
||
;; (add-hook 'text-mode-hook #'my/text-mode-lsp-maybe)
|
||
#+end_src
|
||
*** LanguageTool
|
||
LanguageTool is a great offline spell checker. For some reason, the download link is nowhere to be found on the home page, so it is listed in the references as well.
|
||
|
||
References:
|
||
- [[https://languagetool.org/][LanguageTool homepage]]
|
||
- [[https://dev.languagetool.org/http-server][LanguageTool http server]]
|
||
#+begin_src emacs-lisp
|
||
(use-package langtool
|
||
:straight t
|
||
:commands (langtool-check)
|
||
:config
|
||
(setq langtool-language-tool-server-jar "/home/pavel/bin/LanguageTool-6.4/languagetool-server.jar")
|
||
(setq langtool-mother-tongue "ru")
|
||
(setq langtool-default-language "ru-RU"))
|
||
|
||
(my-leader-def
|
||
:infix "L"
|
||
"" '(:which-key "languagetool")
|
||
"c" 'langtool-check
|
||
"s" 'langtool-server-stop
|
||
"d" 'langtool-check-done
|
||
"n" 'langtool-goto-next-error
|
||
"p" 'langtool-goto-previous-error
|
||
"l" 'langtool-correct-buffer)
|
||
#+end_src
|
||
*** Reverso
|
||
[[https://github.com/SqrtMinusOne/reverso.el][reverso.el]] is a package of mine that provides Emacs interface for [[https://reverso.net]].
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package reverso
|
||
:straight (:host github :repo "SqrtMinusOne/reverso.el")
|
||
:init
|
||
(my-leader-def "ar" #'reverso)
|
||
:commands (reverso)
|
||
:config
|
||
(setq reverso-languages '(russian english german))
|
||
(reverso-history-mode))
|
||
#+end_src
|
||
** Lisp
|
||
:PROPERTIES:
|
||
:MODULE_NAME: lisp
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-lisp.el :comments links
|
||
:END:
|
||
[[file:dot-imgs/lisp_cycles.png]]
|
||
|
||
*** Meta Lisp
|
||
Some packages for editing various Lisps.
|
||
#+begin_src emacs-lisp
|
||
(use-package lispy
|
||
:commands (lispy-mode)
|
||
:straight t)
|
||
|
||
(use-package lispyville
|
||
:hook (lispy-mode . lispyville-mode)
|
||
:straight t)
|
||
|
||
(sp-with-modes sp-lisp-modes
|
||
(sp-local-pair "'" nil :actions nil))
|
||
#+end_src
|
||
*** Emacs Lisp
|
||
**** Package Lint
|
||
A package that checks for the metadata in Emacs Lisp packages.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package flycheck-package
|
||
:straight t
|
||
:defer t
|
||
:init
|
||
(defun my/flycheck-package-setup ()
|
||
(require 'flycheck-package)
|
||
(flycheck-package-setup)
|
||
(remove-hook 'emacs-lisp-mode-hook #'my/flycheck-package-setup))
|
||
(add-hook 'emacs-lisp-mode-hook #'my/flycheck-package-setup))
|
||
#+end_src
|
||
**** General settings
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'emacs-lisp-mode-hook #'aggressive-indent-mode)
|
||
;; (add-hook 'emacs-lisp-mode-hook #'smartparens-strict-mode)
|
||
(add-hook 'emacs-lisp-mode-hook #'lispy-mode)
|
||
#+end_src
|
||
**** Helper functions
|
||
Remove all advice from function. Source: https://emacs.stackexchange.com/questions/24657/unadvise-a-function-remove-all-advice-from-it
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun advice-unadvice (sym)
|
||
"Remove all advices from symbol SYM."
|
||
(interactive "aFunction symbol: ")
|
||
(advice-mapc (lambda (advice _props) (advice-remove sym advice)) sym))
|
||
#+end_src
|
||
**** IELM
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'inferior-emacs-lisp-mode-hook #'smartparens-mode)
|
||
(my-leader-def "bi" #'ielm)
|
||
#+end_src
|
||
*** Common lisp
|
||
**** SLIME
|
||
#+begin_src emacs-lisp
|
||
(use-package slime
|
||
:straight t
|
||
:commands (slime)
|
||
:config
|
||
(setq inferior-lisp-program "sbcl")
|
||
(add-hook 'slime-repl-mode 'smartparens-mode))
|
||
#+end_src
|
||
**** General settings
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'lisp-mode-hook #'aggressive-indent-mode)
|
||
;; (add-hook 'emacs-lisp-mode-hook #'smartparens-strict-mode)
|
||
(add-hook 'lisp-mode-hook #'lispy-mode)
|
||
#+end_src
|
||
*** Clojure
|
||
#+begin_src emacs-lisp
|
||
(use-package clojure-mode
|
||
:straight t
|
||
:mode "\\.clj[sc]?\\'"
|
||
:config
|
||
;; (add-hook 'clojure-mode-hook #'smartparens-strict-mode)
|
||
(add-hook 'clojure-mode-hook #'lispy-mode)
|
||
(add-hook 'clojure-mode-hook #'aggressive-indent-mode))
|
||
|
||
(use-package cider
|
||
:after clojure-mode
|
||
:straight t)
|
||
#+end_src
|
||
*** Hy
|
||
Python requirements:
|
||
- =hy=
|
||
- =jedhy=
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package hy-mode
|
||
:straight t
|
||
:mode "\\.hy\\'"
|
||
:config
|
||
(add-hook 'hy-mode-hook #'lispy-mode)
|
||
(add-hook 'hy-mode-hook #'aggressive-indent-mode))
|
||
#+end_src
|
||
*** Scheme
|
||
#+begin_src emacs-lisp
|
||
(use-package geiser
|
||
:straight t
|
||
:commands (geiser run-geiser)
|
||
:config
|
||
(setq geiser-default-implementation 'guile))
|
||
|
||
(use-package geiser-guile
|
||
:straight t
|
||
:after geiser)
|
||
|
||
(add-hook 'scheme-mode-hook #'aggressive-indent-mode)
|
||
(add-hook 'scheme-mode-hook #'lispy-mode)
|
||
#+end_src
|
||
*** CLIPS
|
||
An honorary Lisp.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package clips-mode
|
||
:straight t
|
||
:mode "\\.cl\\'"
|
||
:disabled t
|
||
:config
|
||
(add-hook 'clips-mode 'lispy-mode))
|
||
#+end_src
|
||
** Python
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
*** ein
|
||
[[https://github.com/millejoh/emacs-ipython-notebook][ein]] is a package that allows for running Jupyter notebooks in Emacs.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package ein
|
||
:commands (ein:run)
|
||
:straight t)
|
||
#+end_src
|
||
*** pyright
|
||
For some reason it doesn't use pipenv python executable, so here is a small workaround.
|
||
#+begin_src emacs-lisp
|
||
(setq my/pipenv-python-alist '())
|
||
|
||
(defun my/get-pipenv-python ()
|
||
(let ((default-directory (projectile-project-root)))
|
||
(if (file-exists-p "Pipfile")
|
||
(let ((asc (assoc default-directory my/pipenv-python-alist)))
|
||
(if asc
|
||
(cdr asc)
|
||
(let ((python-executable
|
||
(string-trim (shell-command-to-string "PIPENV_IGNORE_VIRTUALENVS=1 pipenv run which python 2>/dev/null"))))
|
||
(if (string-match-p ".*not found.*" python-executable)
|
||
(message "Pipfile found, but not pipenv executable!")
|
||
(message (format "Found pipenv python: %s" python-executable))
|
||
(add-to-list 'my/pipenv-python-alist (cons default-directory python-executable))
|
||
python-executable))))
|
||
"python")))
|
||
|
||
(use-package lsp-pyright
|
||
:straight t
|
||
:defer t
|
||
:hook (python-mode . (lambda ()
|
||
(require 'lsp-pyright)
|
||
(setq-local lsp-pyright-python-executable-cmd (my/get-pipenv-python))
|
||
(lsp))))
|
||
|
||
(add-hook 'python-mode-hook #'smartparens-mode)
|
||
(add-hook 'python-mode-hook #'treesit-fold-mode)
|
||
#+end_src
|
||
*** pipenv
|
||
[[https://github.com/pypa/pipenv][Pipenv]] is a package manager for Python.
|
||
|
||
Automatically creates & manages virtualenvs and stores data in =Pipfile= and =Pipfile.lock= (like npm's =package.json= and =package-lock.json=).
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package pipenv
|
||
:straight t
|
||
:hook (python-mode . pipenv-mode)
|
||
:init
|
||
(setq
|
||
pipenv-projectile-after-switch-function
|
||
#'pipenv-projectile-after-switch-extended))
|
||
#+end_src
|
||
*** OFF (OFF) yapf
|
||
[[https://github.com/google/yapf][yapf]] is a formatter for Python files.
|
||
|
||
| Guix dependency |
|
||
|-----------------|
|
||
| python-yapf |
|
||
|
||
References:
|
||
- [[https://github.com/google/yapf][yapf repo]]
|
||
- [[https://github.com/JorisE/yapfify][yapfify.el repo]]
|
||
#+begin_src emacs-lisp
|
||
(use-package yapfify
|
||
:straight (:repo "JorisE/yapfify" :host github)
|
||
:disabled
|
||
:commands (yapfify-region
|
||
yapfify-buffer
|
||
yapfify-region-or-buffer
|
||
yapf-mode))
|
||
#+end_src
|
||
|
||
Global config:
|
||
#+begin_src conf-windows :tangle .config/yapf/style :comments link
|
||
[style]
|
||
based_on_style = facebook
|
||
column_limit = 80
|
||
#+end_src
|
||
*** black
|
||
[[https://github.com/psf/black][black]] is a formatter for Python files.
|
||
|
||
| Guix dependency |
|
||
|-----------------|
|
||
| python-black |
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package python-black
|
||
:straight t
|
||
:commands (python-black-buffer)
|
||
:config
|
||
(setq python-black-command "black"))
|
||
#+end_src
|
||
|
||
*** isort
|
||
[[https://github.com/PyCQA/isort][isort]] is a Python package to sort Python imports.
|
||
|
||
| Guix dependency |
|
||
|-----------------|
|
||
| python-isort |
|
||
|
||
References:
|
||
- [[https://pycqa.github.io/isort/][isort docs]]
|
||
- [[https://github.com/paetzke/py-isort.el][py-isort.el repo]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package py-isort
|
||
:straight t
|
||
:commands (py-isort-buffer py-isort-region))
|
||
#+end_src
|
||
|
||
The following binding calls yapf & isort on the buffer
|
||
#+begin_src emacs-lisp
|
||
(my-leader-def
|
||
:keymaps '(python-mode-map python-ts-mode-map)
|
||
"rr" (lambda ()
|
||
(interactive)
|
||
(unless (and (fboundp #'org-src-edit-buffer-p) (org-src-edit-buffer-p))
|
||
(py-isort-buffer))
|
||
(python-black-buffer)))
|
||
#+end_src
|
||
*** OFF sphinx-doc
|
||
CLOSED: [2024-04-23 Tue 12:33]
|
||
A package to generate sphinx-compatible docstrings.
|
||
|
||
#+begin_src emacs-lisp :tangle no
|
||
(use-package sphinx-doc
|
||
:straight t
|
||
:hook (python-mode . sphinx-doc-mode)
|
||
:config
|
||
(my-leader-def
|
||
:keymaps 'sphinx-doc-mode-map
|
||
"rd" 'sphinx-doc))
|
||
#+end_src
|
||
*** numpydoc
|
||
[[https://github.com/douglasdavis/numpydoc.el][numpydoc.el]] is a package to generate docstring in Python functions.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package numpydoc
|
||
:straight t
|
||
:commands (numpydoc-generate)
|
||
:init
|
||
(my-leader-def
|
||
:keymaps 'python-ts-mode-map
|
||
"rd" #'numpydoc-generate)
|
||
:config
|
||
(setq numpydoc-insertion-style 'prompt)
|
||
(setq numpydoc-insert-return-without-typehint nil))
|
||
#+end_src
|
||
|
||
*** pytest
|
||
[[https://docs.pytest.org/en/6.2.x/][pytest]] is a unit testing framework for Python.
|
||
|
||
Once again a function to set pytest executable from pipenv.
|
||
|
||
References:
|
||
- [[https://docs.pytest.org/en/6.2.x/][pytest docs]]
|
||
- [[https://github.com/wbolster/emacs-python-pytest][emacs-python-pytest]]
|
||
|
||
#+begin_src emacs-lisp :noweb yes
|
||
(defun my/set-pipenv-pytest ()
|
||
(setq-local
|
||
python-pytest-executable
|
||
(concat (my/get-pipenv-python) " -m pytest")))
|
||
|
||
(use-package python-pytest
|
||
:straight t
|
||
:commands (python-pytest-dispatch)
|
||
:init
|
||
(my-leader-def
|
||
:keymaps '(python-mode-map python-ts-mode-map)
|
||
:infix "t"
|
||
"t" 'python-pytest-dispatch)
|
||
:config
|
||
<<override-pytest-run>>
|
||
(add-hook 'python-mode-hook #'my/set-pipenv-pytest)
|
||
(when (derived-mode-p 'python-mode)
|
||
(my/set-pipenv-pytest)))
|
||
#+end_src
|
||
**** Fix comint buffer width
|
||
For some reason, the default comint output width is way too large.
|
||
|
||
To fix that, I've modified the following function in the =python-pytest= package.
|
||
#+begin_src emacs-lisp :noweb-ref override-pytest-run :tangle no
|
||
(cl-defun python-pytest--run-as-comint (&key command)
|
||
"Run a pytest comint session for COMMAND."
|
||
(let* ((buffer (python-pytest--get-buffer))
|
||
(process (get-buffer-process buffer)))
|
||
(with-current-buffer buffer
|
||
(when (comint-check-proc buffer)
|
||
(unless (or compilation-always-kill
|
||
(yes-or-no-p "Kill running pytest process?"))
|
||
(user-error "Aborting; pytest still running")))
|
||
(when process
|
||
(delete-process process))
|
||
(let ((inhibit-read-only t))
|
||
(erase-buffer))
|
||
(unless (eq major-mode 'python-pytest-mode)
|
||
(python-pytest-mode))
|
||
(compilation-forget-errors)
|
||
(display-buffer buffer)
|
||
(setq command (format "export COLUMNS=%s; %s"
|
||
(- (window-width (get-buffer-window buffer)) 5)
|
||
command))
|
||
(insert (format "cwd: %s\ncmd: %s\n\n" default-directory command))
|
||
(setq python-pytest--current-command command)
|
||
(when python-pytest-pdb-track
|
||
(add-hook
|
||
'comint-output-filter-functions
|
||
'python-pdbtrack-comint-output-filter-function
|
||
nil t))
|
||
(run-hooks 'python-pytest-setup-hook)
|
||
(make-comint-in-buffer "pytest" buffer "bash" nil "-c" command)
|
||
(run-hooks 'python-pytest-started-hook)
|
||
(setq process (get-buffer-process buffer))
|
||
(set-process-sentinel process #'python-pytest--process-sentinel))))
|
||
#+end_src
|
||
*** code-cells
|
||
Support for text with magic comments.
|
||
|
||
| Guix dependency | Disabled |
|
||
|-----------------+----------|
|
||
| python-jupytext | t |
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package code-cells
|
||
:straight t
|
||
:commands (code-cells-mode code-cells-convert-ipynb))
|
||
#+end_src
|
||
*** tensorboard
|
||
A function to start up [[https://www.tensorflow.org/tensorboard][TensorBoard]].
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq my/tensorboard-buffer "TensorBoard-out")
|
||
|
||
(defun my/tensorboard ()
|
||
(interactive)
|
||
(start-process
|
||
"tensorboard"
|
||
my/tensorboard-buffer
|
||
"tensorboard"
|
||
"serve"
|
||
"--logdir"
|
||
(car (find-file-read-args "Directory: " t)))
|
||
(display-buffer my/tensorboard-buffer))
|
||
#+end_src
|
||
** Data serialization
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
*** JSON
|
||
#+begin_src emacs-lisp
|
||
(use-package json-mode
|
||
:straight t
|
||
:mode "\\.json\\'"
|
||
:config
|
||
(add-hook 'json-mode-hook #'smartparens-mode)
|
||
(add-hook 'json-mode-hook #'treesit-fold-mode)
|
||
(my/set-smartparens-indent 'json-mode))
|
||
#+end_src
|
||
*** CSV
|
||
#+begin_src emacs-lisp
|
||
(use-package csv-mode
|
||
:straight t
|
||
:disabled
|
||
:mode "\\.csv\\'")
|
||
#+end_src
|
||
*** YAML
|
||
#+begin_src emacs-lisp
|
||
(use-package yaml-mode
|
||
:straight t
|
||
:mode "\\.yml\\'"
|
||
:config
|
||
(add-hook 'yaml-mode-hook 'smartparens-mode)
|
||
;; (add-hook 'yaml-mode-hook 'highlight-indent-guides-mode)
|
||
(add-to-list 'auto-mode-alist '("\\.yml\\'" . yaml-mode)))
|
||
#+end_src
|
||
** Configuration
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
|
||
*** .env
|
||
#+begin_src emacs-lisp
|
||
(use-package dotenv-mode
|
||
:straight t
|
||
:mode "\\.env\\..*\\'")
|
||
#+end_src
|
||
*** .gitignore
|
||
A package to quickly create =.gitignore= files.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package gitignore-templates
|
||
:straight t
|
||
:commands (gitignore-templates-insert
|
||
gitignore-templates-new-file))
|
||
#+end_src
|
||
*** Docker
|
||
#+begin_src emacs-lisp
|
||
(use-package dockerfile-mode
|
||
:mode "Dockerfile\\'"
|
||
:straight t
|
||
:config
|
||
(add-hook 'dockerfile-mode 'smartparens-mode))
|
||
#+end_src
|
||
*** Jenkins
|
||
#+begin_src emacs-lisp
|
||
(use-package jenkinsfile-mode
|
||
:straight t
|
||
:mode "Jenkinsfile\\'"
|
||
:config
|
||
(add-hook 'jenkinsfile-mode-hook #'smartparens-mode)
|
||
(my/set-smartparens-indent 'jenkinsfile-mode))
|
||
#+end_src
|
||
*** crontab
|
||
#+begin_src emacs-lisp
|
||
(use-package crontab-mode
|
||
:mode "/crontab\\(\\.X*[[:alnum:]]+\\)?\\'"
|
||
:straight t)
|
||
#+end_src
|
||
*** nginx
|
||
#+begin_src emacs-lisp
|
||
(use-package nginx-mode
|
||
:straight t
|
||
:config
|
||
(my/set-smartparens-indent 'nginx-mode))
|
||
#+end_src
|
||
*** HCL
|
||
#+begin_src emacs-lisp
|
||
(use-package hcl-mode
|
||
:mode "\\.hcl\\'"
|
||
:straight t)
|
||
#+end_src
|
||
** Shell
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
|
||
*** sh
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'sh-mode-hook #'smartparens-mode)
|
||
#+end_src
|
||
*** fish
|
||
#+begin_src emacs-lisp
|
||
(use-package fish-mode
|
||
:straight t
|
||
:mode "\\.fish\\'"
|
||
:config
|
||
(add-hook 'fish-mode-hook #'smartparens-mode))
|
||
#+end_src
|
||
** Query languages
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
*** SQL
|
||
[[https://github.com/zeroturnaround/sql-formatter][sql-formatter]] is a nice JavaScript package for pretty-printing SQL queries. It is not packaged for Emacs, so the easiest way to use it seems to be to define a custom formatter via [[https://github.com/purcell/emacs-reformatter][reformatter]].
|
||
|
||
Also, I've made a simple function to switch dialects because I often alternate between them.
|
||
|
||
So far I didn't find a nice SQL client for Emacs, but I occasionally run SQL queries in Org Mode, so this quite package is handy.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq my/sqlformatter-dialect-choice
|
||
'("db2" "mariadb" "mysql" "n1ql" "plsql" "postgresql" "redshift" "spark" "sql" "tsql"))
|
||
|
||
(setq my/sqlformatter-dialect "postgresql")
|
||
|
||
(defun my/sqlformatter-set-dialect ()
|
||
"Set dialect for sql-formatter"
|
||
(interactive)
|
||
(setq my/sqlformatter-dialect
|
||
(completing-read "Dialect: " my/sqlformatter-dialect-choice)))
|
||
|
||
(reformatter-define sqlformat
|
||
:program (executable-find "sql-formatter")
|
||
:args `("-l" ,my/sqlformatter-dialect))
|
||
|
||
(my-leader-def
|
||
:keymaps '(sql-mode-map)
|
||
"rr" #'sqlformat-buffer)
|
||
#+end_src
|
||
*** SPARQL
|
||
#+begin_src emacs-lisp
|
||
(use-package sparql-mode
|
||
:mode "\\.sparql\\'"
|
||
:straight t)
|
||
#+end_src
|
||
*** GraphQL
|
||
#+begin_src emacs-lisp
|
||
(use-package graphql-mode
|
||
:mode (rx (| "gql" "grapql") eos)
|
||
:straight t)
|
||
#+end_src
|
||
** Documents
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
*** DocView
|
||
Don't know about this.
|
||
|
||
=doc-view= doesn't look great with the default =doc-view-resolution= of 100. 300 is fine, but then it becomes slow.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/doc-view-setup ()
|
||
(display-line-numbers-mode -1)
|
||
(undo-tree-mode -1))
|
||
|
||
(use-package doc-view
|
||
:straight (:type built-in)
|
||
:config
|
||
(setq doc-view-resolution 300)
|
||
(add-hook 'doc-view-mode-hook #'my/doc-view-setup)
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps '(doc-view-mode-map)
|
||
"j" #'doc-view-next-line-or-next-page
|
||
"k" #'doc-view-previous-line-or-previous-page))
|
||
#+end_src
|
||
** Gnuplot
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
Emacs integration for [[http://gnuplot.info/][gnuplot]].
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package gnuplot
|
||
:straight t
|
||
:commands (gnuplot-mode gnuplot-make-buffer)
|
||
:init
|
||
(add-to-list 'auto-mode-alist '("\\.gp\\'" . gnuplot-mode))
|
||
:config
|
||
(general-define-key
|
||
:keymaps 'gnuplot-mode-map
|
||
"C-c C-c" #'gnuplot-send-buffer-to-gnuplot)
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'gnuplot-mode-map
|
||
"RET" #'gnuplot-send-buffer-to-gnuplot)
|
||
(add-hook 'gnuplot-mode-hook #'smartparens-mode))
|
||
#+end_src
|
||
** x509
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
#+begin_src emacs-lisp
|
||
(use-package x509-mode
|
||
:commands (x509-dwim)
|
||
:straight (:host github :repo "jobbflykt/x509-mode"
|
||
:build (:not native-compile)))
|
||
#+end_src
|
||
** Java
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
#+begin_src emacs-lisp
|
||
(use-package lsp-java
|
||
:straight t
|
||
:after (lsp)
|
||
:config
|
||
(setq lsp-java-jdt-download-url "https://www.eclipse.org/downloads/download.php?file=/jdtls/milestones/1.23.0/jdt-language-server-1.23.0-202304271346.tar.gz"))
|
||
|
||
(add-hook 'java-mode-hook #'smartparens-mode)
|
||
;; (add-hook 'java-mode-hook #'hs-minor-mode)
|
||
(my/set-smartparens-indent 'java-mode)
|
||
#+end_src
|
||
** Go
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
#+begin_src emacs-lisp
|
||
(use-package go-mode
|
||
:straight t
|
||
:mode "\\.go\\'"
|
||
:config
|
||
(my/set-smartparens-indent 'go-mode)
|
||
(add-hook 'go-mode-hook #'smartparens-mode)
|
||
(add-hook 'go-mode-hook #'treesit-fold-mode))
|
||
#+end_src
|
||
** .NET
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
*** C#
|
||
| Guix dependencies | Disabled |
|
||
|-------------------+----------|
|
||
| omnisharp | t |
|
||
| dotnet | t |
|
||
|
||
Disabled that for now because it depends on the old tree sitter.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package csharp-mode
|
||
:straight t
|
||
:mode "\\.cs\\'"
|
||
:disabled t
|
||
:config
|
||
(setq lsp-csharp-server-path (executable-find "omnisharp-wrapper"))
|
||
(add-hook 'csharp-mode-hook #'csharp-tree-sitter-mode)
|
||
(add-hook 'csharp-tree-sitter-mode-hook #'smartparens-mode)
|
||
(add-hook 'csharp-mode-hook #'treesit-fold-mode)
|
||
(my/set-smartparens-indent 'csharp-tree-sitter-mode))
|
||
#+end_src
|
||
*** MSBuild
|
||
#+begin_src emacs-lisp
|
||
(use-package csproj-mode
|
||
:straight t
|
||
:mode "\\.csproj\\'"
|
||
:config
|
||
(add-hook 'csproj-mode #'smartparens-mode))
|
||
#+end_src
|
||
** Haskell
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
#+begin_src emacs-lisp
|
||
(use-package haskell-mode
|
||
:straight t
|
||
:mode "\\.hs\\'")
|
||
|
||
(use-package lsp-haskell
|
||
:straight t
|
||
:after (lsp haskell-mode))
|
||
#+end_src
|
||
** nix
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
#+begin_src emacs-lisp
|
||
(use-package nix-mode
|
||
:straight t
|
||
:mode "\\.nix\\'"
|
||
:config
|
||
(add-hook 'nix-mode-hook #'smartparens-mode)
|
||
(my/set-smartparens-indent 'nix-mode))
|
||
#+end_src
|
||
** Lua
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-programming
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-programming.el :comments links
|
||
:END:
|
||
#+begin_src emacs-lisp
|
||
(use-package lua-mode
|
||
:straight t
|
||
:mode "\\.lua\\'"
|
||
:hook (lua-mode . smartparens-mode))
|
||
|
||
(my/set-smartparens-indent 'lua-mode)
|
||
#+end_src
|
||
* Org Mode
|
||
:PROPERTIES:
|
||
:MODULE_NAME: general-org
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-general-org.el :comments links
|
||
:END:
|
||
*Org mode* is a tool that leverages plain-text files for tasks like making notes, literate programming, task management, etc.
|
||
|
||
References:
|
||
- [[https://orgmode.org/][Org Mode homepage]]
|
||
- [[https://orgmode.org/manual/][Manual]]
|
||
|
||
** Installation & basic settings
|
||
Use the built-in org mode (=:type built-in=).
|
||
|
||
#+begin_src emacs-lisp :noweb yes
|
||
(setq org-directory (expand-file-name "~/30-39 Life/32 org-mode"))
|
||
|
||
(use-package org
|
||
:straight (:type built-in)
|
||
:defer t
|
||
:init
|
||
(unless (file-exists-p org-directory)
|
||
(mkdir org-directory t))
|
||
:config
|
||
(setq org-startup-indented (not my/is-termux))
|
||
(setq org-return-follows-link t)
|
||
(setq org-src-tab-acts-natively nil)
|
||
(add-hook 'org-mode-hook 'smartparens-mode)
|
||
(add-hook 'org-agenda-mode-hook
|
||
(lambda ()
|
||
(visual-line-mode -1)
|
||
(toggle-truncate-lines 1)
|
||
(display-line-numbers-mode 0)))
|
||
(add-hook 'org-mode-hook
|
||
(lambda ()
|
||
(rainbow-delimiters-mode -1))))
|
||
#+end_src
|
||
|
||
*** Encryption
|
||
Setting up =org-crypt= to encrypt parts of file.
|
||
|
||
#+begin_src emacs-lisp :noweb-ref
|
||
(with-eval-after-load 'org
|
||
(require 'org-crypt)
|
||
(org-crypt-use-before-save-magic)
|
||
(setq org-tags-exclude-from-inheritance '("crypt"))
|
||
(setq org-crypt-key "C1EC867E478472439CC82410DE004F32AFA00205"))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'epg
|
||
(setq epg-gpg-program "gpg")
|
||
(setq epg-config--program-alist
|
||
`((OpenPGP
|
||
epg-gpg-program
|
||
;; ("gpg2" . ,epg-gpg2-minimum-version)
|
||
("gpg" . ((,epg-gpg-minimum-version . "2.0")
|
||
,epg-gpg2-minimum-version)))
|
||
(CMS
|
||
epg-gpgsm-program
|
||
("gpgsm" . "2.0.4")))))
|
||
#+end_src
|
||
|
||
This enables encryption for Org segments tagged =:crypt:=.
|
||
|
||
Another way to encrypt Org files is to save them with the extension =.org.gpg=. However, by default [[https://www.gnu.org/software/emacs/manual/html_mono/epa.html][EPA]] always prompts for the key, which is not what I want when there is only one key to select. Hence the following advice:
|
||
#+begin_src emacs-lisp
|
||
(defun my/epa--select-keys-around (fun prompt keys)
|
||
(if (= (seq-length keys) 1)
|
||
keys
|
||
(funcall fun prompt keys)))
|
||
|
||
(with-eval-after-load 'epa
|
||
(advice-add #'epa--select-keys :around #'my/epa--select-keys-around))
|
||
|
||
(unless my/remote-server
|
||
(setq epa-file-encrypt-to '("DE004F32AFA00205")))
|
||
#+end_src
|
||
*** org-contrib
|
||
=org-contrib= is a package with various additions to Org. I use the following:
|
||
- =ox-extra= - extensions for org export
|
||
|
||
This used to have =org-contacts= and =ol-notmuch= at some point, but they have since been migrated to separate repos.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-contrib
|
||
:straight (org-contrib
|
||
:type git
|
||
:repo "https://git.sr.ht/~bzg/org-contrib"
|
||
:build t)
|
||
:after (org)
|
||
:if (not my/remote-server)
|
||
:config
|
||
(require 'ox-extra)
|
||
(ox-extras-activate '(latex-header-blocks ignore-headlines)))
|
||
#+end_src
|
||
*** org-tempo
|
||
=org-tempo= is a convinient package that provides snippets for various org blocks.
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org
|
||
(require 'org-tempo)
|
||
(add-to-list 'org-structure-template-alist '("el" . "src emacs-lisp"))
|
||
(add-to-list 'org-structure-template-alist '("py" . "src python"))
|
||
(add-to-list 'org-structure-template-alist '("sq" . "src sql")))
|
||
#+end_src
|
||
*** evil-org
|
||
Better integration with evil-mode.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package evil-org
|
||
:straight t
|
||
:hook (org-mode . evil-org-mode)
|
||
:config
|
||
(add-hook 'evil-org-mode-hook
|
||
(lambda ()
|
||
(evil-org-set-key-theme '(navigation insert textobjects additional calendar todo))))
|
||
(add-to-list 'evil-emacs-state-modes 'org-agenda-mode)
|
||
(require 'evil-org-agenda)
|
||
(evil-org-agenda-set-keys))
|
||
#+end_src
|
||
*** Support for relative URLs
|
||
Source: https://emacs.stackexchange.com/questions/9807/org-mode-dont-change-relative-urls
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/export-rel-url (path desc format)
|
||
(cl-case format
|
||
(html (format "<a href=\"%s\">%s</a>" path (or desc path)))
|
||
(latex (format "\\href{%s}{%s}" path (or desc path)))
|
||
(otherwise path)))
|
||
|
||
(with-eval-after-load 'org
|
||
(org-link-set-parameters "rel" :follow #'browse-url :export #'my/export-rel-url))
|
||
#+end_src
|
||
|
||
** Keybindings & stuff
|
||
I've moved this block above because the =my-leader-def= expression in the next block seems to override the previous ones. So it has to be on the top.
|
||
|
||
*** General keybindings
|
||
#+begin_src emacs-lisp
|
||
(defun my/outline-prev-or-up-heading ()
|
||
(interactive)
|
||
(if (outline-on-heading-p)
|
||
(outline-up-heading 1)
|
||
(outline-previous-visible-heading 1)))
|
||
|
||
(with-eval-after-load 'org
|
||
(general-define-key
|
||
:keymaps 'org-mode-map
|
||
"C-c d" #'org-decrypt-entry
|
||
"C-c e" #'org-encrypt-entry
|
||
"M-p" #'org-latex-preview
|
||
"M-o" #'org-redisplay-inline-images)
|
||
|
||
(general-define-key
|
||
:keymaps 'org-mode-map
|
||
:states '(normal emacs)
|
||
"L" #'org-shiftright
|
||
"H" #'org-shiftleft
|
||
"S-<next>" #'org-next-visible-heading
|
||
"S-<prior>" #'org-previous-visible-heading
|
||
"M-0" #'org-next-visible-heading
|
||
"M-9" #'org-previous-visible-heading
|
||
"C-0" #'org-forward-heading-same-level
|
||
"C-9" #'org-backward-heading-same-level
|
||
"(" #'my/outline-prev-or-up-heading
|
||
"M-]" #'org-babel-next-src-block
|
||
"M-[" #'org-babel-previous-src-block)
|
||
|
||
(general-define-key
|
||
:keymaps 'org-agenda-mode-map
|
||
"M-]" #'org-agenda-later
|
||
"M-[" #'org-agenda-earlier)
|
||
|
||
(general-nmap :keymaps 'org-mode-map "RET" 'org-ctrl-c-ctrl-c))
|
||
#+end_src
|
||
*** Copy a link
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-link-copy (&optional arg)
|
||
"Extract URL from org-mode link and add it to kill ring."
|
||
(interactive "P")
|
||
(let* ((link (org-element-lineage (org-element-context) '(link) t))
|
||
(type (org-element-property :type link))
|
||
(url (org-element-property :path link))
|
||
(url (concat type ":" url)))
|
||
(kill-new url)
|
||
(message (concat "Copied URL: " url))))
|
||
|
||
(with-eval-after-load 'org
|
||
(general-nmap :keymaps 'org-mode-map
|
||
"C-x C-l" 'my/org-link-copy))
|
||
#+end_src
|
||
*** Navigating source blocks
|
||
An idea born from discussing Org Mode navigation with @Infu.
|
||
|
||
Modifying =org-babel-next-src-block= and =org-babel-previous-src-block= to ignore hidden source blocks.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-babel-next-visible-src-block (arg)
|
||
"Move to the next visible source block.
|
||
|
||
With ARG, repeats or can move backward if negative."
|
||
(interactive "p")
|
||
(let ((regexp org-babel-src-block-regexp))
|
||
(if (< arg 0)
|
||
(beginning-of-line)
|
||
(end-of-line))
|
||
(while (and (< arg 0) (re-search-backward regexp nil :move))
|
||
(unless (bobp)
|
||
(while (pcase (get-char-property-and-overlay (point) 'invisible)
|
||
(`(outline . ,o)
|
||
(goto-char (overlay-start o))
|
||
(re-search-backward regexp nil :move))
|
||
(_ nil))))
|
||
(cl-incf arg))
|
||
(while (and (> arg 0) (re-search-forward regexp nil t))
|
||
(while (pcase (get-char-property-and-overlay (point) 'invisible)
|
||
(`(outline . ,o)
|
||
(goto-char (overlay-end o))
|
||
(re-search-forward regexp nil :move))
|
||
(_ (end-of-line) nil)))
|
||
(re-search-backward regexp nil :move)
|
||
(cl-decf arg))
|
||
(if (> arg 0) (goto-char (point-max)) (beginning-of-line))))
|
||
|
||
(defun my/org-babel-previous-visible-src-block (arg)
|
||
"Move to the prevous visible source block.
|
||
|
||
With ARG, repeats or can move backward if negative."
|
||
(interactive "p")
|
||
(my/org-babel-next-visible-src-block (- arg)))
|
||
|
||
(with-eval-after-load 'org
|
||
(general-define-key
|
||
:keymaps 'org-mode-map
|
||
:states '(normal emacs)
|
||
"M-]" #'my/org-babel-next-visible-src-block
|
||
"M-[" #'my/org-babel-previous-visible-src-block))
|
||
#+end_src
|
||
*** Open a file from =org-directory=
|
||
A function to open a file from =org-directory=, excluding a few directories like =roam= and =journal=.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-file-open ()
|
||
(interactive)
|
||
(let* ((files
|
||
(thread-last
|
||
'("projects" "misc" "learning")
|
||
(mapcar (lambda (f)
|
||
(directory-files (concat org-directory "/" f) t (rx ".org" eos))))
|
||
(apply #'append)
|
||
(mapcar (lambda (file)
|
||
(string-replace (concat org-directory "/") "" file)))
|
||
(append
|
||
'("inbox.org" "contacts.org" "recurring.org")))))
|
||
(find-file
|
||
(concat org-directory "/"
|
||
(completing-read "Org file: " files)))))
|
||
#+end_src
|
||
|
||
** UI
|
||
*** LaTeX fragments
|
||
A function to enable LaTeX native highlighting. Not setting this as default, because it loads LaTeX stuff.
|
||
#+begin_src emacs-lisp
|
||
(defun my/enable-org-latex ()
|
||
(interactive)
|
||
(customize-set-variable 'org-highlight-latex-and-related '(native))
|
||
(add-hook 'org-mode-hook (lambda () (yas-activate-extra-mode 'LaTeX-mode)))
|
||
(sp-local-pair 'org-mode "$" "$")
|
||
(sp--remove-local-pair "'"))
|
||
#+end_src
|
||
|
||
Call the function before opening an org file or reopen a buffer after calling the function.
|
||
|
||
Scale latex fragments preview.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org
|
||
(setq my/org-latex-scale 1.75)
|
||
(setq org-format-latex-options (plist-put org-format-latex-options :scale my/org-latex-scale)))
|
||
#+end_src
|
||
|
||
Also, LaTeX fragments preview tends to break whenever the are custom =#+LATEX_HEADER= entries. To circumvent this, I add a custom header and modify the ~org-preview-latex-process-alist~ variable
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org
|
||
(setq my/latex-preview-header "\\documentclass{article}
|
||
\\usepackage[usenames]{color}
|
||
\\usepackage{graphicx}
|
||
\\usepackage{grffile}
|
||
\\usepackage{longtable}
|
||
\\usepackage{wrapfig}
|
||
\\usepackage{rotating}
|
||
\\usepackage[normalem]{ulem}
|
||
\\usepackage{amsmath}
|
||
\\usepackage{textcomp}
|
||
\\usepackage{amssymb}
|
||
\\usepackage{capt-of}
|
||
\\usepackage{hyperref}
|
||
\\pagestyle{empty}")
|
||
|
||
(setq org-preview-latex-process-alist
|
||
(mapcar
|
||
(lambda (item)
|
||
(cons
|
||
(car item)
|
||
(plist-put (cdr item) :latex-header my/latex-preview-header)))
|
||
org-preview-latex-process-alist)))
|
||
#+end_src
|
||
*** Better headers
|
||
[[https://github.com/integral-dw/org-superstar-mode][org-superstar-mode]] is a package that makes Org heading lines look a bit prettier.
|
||
|
||
Disabled it for now because of overlapping functionality with org-bars.
|
||
#+begin_src emacs-lisp
|
||
(use-package org-superstar
|
||
:straight t
|
||
:disabled
|
||
:hook (org-mode . org-superstar-mode))
|
||
#+end_src
|
||
|
||
[[https://github.com/tonyaldon/org-bars][org-bars]] highlights Org indentation with bars.
|
||
#+begin_src emacs-lisp
|
||
(use-package org-bars
|
||
:straight (:repo "tonyaldon/org-bars" :host github)
|
||
:if (display-graphic-p)
|
||
:hook (org-mode . org-bars-mode))
|
||
#+end_src
|
||
|
||
Fallback to the standard =org-indent-mode= on terminal.
|
||
#+begin_src emacs-lisp
|
||
(unless (display-graphic-p)
|
||
(add-hook 'org-mode-hook #'org-indent-mode))
|
||
#+end_src
|
||
|
||
Remove the ellipsis at the end of folded headlines, as it seems unnecessary with =org-bars=.
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-no-ellipsis-in-headlines ()
|
||
(remove-from-invisibility-spec '(outline . t))
|
||
(add-to-invisibility-spec 'outline))
|
||
|
||
(with-eval-after-load 'org-bars
|
||
(add-hook 'org-mode-hook #'my/org-no-ellipsis-in-headlines)
|
||
(when (eq major-mode 'org-mode)
|
||
(my/org-no-ellipsis-in-headlines)))
|
||
#+end_src
|
||
*** Override colors
|
||
#+begin_src emacs-lisp
|
||
(my/use-colors
|
||
(org-block :background (my/color-value 'bg-other))
|
||
(org-block-begin-line :background (my/color-value 'bg-other)
|
||
:foreground (my/color-value 'grey)))
|
||
#+end_src
|
||
*** Hide stuff in buffer
|
||
[[https://github.com/awth13/org-appear][org-appear]] is a package that toggles visibility of hidden elements upon entering and leaving them.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-appear
|
||
:after (org)
|
||
:straight t)
|
||
#+end_src
|
||
|
||
[[https://github.com/io12/org-fragtog][org-fragtog]] does the same for LaTeX fragment previews.
|
||
#+begin_src emacs-lisp
|
||
(use-package org-fragtog
|
||
:after (org)
|
||
:straight t)
|
||
#+end_src
|
||
** Literate programing
|
||
:PROPERTIES:
|
||
:MODULE_NAME: org-literate
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-org-literate.el :comments links
|
||
:END:
|
||
*** Python & Jupyter
|
||
Use jupyter kernels for Org Mode.
|
||
|
||
References:
|
||
- [[https://github.com/nnicandro/emacs-jupyter][emacs-jupyter repo]]
|
||
- [[https://github.com/jkitchin/scimax/blob/master/scimax.org][SCIMAX manual]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package jupyter
|
||
:straight t
|
||
:after (org)
|
||
:if (not (or my/remote-server my/is-termux)))
|
||
#+end_src
|
||
|
||
Refresh kernelspecs.
|
||
|
||
Kernelspecs by default are hashed, so even switching Anaconda environments doesn't change the kernel (i.e. kernel from the first environment is run after the switch to the second one).
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/jupyter-refresh-kernelspecs ()
|
||
"Refresh Jupyter kernelspecs"
|
||
(interactive)
|
||
(jupyter-available-kernelspecs t))
|
||
#+end_src
|
||
|
||
Also, if some kernel wasn't present at the moment of the load of =emacs-jupyter=, it won't be added to the =org-src-lang-modes= list. E.g. I have Hy kernel installed in a separate Anaconda environment, so if Emacs hasn't been launched in this environment, I wouldn't be able to use =hy= in org-src blocks.
|
||
|
||
Fortunately, =emacs-jupyter= provides a function for that problem as well.
|
||
#+begin_src emacs-lisp
|
||
(defun my/jupyter-refesh-langs ()
|
||
"Refresh Jupyter languages"
|
||
(interactive)
|
||
(org-babel-jupyter-aliases-from-kernelspecs t))
|
||
#+end_src
|
||
|
||
A function to load =jupyter=. The problem with doing that on startup is that it tried to locate the =jupyter= executable, which I have only in an environment.
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-load-jupyter ()
|
||
(interactive)
|
||
(org-babel-do-load-languages
|
||
'org-babel-load-languages
|
||
'((jupyter . t)))
|
||
(my/jupyter-refesh-langs))
|
||
#+end_src
|
||
*** Hy
|
||
#+begin_src emacs-lisp
|
||
(use-package ob-hy
|
||
:after (org)
|
||
:if (not my/remote-server)
|
||
:straight t)
|
||
#+end_src
|
||
*** View HTML in browser
|
||
Open HTML in the ~begin_export~ block with xdg-open.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq my/org-view-html-tmp-dir "/tmp/org-html-preview/")
|
||
|
||
(use-package f
|
||
:straight t)
|
||
|
||
(defun my/org-view-html ()
|
||
(interactive)
|
||
(let ((elem (org-element-at-point))
|
||
(temp-file-path (concat my/org-view-html-tmp-dir (number-to-string (random (expt 2 32))) ".html")))
|
||
(cond
|
||
((not (eq 'export-block (car elem)))
|
||
(message "Not in an export block!"))
|
||
((not (string-equal (plist-get (car (cdr elem)) :type) "HTML"))
|
||
(message "Export block is not HTML!"))
|
||
(t (progn
|
||
(f-mkdir my/org-view-html-tmp-dir)
|
||
(f-write (plist-get (car (cdr elem)) :value) 'utf-8 temp-file-path)
|
||
(start-process "org-html-preview" nil "xdg-open" temp-file-path))))))
|
||
#+end_src
|
||
*** PlantUML
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org
|
||
(setq org-plantuml-executable-path "/home/pavel/.guix-extra-profiles/emacs/emacs/bin/plantuml")
|
||
(setq org-plantuml-exec-mode 'plantuml)
|
||
(add-to-list 'org-src-lang-modes '("plantuml" . plantuml)))
|
||
#+end_src
|
||
*** Restclient
|
||
[[https://github.com/pashky/restclient.el][restclient.el]] is an Emacs package to send HTTP requests. [[https://github.com/alf/ob-restclient.el][ob-restclient]] provides interaction with Org Babel.
|
||
|
||
References:
|
||
- [[https://joseph8th.github.io/posts/wow-writing-literate-api-documentation-in-emacs-org-mode/][WOW! Writing Literate API Documentation in Emacs Org Mode]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package restclient
|
||
:if (not my/remote-server)
|
||
:straight t
|
||
:mode ("\\.http\\'" . restclient-mode)
|
||
:config
|
||
(general-define-key
|
||
:keymaps 'restclient-mode-map
|
||
:states '(normal visual)
|
||
"RET" #'restclient-http-send-current
|
||
"M-RET" #'restclient-http-send-current-stay-in-window
|
||
"y" nil
|
||
"M-y" #'restclient-copy-curl-command)
|
||
(general-define-key
|
||
:keymaps 'restclient-response-mode-map
|
||
:states '(normal visual)
|
||
"q" #'quit-window))
|
||
|
||
(use-package ob-restclient
|
||
:after (org restclient)
|
||
:if (not my/remote-server)
|
||
:straight t)
|
||
#+end_src
|
||
*** Org Babel Setup
|
||
Enable languages
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org
|
||
(org-babel-do-load-languages
|
||
'org-babel-load-languages
|
||
`((emacs-lisp . t)
|
||
(python . t)
|
||
(sql . t)
|
||
(sqlite . t)
|
||
;; (typescript .t)
|
||
(hy . t)
|
||
(shell . t)
|
||
(plantuml . t)
|
||
(octave . t)
|
||
(sparql . t)
|
||
(gnuplot . t)))
|
||
|
||
(add-hook 'org-babel-after-execute-hook 'org-redisplay-inline-images))
|
||
#+end_src
|
||
|
||
Use Jupyter block instead of built-in Python.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'ob-jupyter
|
||
(org-babel-jupyter-override-src-block "python")
|
||
(org-babel-jupyter-override-src-block "hy"))
|
||
#+end_src
|
||
|
||
Turn of some minor modes in source blocks.
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'org-src-mode-hook
|
||
(lambda ()
|
||
;; (hs-minor-mode -1)
|
||
;; (electric-indent-local-mode -1)
|
||
;; (rainbow-delimiters-mode -1)
|
||
;; (highlight-indent-guides-mode -1)
|
||
))
|
||
#+end_src
|
||
|
||
Async code blocks evaluations. Jupyter blocks have a built-in async, so they are set as ignored.
|
||
#+begin_src emacs-lisp
|
||
(use-package ob-async
|
||
:straight t
|
||
:after (org)
|
||
:config
|
||
(setq ob-async-no-async-languages-alist '("python" "hy" "jupyter-python" "jupyter-octave" "restclient")))
|
||
#+end_src
|
||
*** Managing Jupyter kernels
|
||
Functions for managing local Jupyter kernels.
|
||
|
||
~my/insert-jupyter-kernel~ inserts a path to an active Jupyter kernel to the buffer. Useful to quickly write a header like:
|
||
#+begin_example
|
||
#+PROPERTY: header-args:python :session <path-to-kernel>
|
||
#+end_example
|
||
|
||
~my/jupyter-connect-repl~ opens a =emacs-jupyter= REPL, connected to an active kernel. ~my/jupyter-qtconsole~ runs a standalone Jupyter QtConsole.
|
||
|
||
Requirements: =ss=
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq my/jupyter-runtime-folder (expand-file-name "~/.local/share/jupyter/runtime"))
|
||
|
||
(defun my/get-open-ports ()
|
||
(mapcar
|
||
#'string-to-number
|
||
(split-string (shell-command-to-string "ss -tulpnH | awk '{print $5}' | sed -e 's/.*://'") "\n")))
|
||
|
||
(defun my/list-jupyter-kernel-files ()
|
||
(mapcar
|
||
(lambda (file) (cons (car file) (cdr (assq 'shell_port (json-read-file (car file))))))
|
||
(sort
|
||
(directory-files-and-attributes my/jupyter-runtime-folder t ".*kernel.*json$")
|
||
(lambda (x y) (not (time-less-p (nth 6 x) (nth 6 y)))))))
|
||
|
||
(defun my/select-jupyter-kernel ()
|
||
(let ((ports (my/get-open-ports))
|
||
(files (my/list-jupyter-kernel-files)))
|
||
(completing-read
|
||
"Jupyter kernels: "
|
||
(seq-filter
|
||
(lambda (file)
|
||
(member (cdr file) ports))
|
||
files))))
|
||
|
||
(defun my/insert-jupyter-kernel ()
|
||
"Insert a path to an active Jupyter kernel into the buffer"
|
||
(interactive)
|
||
(insert (my/select-jupyter-kernel)))
|
||
|
||
(defun my/jupyter-connect-repl ()
|
||
"Open an emacs-jupyter REPL, connected to a Jupyter kernel"
|
||
(interactive)
|
||
(jupyter-connect-repl (my/select-jupyter-kernel) nil nil nil t))
|
||
|
||
(defun my/jupyter-qtconsole ()
|
||
"Open Jupyter QtConsole, connected to a Jupyter kernel"
|
||
(interactive)
|
||
(start-process "jupyter-qtconsole" nil "setsid" "jupyter" "qtconsole" "--existing"
|
||
(file-name-nondirectory (my/select-jupyter-kernel))))
|
||
#+end_src
|
||
|
||
I've also noticed that there are JSON files left in the runtime folder whenever the kernel isn't stopped correctly. So here is a cleanup function.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/jupyter-cleanup-kernels ()
|
||
(interactive)
|
||
(let* ((ports (my/get-open-ports))
|
||
(files (my/list-jupyter-kernel-files))
|
||
(to-delete (seq-filter
|
||
(lambda (file)
|
||
(not (member (cdr file) ports)))
|
||
files)))
|
||
(when (and (length> to-delete 0)
|
||
(y-or-n-p (format "Delete %d files?" (length to-delete))))
|
||
(dolist (file to-delete)
|
||
(delete-file (car file))))))
|
||
#+end_src
|
||
*** Output post-processing
|
||
**** Do not wrap the output in emacs-jupyter
|
||
Emacs-jupyter has its own insertion mechanisms, which always prepends output statements with =:=. That is not desirable in cases where a kernel supports only plain output, e.g. calysto_hy kernel.
|
||
|
||
So there we have a minor mode that overrides this behavior.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/jupyter-org-scalar (value)
|
||
(cond
|
||
((stringp value) value)
|
||
(t (jupyter-org-scalar value))))
|
||
|
||
(define-minor-mode my/emacs-jupyter-raw-output
|
||
"Make emacs-jupyter do raw output")
|
||
|
||
(defun my/jupyter-org-scalar-around (fun value)
|
||
(if my/emacs-jupyter-raw-output
|
||
(my/jupyter-org-scalar value)
|
||
(funcall fun value)))
|
||
|
||
(with-eval-after-load 'jupyter
|
||
(advice-add 'jupyter-org-scalar :around #'my/jupyter-org-scalar-around))
|
||
#+end_src
|
||
**** Wrap source code output
|
||
A function to remove the :RESULTS: drawer from results. Once again, it's necessary because emacs-jupyter doesn't seem to respect =:results raw=.
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-strip-results (data)
|
||
(replace-regexp-in-string ":\\(RESULTS\\|END\\):\n" "" data))
|
||
#+end_src
|
||
|
||
And an all-in-one function to:
|
||
- prepend =#+NAME:= and =#+CAPTION:= to the source block output. Useful if the output is an image.
|
||
- strip the :RESULTS: drawer from the output, if necessary
|
||
- wrap results in the =src= block
|
||
|
||
As for now, it looks sufficient to format source code outputs to get a tolerable LaTeX.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-caption-wrap (data &optional name caption attrs strip-drawer src-wrap)
|
||
(let* ((data-s (if (and strip-drawer (not (string-empty-p strip-drawer)))
|
||
(my/org-strip-results data)
|
||
data))
|
||
(drawer-start (if (string-match-p "^:RESULTS:.*" data-s) 10 0)))
|
||
(concat
|
||
(substring data-s 0 drawer-start)
|
||
(and name (not (string-empty-p name)) (concat "#+NAME:" name "\n"))
|
||
(and caption (not (string-empty-p caption)) (concat "#+CAPTION:" caption "\n"))
|
||
(and attrs (not (string-empty-p attrs)) (concat "#+ATTR_LATEX:" attrs "\n"))
|
||
(if (and src-wrap (not (string-empty-p src-wrap)))
|
||
(concat "#+begin_src " src-wrap "\n"
|
||
(substring data-s drawer-start)
|
||
(when (not (string-match-p ".*\n" data-s)) "\n")
|
||
"#+end_src")
|
||
(substring data-s drawer-start)))))
|
||
#+end_src
|
||
|
||
To use, add the following snippet to the org file:
|
||
#+begin_example
|
||
#+NAME: out_wrap
|
||
#+begin_src emacs-lisp :var data="" caption="" name="" attrs="" strip-drawer="" src-wrap="" :tangle no :exports none
|
||
(my/org-caption-wrap data name caption attrs strip-drawer src-wrap)
|
||
#+end_src
|
||
#+end_example
|
||
|
||
Example usage:
|
||
#+begin_example
|
||
:post out_wrap(name="fig:chart", caption="График", data=*this*)
|
||
#+end_example
|
||
**** Apply ANSI color codes
|
||
*SOURCE*: [[https://emacs.stackexchange.com/questions/44664/apply-ansi-color-escape-sequences-for-org-babel-results][Apply ANSI color escape sequences for Org Babel results]]
|
||
|
||
A minor mode to apply ANSI color codes after execution.
|
||
#+begin_src emacs-lisp
|
||
(defun my/babel-ansi ()
|
||
(when-let ((beg (org-babel-where-is-src-block-result nil nil)))
|
||
(save-excursion
|
||
(goto-char beg)
|
||
(when (looking-at org-babel-result-regexp)
|
||
(let ((end (org-babel-result-end))
|
||
(ansi-color-context-region nil))
|
||
(ansi-color-apply-on-region beg end))))))
|
||
|
||
(define-minor-mode org-babel-ansi-colors-mode
|
||
"Apply ANSI color codes to Org Babel results."
|
||
:global t
|
||
:after-hook
|
||
(if org-babel-ansi-colors-mode
|
||
(add-hook 'org-babel-after-execute-hook #'my/babel-ansi)
|
||
(remove-hook 'org-babel-after-execute-hook #'my/babel-ansi)))
|
||
#+end_src
|
||
*** Executing stuff
|
||
A few convinient functions and keybindings to execute things in an org buffer.
|
||
|
||
First, execute things above and below the point:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-babel-execute-buffer-below (&optional arg)
|
||
(interactive "P")
|
||
(org-babel-eval-wipe-error-buffer)
|
||
(let ((point (point)))
|
||
(org-save-outline-visibility t
|
||
(org-babel-map-executables nil
|
||
(when (>= (point) point)
|
||
(if (memq (org-element-type (org-element-context))
|
||
'(babel-call inline-babel-call))
|
||
(org-babel-lob-execute-maybe)
|
||
(org-babel-execute-src-block arg)))))))
|
||
|
||
(defun my/org-babel-execute-buffer-above (&optional arg)
|
||
(interactive "P")
|
||
(org-babel-eval-wipe-error-buffer)
|
||
(let ((point (point)))
|
||
(org-save-outline-visibility t
|
||
(org-babel-map-executables nil
|
||
(when (<= (point) point)
|
||
(if (memq (org-element-type (org-element-context))
|
||
'(babel-call inline-babel-call))
|
||
(org-babel-lob-execute-maybe)
|
||
(org-babel-execute-src-block arg)))))))
|
||
#+end_src
|
||
|
||
Execute all code blocks marked with =:startup t=:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-babel-execute-marked (&optional arg)
|
||
(interactive "P")
|
||
(let (markers)
|
||
(org-element-map (org-element-parse-buffer) 'src-block
|
||
(lambda (elem)
|
||
(let ((params (org-element-property :parameters elem)))
|
||
(when (and params
|
||
(string-match-p (rx "startup t") params))
|
||
(let ((m (make-marker)))
|
||
(set-marker m (org-element-property :begin elem))
|
||
(set-marker-insertion-type m t)
|
||
(push m markers))))))
|
||
(setq markers (nreverse markers))
|
||
(when arg
|
||
(setq markers
|
||
(seq-filter
|
||
(lambda (m) (> (marker-position m) (point)))
|
||
markers)))
|
||
(dolist (m markers)
|
||
(goto-char m)
|
||
(ignore-errors
|
||
(org-babel-execute-src-block)))))
|
||
#+end_src
|
||
|
||
Some keybindings:
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org
|
||
(general-define-key
|
||
:keymaps 'org-babel-map
|
||
"B" #'my/org-babel-execute-buffer-below
|
||
"A" #'my/org-babel-execute-buffer-above)
|
||
|
||
(my-leader-def
|
||
:keymaps 'org-mode-map
|
||
"SPC b" '(:wk "org-babel")
|
||
"SPC b" org-babel-map))
|
||
#+end_src
|
||
|
||
*** Managing a literate programming project
|
||
A few tricks to do literate programming. I actually have only one ([[https://github.com/SqrtMinusOne/sqrt-data][sqrt-data]]), and I'm not convinced in the benefits of the approach...
|
||
|
||
Anyway, Org files are better off in a separated directory (e.g. =org=). So I've come up with the following solution to avoid manually prefixing the =:tangle= arguments.
|
||
|
||
Set up the following argument with the path to the project root:
|
||
#+begin_example
|
||
#+PROPERTY: PRJ-DIR ..
|
||
#+end_example
|
||
|
||
A function to do the prefixing:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-prj-dir (path)
|
||
(expand-file-name path (org-entry-get nil "PRJ-DIR" t)))
|
||
#+end_src
|
||
|
||
Example usage is as follows:
|
||
#+begin_example
|
||
:tangle (my/org-prj-dir "sqrt_data/api/__init__.py")
|
||
#+end_example
|
||
** Tools
|
||
Various small packages.
|
||
|
||
*** Presentations
|
||
Doing presentations with [[https://github.com/rlister/org-present][org-present]].
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package hide-mode-line
|
||
:straight t
|
||
:commands (hide-mode-line-mode))
|
||
|
||
(defun my/present-next-with-latex ()
|
||
(interactive)
|
||
(org-present-next)
|
||
(org-latex-preview '(16)))
|
||
|
||
(defun my/present-prev-with-latex ()
|
||
(interactive)
|
||
(org-present-prev)
|
||
(org-latex-preview '(16)))
|
||
|
||
(use-package org-present
|
||
:straight (:host github :repo "rlister/org-present")
|
||
:if (not my/remote-server)
|
||
:commands (org-present)
|
||
:config
|
||
(general-define-key
|
||
:keymaps 'org-present-mode-keymap
|
||
"<next>" 'my/present-next-with-latex
|
||
"<prior>" 'my/present-prev-with-latex)
|
||
(setq org-present-mode-hook
|
||
(list (lambda ()
|
||
(blink-cursor-mode 0)
|
||
(org-present-big)
|
||
(org-bars-mode -1)
|
||
;; (org-display-inline-images)
|
||
(org-present-hide-cursor)
|
||
(org-present-read-only)
|
||
(display-line-numbers-mode 0)
|
||
(hide-mode-line-mode +1)
|
||
(setq-local org-format-latex-options
|
||
(plist-put org-format-latex-options
|
||
:scale (* org-present-text-scale my/org-latex-scale 0.5)))
|
||
;; (org-latex-preview '(16))
|
||
;; TODO ^somehow this stucks at running LaTeX^
|
||
(setq-local olivetti-body-width 60)
|
||
(olivetti-mode 1))))
|
||
(setq org-present-mode-quit-hook
|
||
(list (lambda ()
|
||
(blink-cursor-mode 1)
|
||
(org-present-small)
|
||
(org-bars-mode 1)
|
||
;; (org-remove-inline-images)
|
||
(org-present-show-cursor)
|
||
(org-present-read-write)
|
||
(display-line-numbers-mode 1)
|
||
(hide-mode-line-mode 0)
|
||
(setq-local org-format-latex-options (plist-put org-format-latex-options :scale my/org-latex-scale))
|
||
(org-latex-preview '(64))
|
||
(olivetti-mode -1)
|
||
(setq-local olivetti-body-width (default-value 'olivetti-body-width))))))
|
||
#+end_src
|
||
*** Screenshots
|
||
A nice package to make screenshots and insert them to the Org document.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-attach-screenshot
|
||
:commands (org-attach-screenshot)
|
||
:straight t
|
||
:config
|
||
(setq org-attach-screenshot-auto-refresh 'never))
|
||
#+end_src
|
||
*** Transclusion
|
||
A package that implements transclusions in Org Mode, i.e. rendering part of one file inside another file.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-transclusion
|
||
:after org
|
||
:straight (:host github :repo "nobiot/org-transclusion")
|
||
:config
|
||
(add-to-list 'org-transclusion-extensions 'org-transclusion-indent-mode)
|
||
(require 'org-transclusion-indent-mode)
|
||
(general-define-key
|
||
:keymaps '(org-transclusion-map)
|
||
:states '(normal)
|
||
"RET" #'org-transclusion-open-source
|
||
"gr" #'org-transclusion-refresh)
|
||
(general-define-key
|
||
:keymaps '(org-mode-map)
|
||
:states 'normal
|
||
"C-c t a" #'org-transclusion-add
|
||
"C-c t A" #'org-transclusion-add-all
|
||
"C-c t t" #'org-transclusion-mode))
|
||
#+end_src
|
||
*** Managing tables
|
||
I use Org to manage some small tables which I want to process further. So here is a function that saves each table to a CSV file.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/export-org-tables-to-csv ()
|
||
(interactive)
|
||
(org-table-map-tables
|
||
(lambda ()
|
||
(when-let
|
||
(name
|
||
(plist-get (cadr (org-element-at-point)) :name))
|
||
(org-table-export
|
||
(concat
|
||
(file-name-directory
|
||
(buffer-file-name))
|
||
name ".csv")
|
||
"orgtbl-to-csv")))))
|
||
#+end_src
|
||
** Productivity & Knowledge management
|
||
:PROPERTIES:
|
||
:MODULE_NAME: org-productivity
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-org-productivity.el :comments links
|
||
:END:
|
||
My ongoing effort to +get a productivity setup+ manage something in my life in Org.
|
||
|
||
Initial inspirations (<2021-06-30 Wed>):
|
||
- [[https://www.labri.fr/perso/nrougier/GTD/index.html][Nicolas P. Rougier. Get Things Done with Emacs]]
|
||
- [[https://blog.jethro.dev/posts/org_mode_workflow_preview/][Jetro Kuan. Org-mode Workflow]]
|
||
- [[https://www.alexeyshmalko.com/how-i-note/][Alexey Shmalko: How I note]]
|
||
- [[https://rgoswami.me/posts/org-note-workflow/][Rohit Goswami: An Orgmode Note Workflow]]
|
||
|
||
Current status of what I ended up using (<2023-12-16 Sat>):
|
||
- [[*Org Journal][org-journal]] for keeping a journal
|
||
- [[*Org Roam][org-roam]] for a knowledge base.
|
||
- org-agenda with org-clock for tasks
|
||
|
||
*** Org Agenda & Project Management
|
||
This section had seen a lot of experimentation over the last... well, years.
|
||
|
||
**** Agenda & refile files
|
||
All my project files live in the =/projects= directory, so here's a function to set up =org-agenda-files= and =org-refile-targets= accordingly.
|
||
|
||
Also, my project structure is somewhat chaotic, so I have an =.el= file in the org directory that defines some of the refile targets.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/update-org-agenda ()
|
||
(interactive)
|
||
(let ((project-files
|
||
(when (file-directory-p (concat org-directory "/projects"))
|
||
(thread-last "/projects"
|
||
(concat org-directory)
|
||
(directory-files)
|
||
(mapcar (lambda (f)
|
||
(concat
|
||
org-directory "/projects/" f)))
|
||
(seq-filter (lambda (f)
|
||
(not (file-directory-p f))))))))
|
||
(setq org-agenda-files
|
||
(seq-filter #'file-exists-p
|
||
(append
|
||
project-files
|
||
(mapcar (lambda (f)
|
||
(concat org-directory "/" f))
|
||
'("inbox.org"
|
||
"misc/habit.org"
|
||
"contacts.org")))))
|
||
(setq org-refile-targets
|
||
`(,@(mapcar
|
||
(lambda (f) `(,f . (:tag . "refile")))
|
||
project-files)
|
||
,@(mapcar
|
||
(lambda (f) `(,f . (:regexp . "Tasks")))
|
||
project-files)))
|
||
(when (file-exists-p (concat org-directory "/scripts/refile.el"))
|
||
(load-file (concat org-directory "/scripts/refile.el"))
|
||
(run-hooks 'my/org-refile-hooks))))
|
||
|
||
(setq org-roam-directory (concat org-directory "/roam"))
|
||
(with-eval-after-load 'org
|
||
(require 'seq)
|
||
(my/update-org-agenda))
|
||
#+end_src
|
||
|
||
Refile settings
|
||
#+begin_src emacs-lisp
|
||
(setq org-refile-use-outline-path 'file)
|
||
(setq org-outline-path-complete-in-steps nil)
|
||
#+end_src
|
||
|
||
My day ends late sometimes. Thanks John Wigley.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq org-extend-today-until 4)
|
||
#+end_src
|
||
|
||
**** Capture templates
|
||
Settings for Org capture mode. The goal here is to have a non-disruptive process to capture various ideas.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/generate-inbox-note-name ()
|
||
(format
|
||
"%s/inbox-notes/%s%s.org"
|
||
org-directory
|
||
(format-time-string "%Y%m%d%H%M%S")
|
||
(let ((note-name (read-string "Note name: ")))
|
||
(if (not (string-empty-p note-name))
|
||
(string-replace " " "-" (concat "-" (downcase note-name)))
|
||
""))))
|
||
|
||
(setq org-capture-templates
|
||
`(("i" "Inbox" entry (file "inbox.org")
|
||
,(concat "* TODO %?\n"
|
||
"/Entered on/ %U"))
|
||
("e" "email" entry (file "inbox.org")
|
||
,(concat "* TODO %:from %:subject \n"
|
||
"/Entered on/ %U\n"
|
||
"/Received on/ %:date-timestamp-inactive\n"
|
||
"%a\n"))
|
||
("f" "elfeed" entry (file "inbox.org")
|
||
,(concat "* TODO %:elfeed-entry-title\n"
|
||
"/Entered on/ %U\n"
|
||
"%a\n"))
|
||
("n" "note" plain (file my/generate-inbox-note-name)
|
||
,(concat "#+TODO: PROCESSED(p)\n"
|
||
"\n"
|
||
"* %?\n"
|
||
"/Entered on/ %U"))))
|
||
#+end_src
|
||
|
||
**** org-clock & org-clock-agg
|
||
[[https://orgmode.org/manual/Clocking-Work-Time.html][org-clock]] allows for tracking time spent in Org entries. [[https://github.com/SqrtMinusOne/org-clock-agg][org-clock-agg]] is my package for creating reports from org-clock records.
|
||
|
||
It's been somewhat complicated to integrate into my workflow, but I think it's been worth it because I can now create reports for:
|
||
- how much time i spent on which category of tasks (education / job / ...);
|
||
- time spent per activity, particularly time spent on meetings per category;
|
||
- time spent per project
|
||
- ...
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-clock-agg
|
||
:straight (:host github :repo "SqrtMinusOne/org-clock-agg")
|
||
:commands (org-clock-agg)
|
||
:init
|
||
(with-eval-after-load 'org
|
||
(my-leader-def "ol" #'org-clock-agg))
|
||
:config
|
||
(setq org-clock-agg-node-format
|
||
"%-%(+ title-width)t %20c %8z %s/%S")
|
||
(setq org-clock-agg-node-title-width-delta 47)
|
||
(push
|
||
(cons "Agenda+Archive"
|
||
(append
|
||
(org-agenda-files)
|
||
(thread-last "/projects/archive"
|
||
(concat org-directory)
|
||
(directory-files)
|
||
(mapcar (lambda (f)
|
||
(concat
|
||
org-directory "/projects/archive/" f)))
|
||
(seq-filter (lambda (f)
|
||
(not (file-directory-p f)))))))
|
||
org-clock-agg-files-preset))
|
||
#+end_src
|
||
|
||
The following enables org-clock persistence between Emacs sessions.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org
|
||
(setq org-clock-persist 'clock)
|
||
(org-clock-persistence-insinuate))
|
||
#+end_src
|
||
|
||
Effort estimation. Not using this as of now.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org
|
||
(add-to-list
|
||
'org-global-properties
|
||
'("Effort_ALL" . "0 0:05 0:10 0:15 0:30 0:45 1:00 1:30 2:00 4:00 8:00")))
|
||
#+end_src
|
||
|
||
Log DONE time
|
||
#+begin_src emacs-lisp
|
||
(setq org-log-done 'time)
|
||
#+end_src
|
||
|
||
***** Custom modeline positioning
|
||
I wanted =org-mode-line-string= to be prepended to =global-mode-string= rather than appended, but somehow the modeline stops working if =org-mode-line-string= is the first element... So I'll at least put it before my =exwm-modeline-segment=.
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-clock-in--fix-mode-line ()
|
||
(when (memq 'org-mode-line-string global-mode-string)
|
||
(let (new-global-mode-string
|
||
appended
|
||
(is-first t))
|
||
(dolist (item global-mode-string)
|
||
(cond
|
||
((or (equal item '(:eval (exwm-modeline-segment)))
|
||
(equal item '(:eval (persp-mode-line))))
|
||
(unless appended
|
||
(when is-first
|
||
(push "" new-global-mode-string))
|
||
(push 'org-mode-line-string new-global-mode-string)
|
||
(setq appended t))
|
||
(push item new-global-mode-string))
|
||
((equal item 'org-mode-line-string))
|
||
(t
|
||
(push item new-global-mode-string)))
|
||
(setq is-first nil))
|
||
(unless appended
|
||
(push 'org-mode-line-string new-global-mode-string))
|
||
(setq global-mode-string (nreverse new-global-mode-string)))))
|
||
|
||
(add-hook 'org-clock-in-hook #'my/org-clock-in--fix-mode-line)
|
||
#+end_src
|
||
|
||
***** Prompt start time for org-clock-in
|
||
Support prompting for start time for =org-clock-in=:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-clock-in-prompt-time (&optional select)
|
||
(interactive "P")
|
||
(org-clock-in
|
||
select
|
||
(encode-time
|
||
(org-parse-time-string
|
||
(org-read-date t)))))
|
||
|
||
(with-eval-after-load 'org
|
||
(my-leader-def
|
||
:keymaps 'org-mode-map
|
||
:infix "SPC"
|
||
"I" #'my/org-clock-in-prompt-time))
|
||
#+end_src
|
||
|
||
***** Put total clocked time in properties
|
||
By default, =org-clock= stores its results only in the =:LOGBOOK:= drawer, which doesn't get parsed by =org-element-at-point=. As such, clock resutls are inaccessible from =org-ql=.
|
||
|
||
This ensures that the total clocked time is also saved in the =:PROPERTIES:= drawer.
|
||
|
||
We can get the clocked value in minutes with =org-clock-sum=. This weird function stores what I need in buffer-local variables and text-properties.
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-clock-get-total-minutes-at-point ()
|
||
"Get total clocked time for heading at point."
|
||
(let* ((element (org-element-at-point-no-context))
|
||
(s (buffer-substring-no-properties
|
||
(org-element-property :begin element)
|
||
(org-element-property :end element))))
|
||
(with-temp-buffer
|
||
(insert s)
|
||
(org-clock-sum)
|
||
org-clock-file-total-minutes)))
|
||
#+end_src
|
||
|
||
And use the function to set the total clocked time.
|
||
#+begin_src emacs-lisp
|
||
(defconst my/org-clock-total-prop :CLOCK_TOTAL)
|
||
|
||
(defun my/org-clock-set-total-clocked ()
|
||
"Set total clocked time for heading at point."
|
||
(interactive)
|
||
(save-excursion
|
||
(org-back-to-heading t)
|
||
(org-set-property
|
||
(substring
|
||
(symbol-name my/org-clock-total-prop)
|
||
1)
|
||
(org-duration-from-minutes
|
||
(my/org-clock-get-total-minutes-at-point)))))
|
||
|
||
(add-hook 'org-clock-in-hook #'my/org-clock-set-total-clocked)
|
||
(add-hook 'org-clock-out-hook #'my/org-clock-set-total-clocked)
|
||
(add-hook 'org-clock-cancel-hook #'my/org-clock-set-total-clocked)
|
||
#+end_src
|
||
|
||
***** Switch between recently clocked items
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-clock-recent ()
|
||
(interactive)
|
||
(let* ((entries (org-ql-query
|
||
:select #'element-with-markers
|
||
:from (org-agenda-files)
|
||
:where '(clocked :from -1)))
|
||
(entries-data (mapcar (lambda (e)
|
||
(cons (org-element-property :raw-value e) e))
|
||
entries)))
|
||
(unless entries
|
||
(user-error "No recently clocked entries!"))
|
||
entries-data
|
||
(let* ((entry (alist-get (completing-read "Entry: " entries-data)
|
||
entries-data nil nil #'equal))
|
||
(marker (org-element-property :org-marker entry)))
|
||
(pop-to-buffer-same-window (marker-buffer marker))
|
||
(goto-char marker))))
|
||
|
||
(with-eval-after-load 'org
|
||
(my-leader-def
|
||
:keymaps 'org-mode-map
|
||
:infix "SPC"
|
||
"C" #'my/org-clock-recent))
|
||
#+end_src
|
||
***** Fix tasks without TASK_KIND
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-fix-task-kind ()
|
||
(interactive)
|
||
(let ((entries (org-ql-query
|
||
:select #'element-with-markers
|
||
:from (current-buffer)
|
||
:where '(and (olp "Tasks")
|
||
(not (property "TASK_KIND"))
|
||
(clocked)))))
|
||
(org-fold-show-all)
|
||
(dolist (entry entries)
|
||
(let ((marker (org-element-property :org-marker entry)))
|
||
(org-with-point-at marker
|
||
(let ((value (org-read-property-value "TASK_KIND")))
|
||
(org-set-property "TASK_KIND" value)))))))
|
||
#+end_src
|
||
**** org-super-agenda
|
||
[[https://github.com/alphapapa/org-super-agenda][org-super-agenda]] is alphapapa's extension to group items in org-agenda. I don't use it instead of the standard agenda, but =org-ql= uses it for some of its views.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-super-agenda
|
||
:straight t
|
||
:after (org)
|
||
:config
|
||
;; Alphapapa doesn't like evil
|
||
(general-define-key
|
||
:keymaps '(org-super-agenda-header-map)
|
||
"h" nil
|
||
"j" nil
|
||
"k" nil
|
||
"l" nil)
|
||
(org-super-agenda--def-auto-group outline-path-file "their outline paths & files"
|
||
:key-form
|
||
(org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
|
||
;; org-ql depends on f and s anyway
|
||
(s-join "/" (cons
|
||
(f-filename (buffer-file-name))
|
||
(org-get-outline-path))))))
|
||
#+end_src
|
||
|
||
It doesn't look great with org-bars mode, so...
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-super-agenda--make-agenda-header-around (fun name)
|
||
(remove-text-properties 0 (length name) '(line-prefix nil) name)
|
||
(remove-text-properties 0 (length name) '(wrap-prefix nil) name)
|
||
(funcall fun (substring-no-properties name)))
|
||
|
||
(with-eval-after-load 'org-super-agenda
|
||
(advice-add 'org-super-agenda--make-agenda-header :around #'my/org-super-agenda--make-agenda-header-around))
|
||
#+end_src
|
||
|
||
**** org-ql
|
||
[[https://github.com/alphapapa/org-ql][org-ql]] is a package to query org files.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-ql
|
||
:after (org)
|
||
:if (not my/remote-server)
|
||
:straight t
|
||
:config
|
||
(setq org-ql-ask-unsafe-queries nil)
|
||
:init
|
||
;; See https://github.com/alphapapa/org-ql/pull/237
|
||
(setq org-ql-regexp-part-ts-time
|
||
(rx " " (repeat 1 2 digit) ":" (repeat 2 digit)
|
||
(optional "-" (repeat 1 2 digit) ":" (repeat 2 digit))))
|
||
(my-leader-def
|
||
:infix "o"
|
||
"v" #'org-ql-view
|
||
"q" #'org-ql-search))
|
||
#+end_src
|
||
|
||
***** Recent items
|
||
I just want to change the default grouping in =org-ql-view-recent-items=...
|
||
#+begin_src emacs-lisp
|
||
(cl-defun my/org-ql-view-recent-items
|
||
(&key num-days (type 'ts)
|
||
(files (org-agenda-files))
|
||
(groups '((:auto-outline-path-file t)
|
||
(:auto-todo t))))
|
||
"Show items in FILES from last NUM-DAYS days with timestamps of TYPE.
|
||
TYPE may be `ts', `ts-active', `ts-inactive', `clocked', or
|
||
`closed'."
|
||
(interactive (list :num-days (read-number "Days: ")
|
||
:type (->> '(ts ts-active ts-inactive clocked closed)
|
||
(completing-read "Timestamp type: ")
|
||
intern)))
|
||
;; It doesn't make much sense to use other date-based selectors to
|
||
;; look into the past, so to prevent confusion, we won't allow them.
|
||
(-let* ((query (pcase-exhaustive type
|
||
((or 'ts 'ts-active 'ts-inactive)
|
||
`(,type :from ,(- num-days) :to 0))
|
||
((or 'clocked 'closed)
|
||
`(,type :from ,(- num-days) :to 0)))))
|
||
(org-ql-search files query
|
||
:title "Recent items"
|
||
:sort '(todo priority date)
|
||
:super-groups groups)))
|
||
#+end_src
|
||
|
||
***** Return all TODOs
|
||
A view to return all TODOs in a category.
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-ql-all-todo ()
|
||
(interactive)
|
||
;; The hack I borrowed from notmuch to make " " a separator
|
||
(let* ((crm-separator " ")
|
||
(crm-local-completion-map
|
||
(let ((map (make-sparse-keymap)))
|
||
(set-keymap-parent map crm-local-completion-map)
|
||
(define-key map " " 'self-insert-command)
|
||
map))
|
||
(vertico-sort-function nil)
|
||
(categories (completing-read-multiple
|
||
"Categories: "
|
||
'("TEACH" "EDU" "JOB" "LIFE" "COMP"))))
|
||
(org-ql-search (org-agenda-files)
|
||
`(and (todo)
|
||
,@(unless (seq-empty-p categories)
|
||
`((category ,@categories))))
|
||
:sort '(priority todo deadline)
|
||
:super-groups '((:auto-outline-path-file t)))))
|
||
#+end_src
|
||
|
||
***** Items clocked or closed today
|
||
Some custom functions to account for =org-extend-today-until=. Needed because sometimes my daily reviews cross 00:00.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-ql-clocked-today ()
|
||
(interactive)
|
||
(let ((today (format-time-string
|
||
"%Y-%m-%d"
|
||
(days-to-time
|
||
(- (org-today) (time-to-days 0))))))
|
||
(org-ql-search (org-agenda-files) `(clocked :from ,today)
|
||
:title "Clocked today"
|
||
:sort '(todo priority date)
|
||
:super-groups '((:auto-outline-path-file t)
|
||
(:auto-todo t)))))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-ql-closed-today ()
|
||
(interactive)
|
||
(let ((today (format-time-string
|
||
"%Y-%m-%d"
|
||
(days-to-time
|
||
(- (org-today) (time-to-days 0))))))
|
||
(org-ql-search (org-agenda-files) `(closed :from ,today)
|
||
:title "Closed today"
|
||
:sort '(todo priority date)
|
||
:super-groups '((:auto-outline-path-file t)
|
||
(:auto-todo t)))))
|
||
#+end_src
|
||
|
||
***** Configuring views
|
||
Putting all the above in =org-ql-views=.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq org-ql-views
|
||
(list
|
||
(cons "Overview: All TODO" #'my/org-ql-all-todo)
|
||
(cons "Review: Stale tasks"
|
||
(list :buffers-files #'org-agenda-files
|
||
:query '(and (todo)
|
||
(not (tags "nots"))
|
||
(not (ts :from -14)))
|
||
:title "Review: Stale tasks"
|
||
:sort '(todo priority date)
|
||
:super-groups '((:auto-outline-path-file t))))
|
||
(cons "Review: Unclocked tasks"
|
||
(list :buffers-files #'org-agenda-files
|
||
:query '(and (done)
|
||
(ts :from -14)
|
||
(not (clocked))
|
||
(not (tags "nots")))
|
||
:title "Review: Unclocked tasks"
|
||
:sort '(todo priority date)
|
||
:super-groups '((:auto-outline-path-file t))))
|
||
(cons "Review: Recently timestamped" #'my/org-ql-view-recent-items)
|
||
(cons "Review: Clocked today" #'my/org-ql-clocked-today)
|
||
(cons "Review: Closed today" #'my/org-ql-closed-today)
|
||
(cons "Fix: tasks without TASK_KIND"
|
||
(lambda ()
|
||
(interactive)
|
||
(org-ql-search (current-buffer)
|
||
'(and (olp "Tasks")
|
||
(not (property "TASK_KIND"))
|
||
(clocked))
|
||
:super-groups '((:auto-outline-path-file t)))))))
|
||
#+end_src
|
||
|
||
***** Custom format element
|
||
Changing the default =org-ql-view--format-element= to include effort estimation and the clocked time. I wish it were more configurable out-of-the-box.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-ql-view--format-element-override (element)
|
||
"Format ELEMENT for `org-ql-view'.
|
||
|
||
Check `org-ql-view--format-element' for the original implementation
|
||
and lots of comments which are too long for my Emacs config."
|
||
(if (not element)
|
||
""
|
||
(setf element (org-ql-view--resolve-element-properties element))
|
||
(let* ((properties (cadr element))
|
||
(properties (cl-loop for (key val) on properties by #'cddr
|
||
for symbol = (intern (cl-subseq (symbol-name key) 1))
|
||
unless (member symbol '(parent))
|
||
append (list symbol val)))
|
||
(title (--> (org-ql-view--add-faces element)
|
||
(org-element-property :raw-value it)
|
||
(org-link-display-format it)))
|
||
(todo-keyword (-some--> (org-element-property :todo-keyword element)
|
||
(org-ql-view--add-todo-face it)))
|
||
(tag-list (if org-use-tag-inheritance
|
||
(if-let ((marker (or (org-element-property :org-hd-marker element)
|
||
(org-element-property :org-marker element))))
|
||
(with-current-buffer (marker-buffer marker)
|
||
(org-with-wide-buffer
|
||
(goto-char marker)
|
||
(cl-loop for type in (org-ql--tags-at marker)
|
||
unless (or (eq 'org-ql-nil type)
|
||
(not type))
|
||
append type)))
|
||
(display-warning 'org-ql (format "No marker found for item: %s" title))
|
||
(org-element-property :tags element))
|
||
(org-element-property :tags element)))
|
||
(tag-string (when tag-list
|
||
(--> tag-list
|
||
(s-join ":" it)
|
||
(s-wrap it ":")
|
||
(org-add-props it nil 'face 'org-tag))))
|
||
;; (category (org-element-property :category element))
|
||
(priority-string (-some->> (org-element-property :priority element)
|
||
(char-to-string)
|
||
(format "[#%s]")
|
||
(org-ql-view--add-priority-face)))
|
||
(clock-string (let ((effort (org-element-property :EFFORT element))
|
||
(clocked (org-element-property my/org-clock-total-prop element)))
|
||
(cond
|
||
((and clocked effort) (format "[%s/%s]" clocked effort))
|
||
((and clocked (not effort) (format "[%s]" clocked)))
|
||
((and (not clocked) effort) (format "[EST: %s]" effort)))))
|
||
(habit-property (org-with-point-at (or (org-element-property :org-hd-marker element)
|
||
(org-element-property :org-marker element))
|
||
(when (org-is-habit-p)
|
||
(org-habit-parse-todo))))
|
||
(due-string (pcase (org-element-property :relative-due-date element)
|
||
('nil "")
|
||
(string (format " %s " (org-add-props string nil 'face 'org-ql-view-due-date)))))
|
||
(string (s-join " " (-non-nil (list todo-keyword priority-string title due-string clock-string tag-string)))))
|
||
(remove-list-of-text-properties 0 (length string) '(line-prefix) string)
|
||
(--> string
|
||
(concat " " it)
|
||
(org-add-props it properties
|
||
'org-agenda-type 'search
|
||
'todo-state todo-keyword
|
||
'tags tag-list
|
||
'org-habit-p habit-property)))))
|
||
|
||
(with-eval-after-load 'org-ql
|
||
(advice-add #'org-ql-view--format-element :override #'my/org-ql-view--format-element-override))
|
||
#+end_src
|
||
|
||
**** Tracking habits
|
||
Let's see how this goes.
|
||
|
||
References:
|
||
- https://orgmode.org/manual/Tracking-your-habits.html
|
||
|
||
[[https://github.com/ml729/org-habit-stats][org-habit-stats]] is a pretty nice package. Using my fork until my PR is merged.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-habit-stats
|
||
:straight (:host github :repo "ml729/org-habit-stats")
|
||
:after (org)
|
||
:config
|
||
(general-define-key
|
||
:keymaps '(org-habit-stats-mode-map)
|
||
:states '(normal emacs)
|
||
"q" #'org-habit-stats-exit
|
||
"<" #'org-habit-stats-calendar-scroll-left
|
||
">" #'org-habit-stats-calendar-scroll-right
|
||
"[" #'org-habit-stats-scroll-graph-left
|
||
"]" #'org-habit-stats-scroll-graph-right
|
||
"{" #'org-habit-stats-scroll-graph-left-big
|
||
"}" #'org-habit-stats-scroll-graph-right-big
|
||
"." #'org-habit-stats-view-next-habit
|
||
"," #'org-habit-stats-view-previous-habit)
|
||
(add-hook 'org-after-todo-state-change-hook 'org-habit-stats-update-properties))
|
||
#+end_src
|
||
**** Custom agendas
|
||
Some custom agendas to fit my workflow.
|
||
|
||
See [[https://emacs.stackexchange.com/questions/18179/org-agenda-command-with-org-agenda-filter-by-tag-not-working][this answer]] at Emacs StackExchange for filtering the =agenda= block by tag:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-match-at-point-p (match)
|
||
"Return non-nil if headline at point matches MATCH.
|
||
Here MATCH is a match string of the same format used by
|
||
`org-tags-view'."
|
||
(funcall (cdr (org-make-tags-matcher match))
|
||
(org-get-todo-state)
|
||
(org-get-tags-at)
|
||
(org-reduced-level (org-current-level))))
|
||
|
||
(defun my/org-agenda-skip-without-match (match)
|
||
"Skip current headline unless it matches MATCH.
|
||
|
||
Return nil if headline containing point matches MATCH (which
|
||
should be a match string of the same format used by
|
||
`org-tags-view'). If headline does not match, return the
|
||
position of the next headline in current buffer.
|
||
|
||
Intended for use with `org-agenda-skip-function', where this will
|
||
skip exactly those headlines that do not match."
|
||
(save-excursion
|
||
(unless (org-at-heading-p) (org-back-to-heading))
|
||
(let ((next-headline (save-excursion
|
||
(or (outline-next-heading) (point-max)))))
|
||
(if (my/org-match-at-point-p match) nil next-headline))))
|
||
#+end_src
|
||
|
||
And the agendas themselves:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-scheduled-get-time ()
|
||
(let ((scheduled (org-get-scheduled-time (point))))
|
||
(if scheduled
|
||
(format-time-string "%Y-%m-%d" scheduled)
|
||
"")))
|
||
|
||
(setq org-agenda-hide-tags-regexp (rx (or "org" "refile" "proj" "habit")))
|
||
|
||
(setq org-agenda-custom-commands
|
||
`(("p" "My outline"
|
||
((agenda "" ((org-agenda-skip-function '(my/org-agenda-skip-without-match "-habit"))))
|
||
(tags-todo "inbox"
|
||
((org-agenda-overriding-header "Inbox")
|
||
(org-agenda-prefix-format " %i %-12:c")
|
||
(org-agenda-hide-tags-regexp ".")))
|
||
(tags-todo "+waitlist+SCHEDULED<=\"<+14d>\""
|
||
((org-agenda-overriding-header "Waitlist")
|
||
(org-agenda-hide-tags-regexp "waitlist")
|
||
(org-agenda-prefix-format " %i %-12:c %-12(my/org-scheduled-get-time)")))
|
||
(tags-todo "habit+SCHEDULED<=\"<+0d>\""
|
||
((org-agenda-overriding-header "Habits")
|
||
(org-agenda-prefix-format " %i %-12:c")
|
||
(org-agenda-hide-tags-regexp ".")))))))
|
||
#+end_src
|
||
**** Alerts
|
||
- Me at 10:00: /Open Org Agenga/ oh, there's a meeting at 15:00
|
||
- Me at 14:00: /Open Org Agenda/ oh, there's a meeting at 15:00
|
||
- Me at 14:45: Gotta remember to join in 15 minutes
|
||
- Me at 14:55: Gotta remember to join in 5 minutes
|
||
- Me at 15:05: Sh*t
|
||
|
||
Okay, I will set up +org-alert+ some custom alert system.
|
||
|
||
There's also [[https://gitlab.com/grinn.amy/org-yaap][org-yaap]] by Amy Grinn, but I opt for my system for now.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-yaap
|
||
:straight (org-yaap :type git :host gitlab :repo "SqrtMinusOne/org-yaap")
|
||
:after (org)
|
||
:if (not my/nested-emacs)
|
||
:disabled t
|
||
:config
|
||
(org-yaap-mode 1)
|
||
(setq org-yaap-alert-before '(10 1))
|
||
(setq org-yaap-alert-title "PROXIMITY ALERT")
|
||
(setq org-yaap-todo-keywords-only '("FUTURE")))
|
||
#+end_src
|
||
|
||
I want to have multiple warnings, let it be 10 minutes in advance and 1 minute in advance for now.
|
||
#+begin_src emacs-lisp
|
||
(setq my/org-alert-notify-times '(600 60))
|
||
#+end_src
|
||
|
||
And IDK if that makes much sense, but I'll try to avoid re-creating timers. So, here are functions to schedule showing some label at some time and to check whether the label is scheduled:
|
||
#+begin_src emacs-lisp
|
||
(setq my/org-alert--alerts (make-hash-table :test #'equal))
|
||
|
||
(defun my/org-alert--is-scheduled (label time)
|
||
"Check if LABEL is scheduled to be shown an TIME."
|
||
(gethash (cons label time)
|
||
my/org-alert--alerts nil))
|
||
|
||
(defun my/org-alert--schedule (label time)
|
||
"Schedule LABEL to be shown at TIME, unless it's already scheduled."
|
||
(unless (my/org-alert--is-scheduled label time)
|
||
(puthash (cons label time)
|
||
(run-at-time time
|
||
nil
|
||
(lambda ()
|
||
(alert label
|
||
:title "PROXIMITY ALERT")))
|
||
my/org-alert--alerts)))
|
||
#+end_src
|
||
|
||
And unschedule items that need to be unscheduled:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-alert-cleanup (&optional keys)
|
||
"Unschedule items that do not appear in KEYS.
|
||
|
||
KEYS is a list of cons cells like (<label> . <time>)."
|
||
(let ((existing-hash (make-hash-table :test #'equal)))
|
||
(cl-loop for key in keys
|
||
do (puthash key t existing-hash))
|
||
(cl-loop for key being the hash-keys of my/org-alert--alerts
|
||
unless (gethash key existing-hash)
|
||
do (progn
|
||
(cancel-timer (gethash key my/org-alert--alerts))
|
||
(remhash key my/org-alert--alerts)))))
|
||
#+end_src
|
||
|
||
And a function to extract the required items with =org-ql-query= and schedule them:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-alert--update-today-alerts ()
|
||
(when-let* ((files (org-agenda-files))
|
||
(items
|
||
(org-ql-query
|
||
:select 'element
|
||
:from files
|
||
:where `(and
|
||
(todo "FUTURE")
|
||
(ts-active :from ,(format-time-string "%Y-%m-%d %H:%M")
|
||
:to ,(format-time-string
|
||
"%Y-%m-%d"
|
||
(time-add
|
||
(current-time)
|
||
(* 60 60 24)))
|
||
:with-time t))
|
||
:order-by 'date)))
|
||
(let (scheduled-keys)
|
||
(cl-loop
|
||
for item in items
|
||
for scheduled = (org-timestamp-to-time (org-element-property :scheduled item))
|
||
do (cl-loop
|
||
for before-time in my/org-alert-notify-times
|
||
for label = (format "%s at %s [%s min. remaining]"
|
||
(org-element-property :raw-value item)
|
||
(format-time-string "%H:%M" scheduled)
|
||
(number-to-string (/ before-time 60)))
|
||
for time = (time-convert
|
||
(+ (time-convert scheduled 'integer) (- before-time)))
|
||
do (progn
|
||
(my/org-alert--schedule label time)
|
||
(push (cons label time) scheduled-keys))))
|
||
(my/org-alert-cleanup scheduled-keys))))
|
||
#+end_src
|
||
|
||
Let's wrap it into a minor mode:
|
||
#+begin_src emacs-lisp
|
||
(setq my/org-alert--timer nil)
|
||
|
||
(define-minor-mode my/org-alert-mode ()
|
||
:global t
|
||
:after-hook
|
||
(if my/org-alert-mode
|
||
(progn
|
||
(my/org-alert--update-today-alerts)
|
||
(when (timerp my/org-alert--timer)
|
||
(cancel-timer my/org-alert--timer))
|
||
(setq my/org-alert--timer
|
||
(run-at-time 600 t #'my/org-alert--update-today-alerts)))
|
||
(when (timerp my/org-alert--timer)
|
||
(cancel-timer my/org-alert--timer))
|
||
(my/org-alert-cleanup)))
|
||
#+end_src
|
||
|
||
I don't have any idea why, but evaluating =(my/org-alert-mode)= just after =org= breaks font-lock after I try to open =inbox.org=. =emacs-startup-hook=, however, works fine.
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org
|
||
(if my/emacs-started
|
||
(my/org-alert-mode)
|
||
(add-hook 'emacs-startup-hook #'my/org-alert-mode)))
|
||
#+end_src
|
||
|
||
**** Seqeuential headers
|
||
I like to add numbers to repeating events, like meetings. E.g.
|
||
|
||
#+begin_example
|
||
,* Job meeting 62
|
||
SCHEDULED: <2022-11-13 16:00>
|
||
,* Job meeting 63
|
||
SCHEDULED: <2022-11-14 16:00>
|
||
...
|
||
#+end_example
|
||
|
||
***** Copying records
|
||
Naturally, I want a way to copy such records. Org Mode already has a function called =org-clone-subtree-with-time-shift=, that does everything I want except for updating the numbers.
|
||
|
||
Unfortunately, I see no way to advise the original function, so here's my version that makes use of =evil-numbers=:
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-clone-subtree-with-time-shift (n &optional shift)
|
||
(interactive "nNumber of clones to produce: ")
|
||
(unless (wholenump n) (user-error "Invalid number of replications %s" n))
|
||
(when (org-before-first-heading-p) (user-error "No subtree to clone"))
|
||
(let* ((beg (save-excursion (org-back-to-heading t) (point)))
|
||
(end-of-tree (save-excursion (org-end-of-subtree t t) (point)))
|
||
(shift
|
||
(or shift
|
||
(if (and (not (equal current-prefix-arg '(4)))
|
||
(save-excursion
|
||
(goto-char beg)
|
||
(re-search-forward org-ts-regexp-both end-of-tree t)))
|
||
(read-from-minibuffer
|
||
"Date shift per clone (e.g. +1w, empty to copy unchanged): ")
|
||
""))) ;No time shift
|
||
(doshift
|
||
(and (org-string-nw-p shift)
|
||
(or (string-match "\\`[ \t]*\\([+-]?[0-9]+\\)\\([hdwmy]\\)[ \t]*\\'"
|
||
shift)
|
||
(user-error "Invalid shift specification %s" shift)))))
|
||
(goto-char end-of-tree)
|
||
(unless (bolp) (insert "\n"))
|
||
(let* ((end (point))
|
||
(template (buffer-substring beg end))
|
||
(shift-n (and doshift (string-to-number (match-string 1 shift))))
|
||
(shift-what (pcase (and doshift (match-string 2 shift))
|
||
(`nil nil)
|
||
("h" 'hour)
|
||
("d" 'day)
|
||
("w" (setq shift-n (* 7 shift-n)) 'day)
|
||
("m" 'month)
|
||
("y" 'year)
|
||
(_ (error "Unsupported time unit"))))
|
||
(nmin 1)
|
||
(nmax n)
|
||
(n-no-remove -1)
|
||
(org-id-overriding-file-name (buffer-file-name (buffer-base-buffer)))
|
||
(idprop (org-entry-get beg "ID")))
|
||
(when (and doshift
|
||
(string-match-p "<[^<>\n]+ [.+]?\\+[0-9]+[hdwmy][^<>\n]*>"
|
||
template))
|
||
(delete-region beg end)
|
||
(setq end beg)
|
||
(setq nmin 0)
|
||
(setq nmax (1+ nmax))
|
||
(setq n-no-remove nmax))
|
||
(goto-char end)
|
||
(cl-loop for n from nmin to nmax do
|
||
(insert
|
||
;; Prepare clone.
|
||
(with-temp-buffer
|
||
(insert template)
|
||
(org-mode)
|
||
(goto-char (point-min))
|
||
(org-show-subtree)
|
||
(and idprop (if org-clone-delete-id
|
||
(org-entry-delete nil "ID")
|
||
(org-id-get-create t)))
|
||
(unless (= n 0)
|
||
(while (re-search-forward org-clock-line-re nil t)
|
||
(delete-region (line-beginning-position)
|
||
(line-beginning-position 2)))
|
||
(goto-char (point-min))
|
||
(while (re-search-forward org-drawer-regexp nil t)
|
||
(org-remove-empty-drawer-at (point))))
|
||
(goto-char (point-min))
|
||
|
||
(when doshift
|
||
(while (re-search-forward org-ts-regexp-both nil t)
|
||
(org-timestamp-change (* n shift-n) shift-what))
|
||
(save-excursion
|
||
(goto-char (point-min))
|
||
(evil-numbers/inc-at-pt n (point-min)))
|
||
(unless (= n n-no-remove)
|
||
(goto-char (point-min))
|
||
(while (re-search-forward org-ts-regexp nil t)
|
||
(save-excursion
|
||
(goto-char (match-beginning 0))
|
||
(when (looking-at "<[^<>\n]+\\( +[.+]?\\+[0-9]+[hdwmy]\\)")
|
||
(delete-region (match-beginning 1) (match-end 1)))))))
|
||
(buffer-string)))))
|
||
(goto-char beg)))
|
||
#+end_src
|
||
|
||
My addition to that is the form with =evil-numbers/inc-at-pt=.
|
||
***** Keeping consistency among sequential records
|
||
I also like to keep such headers consistent. Here are a few tools to help with that.
|
||
|
||
First, I need to find and group and such headers. =org-ql= can help with that:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org--headings-in-outline ()
|
||
(org-ql-query
|
||
:select (lambda () (propertize
|
||
(substring-no-properties (org-get-heading t t t))
|
||
'marker (copy-marker (point))))
|
||
:from (append
|
||
(list (buffer-file-name))
|
||
(let ((archive
|
||
(concat (file-name-directory (buffer-file-name))
|
||
"archive/"
|
||
(file-name-nondirectory (buffer-file-name)))))
|
||
(when (file-exists-p archive)
|
||
(list archive))))
|
||
:where `(and (outline-path ,@(org-get-outline-path))
|
||
(level ,(org-current-level)))))
|
||
|
||
(defun my/org--heading-strip (heading)
|
||
(thread-last
|
||
heading
|
||
(substring-no-properties)
|
||
(replace-regexp-in-string (rx (| "(" "[") (+ nonl) (| "]" ")")) "")
|
||
(replace-regexp-in-string (rx " " (+ (or digit "."))) " ")
|
||
(replace-regexp-in-string (rx (+ " ")) " ")
|
||
(string-trim)))
|
||
|
||
(defun my/org--headings-group-seq (headings)
|
||
(thread-last
|
||
headings
|
||
(seq-group-by #'my/org--heading-strip)
|
||
(seq-sort-by #'car #'string-lessp)
|
||
(mapcar (lambda (group)
|
||
(cons (car group)
|
||
(seq-sort-by
|
||
(lambda (heading)
|
||
(save-match-data
|
||
(or
|
||
(and (string-match (rx (group (+ digit)))
|
||
heading)
|
||
(string-to-number (match-string 1 heading)))
|
||
-1)))
|
||
#'<
|
||
(cdr group)))))))
|
||
#+end_src
|
||
|
||
Then, display all such headings a buffer:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-headings-seq ()
|
||
(interactive)
|
||
(let* ((headings (my/org--headings-in-outline))
|
||
(headings-seq (my/org--headings-group-seq headings))
|
||
(buffer (generate-new-buffer "*Sequential Headings in Outline*")))
|
||
(with-current-buffer buffer
|
||
(outline-mode)
|
||
(setq-local widget-push-button-prefix "")
|
||
(setq-local widget-push-button-suffix "")
|
||
(dolist (group headings-seq)
|
||
(insert (format "* %s\n" (car group)))
|
||
(dolist (heading (cdr group))
|
||
(widget-create 'push-button
|
||
:marker (get-text-property 0 'marker heading)
|
||
:notify (lambda (widget &rest ignore)
|
||
(let ((marker (widget-get widget :marker)))
|
||
(pop-to-buffer (marker-buffer marker))
|
||
(goto-char marker)))
|
||
(concat "** " (substring-no-properties heading)))
|
||
(insert "\n")))
|
||
(widget-setup)
|
||
(setq buffer-read-only t)
|
||
(goto-char (point-min)))
|
||
(pop-to-buffer buffer)))
|
||
#+end_src
|
||
|
||
And insert a similar heading:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-heading-seq-insert ()
|
||
(interactive)
|
||
(let* ((headings (my/org--headings-in-outline))
|
||
(headings-seq (my/org--headings-group-seq headings))
|
||
(heading (completing-read "Headings: " headings-seq))
|
||
(last-number
|
||
(thread-last headings-seq
|
||
(assoc heading)
|
||
(cdr)
|
||
(mapcar (lambda (x)
|
||
(save-match-data
|
||
(or
|
||
(when (string-match (rx (group (+ digit)))
|
||
x)
|
||
(string-to-number (match-string 1 x)))
|
||
1))))
|
||
(seq-max)
|
||
(1+))))
|
||
(org-insert-heading '(4))
|
||
(insert (format "FUTURE %s %s" heading last-number))))
|
||
#+end_src
|
||
|
||
**** Archiving records
|
||
- *CREDIT*: thanks [[https://emacs.ch/@grinn][Amy]] for pointing me to the right functionality of =org-refile=.
|
||
|
||
I have several org files for long-running projects. They are getting hard to manage because there are lots of different tasks, events, etc.
|
||
|
||
So I want to create "archive versions" of these files which would have the same structure but store items, say, with a timestamp older than 2 months.
|
||
|
||
Archive versions are to be stored in the =archive= subdirectory relative to the current file, e.g., =foo.org= -> =archive/foo.org=:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-archive--get-file ()
|
||
"Get an archive version of the file."
|
||
(let ((archive-file
|
||
(concat
|
||
(file-name-directory (buffer-file-name))
|
||
"archive/" (file-name-nondirectory (buffer-file-name)))))
|
||
(unless (file-exists-p archive-file)
|
||
(make-empty-file archive-file))
|
||
archive-file))
|
||
#+end_src
|
||
|
||
In order to maintain structure, we need to make sure that the archive version has all the necessary headers.
|
||
|
||
=org-refile= (or, to be precise, =org-refile-get-location=) by itself can create the last level of headers with =org-refile-allow-creating-parent-nodes=. So I can just invoke the same logic for all missing headers:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-refile--assert-path-exists (refile-path)
|
||
(cl-assert (equal org-refile-use-outline-path 'file))
|
||
(let* ((parts (string-split refile-path "/"))
|
||
(tbl (mapcar
|
||
(lambda (x)
|
||
(cons (concat (car x) "/") (cdr x)))
|
||
org-refile-target-table)))
|
||
(cl-loop for i from 1
|
||
for part in (cdr parts)
|
||
for target = (org-refile--get-location
|
||
(string-join (seq-take parts (1+ i)) "/")
|
||
tbl)
|
||
unless target
|
||
do (let ((parent-target
|
||
(org-refile--get-location
|
||
(string-join (seq-take parts i) "/")
|
||
tbl)))
|
||
(push (org-refile-new-child parent-target part) tbl)))))
|
||
#+end_src
|
||
|
||
Now we can make a function to archive one record interactively.
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-archive-refile ()
|
||
(interactive)
|
||
(let* ((org-refile-targets `((,(my/org-archive--get-file) . (:maxlevel . 6))))
|
||
(org-refile-target-table (org-refile-get-targets))
|
||
(org-refile-history nil)
|
||
(org-refile-use-outline-path 'file)
|
||
(org-refile-allow-creating-parent-nodes t)
|
||
(org-outline-path-complete-in-steps nil)
|
||
(refile-path (string-join
|
||
(append
|
||
(list (file-name-nondirectory
|
||
(buffer-file-name)))
|
||
(org-get-outline-path nil t))
|
||
"/")))
|
||
;; The path is already known
|
||
(cl-letf (((symbol-function 'completing-read)
|
||
(lambda (&rest _) refile-path)))
|
||
(my/org-refile--assert-path-exists refile-path)
|
||
(org-refile))))
|
||
#+end_src
|
||
|
||
And a function to archive all records older than the given number of days. I'll use =org-ql= to find these records.
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-archive-refile-all (days)
|
||
(interactive (list (read-number "Days: " 60)))
|
||
(let ((records (org-ql-query
|
||
:select #'element-with-markers
|
||
:from (current-buffer)
|
||
:where `(and (ts :to ,(- days)) (done)))))
|
||
(when (y-or-n-p (format "Archive %d records? " (length records)))
|
||
(dolist (record records)
|
||
(let ((marker (org-element-property :org-marker record)))
|
||
(org-with-point-at marker
|
||
(my/org-archive-refile)))))))
|
||
#+end_src
|
||
|
||
**** Keybindings
|
||
Global keybindings:
|
||
|
||
#+begin_src emacs-lisp
|
||
(my-leader-def
|
||
:infix "o"
|
||
"" '(:which-key "org-mode")
|
||
"c" 'org-capture
|
||
"a" 'org-agenda
|
||
"o" #'my/org-file-open
|
||
"v" #'org-ql-view
|
||
"q" #'org-ql-search)
|
||
#+end_src
|
||
|
||
Local keybindings
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org
|
||
(my-leader-def
|
||
:infix "SPC"
|
||
:keymaps '(org-mode-map)
|
||
"i" #'org-clock-in
|
||
"o" #'org-clock-out
|
||
"O" #'org-clock-cancel
|
||
"c" #'org-clock-goto
|
||
"p" #'org-set-property
|
||
"e" #'org-set-effort
|
||
"r" #'org-priority
|
||
"m" #'my/org-meeting-link))
|
||
#+end_src
|
||
|
||
*** Org Journal
|
||
[[https://github.com/bastibe/org-journal][org-journal]] is a package for maintaining a journal in org mode.
|
||
|
||
This part turned out to be great. I even consulted the journal a few times to check if something actually happened, which makes me uneasy now that I think about it...
|
||
|
||
One issue I found is that it's kinda hard to find anything in the journal, and I'm not eager to open the journal for a random date anyway. So I've made a package called [[https://github.com/SqrtMinusOne/org-journal-tags][org-journal-tags]].
|
||
|
||
My initial desire was to be able to query the journal for my thoughts on a particular subject or theme, for progress on some project, or for records related to some person... Which is kinda useful, although not quite as much as I expected it to be. Relatively fast querying of the journal is also nice.
|
||
|
||
The section I named "on this day" turned out to be particularly interesting, as it kinda allowed me to connect with past versions of myself.
|
||
|
||
And it was interesting to find the reinforcement effect of checked dates on the calendar.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-journal
|
||
:straight t
|
||
:if (not my/remote-server)
|
||
:init
|
||
(my-leader-def
|
||
:infix "oj"
|
||
"" '(:which-key "org-journal")
|
||
"j" 'org-journal-new-entry
|
||
"o" 'org-journal-open-current-journal-file
|
||
"s" 'org-journal-tags-status)
|
||
:after org
|
||
:config
|
||
(setq org-journal-dir (concat org-directory "/journal"))
|
||
(setq org-journal-file-type 'weekly)
|
||
(setq org-journal-file-format "%Y-%m-%d.org")
|
||
(setq org-journal-date-format "%A, %Y-%m-%d")
|
||
(setq org-journal-enable-encryption t)
|
||
(setq org-journal-time-format-post-midnight "PM: %R "))
|
||
#+end_src
|
||
|
||
So, [[https://github.com/SqrtMinusOne/org-journal-tags][org-journal-tags]] is my package that implements a tagging system for org-journal.
|
||
#+begin_src emacs-lisp
|
||
(use-package org-journal-tags
|
||
:straight (:host github :repo "SqrtMinusOne/org-journal-tags")
|
||
:after (org-journal)
|
||
:if (not my/remote-server)
|
||
:config
|
||
(org-journal-tags-autosync-mode)
|
||
(general-define-key
|
||
:keymaps 'org-journal-mode-map
|
||
"C-c t" #'org-journal-tags-insert-tag))
|
||
#+end_src
|
||
|
||
Also, I want to add some extra information to the journal. Here's a functionality to get the current weather from wttr.in:
|
||
#+begin_src emacs-lisp
|
||
(use-package request
|
||
:straight t
|
||
:defer t)
|
||
|
||
(defvar my/weather-last-time 0)
|
||
(defvar my/weather-value nil)
|
||
|
||
(defun my/weather-get ()
|
||
(when (> (- (time-convert nil 'integer) my/weather-last-time)
|
||
(* 60 5))
|
||
(request (format "https://wttr.in/%s" my/location)
|
||
:params '(("format" . "%l:%20%C%20%t%20%w%20%p"))
|
||
:sync t
|
||
:parser (lambda () (url-unhex-string (buffer-string)))
|
||
:timeout 10
|
||
:success (cl-function
|
||
(lambda (&key data &allow-other-keys)
|
||
(setq my/weather-value data)
|
||
(setq my/weather-last-time (time-convert nil 'integer))))
|
||
:error
|
||
(cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
|
||
(message "Got error: %S" error-thrown)))))
|
||
my/weather-value)
|
||
#+end_src
|
||
|
||
Let's also try to log the current mood:
|
||
#+begin_src emacs-lisp
|
||
(defun my/get-mood ()
|
||
(let* ((crm-separator " ")
|
||
(crm-local-completion-map
|
||
(let ((map (make-sparse-keymap)))
|
||
(set-keymap-parent map crm-local-completion-map)
|
||
(define-key map " " 'self-insert-command)
|
||
map))
|
||
(vertico-sort-function nil))
|
||
(mapconcat
|
||
#'identity
|
||
(completing-read-multiple
|
||
"How do you feel: "
|
||
my/mood-list)
|
||
" ")))
|
||
#+end_src
|
||
|
||
And here's the function that creates a drawer with such information. At the moment, it's:
|
||
- Emacs version
|
||
- Hostname
|
||
- Location
|
||
- Weather
|
||
- Current EMMS track
|
||
- Current mood
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/set-journal-header ()
|
||
(org-set-property "Emacs" emacs-version)
|
||
(org-set-property "Hostname" (my/system-name))
|
||
(org-journal-tags-prop-apply-delta :add (list (format "host.%s" (my/system-name))))
|
||
(when (boundp 'my/location)
|
||
(org-set-property "Location" my/location)
|
||
(when-let ((weather (my/weather-get)))
|
||
(org-set-property "Weather" weather)))
|
||
(when (boundp 'my/loc-tag)
|
||
(org-journal-tags-prop-apply-delta :add (list my/loc-tag)))
|
||
(when (fboundp 'emms-playlist-current-selected-track)
|
||
(let ((track (emms-playlist-current-selected-track)))
|
||
(when track
|
||
(let ((album (cdr (assoc 'info-album track)))
|
||
(artist (or (cdr (assoc 'info-albumartist track))
|
||
(cdr (assoc 'info-album track))))
|
||
(title (cdr (assoc 'info-title track)))
|
||
(string ""))
|
||
(when artist
|
||
(setq string (concat string "[" artist "] ")))
|
||
(when album
|
||
(setq string (concat string album " - ")))
|
||
(when title
|
||
(setq string (concat string title)))
|
||
(when (> (length string) 0)
|
||
(org-set-property "EMMS_Track" string))))))
|
||
(when-let (mood (my/get-mood))
|
||
(org-set-property "Mood" mood)))
|
||
|
||
(add-hook 'org-journal-after-entry-create-hook
|
||
#'my/set-journal-header)
|
||
#+end_src
|
||
|
||
Also, a function to decrypt the current file:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-journal-decrypt ()
|
||
"Decrypt the current org journal file."
|
||
(interactive)
|
||
(org-journal-tags--ensure-decrypted))
|
||
#+end_src
|
||
|
||
*** Bibliography
|
||
I use [[https://www.zotero.org/][Zotero]] to manage my bibliograhy.
|
||
|
||
There is a Zotero extension called [[https://retorque.re/zotero-better-bibtex/][better bibtex]], which allows for having one bibtex file that is always syncronized with the library. That comes quite handy for Emacs integration.
|
||
|
||
Resources:
|
||
- [[https://blog.tecosaur.com/tmio/2021-07-31-citations.html][Introducing citations!]]
|
||
|
||
**** citar
|
||
[[https://github.com/emacs-citar/citar][citar]] is a package that works with citations.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package citar
|
||
:straight t
|
||
:init
|
||
(my-leader-def "fB" #'citar-open)
|
||
:commands (citar-open citar-insert-citation)
|
||
:config
|
||
(setq
|
||
org-cite-global-bibliography '("~/30-39 Life/32 org-mode/library.bib")
|
||
org-cite-insert-processor 'citar
|
||
org-cite-follow-processor 'citar
|
||
org-cite-activate-processor 'citar
|
||
citar-bibliography org-cite-global-bibliography)
|
||
(setq org-cite-export-processors
|
||
'((latex bibtex "numeric")))
|
||
(setq citar-library-paths
|
||
'("~/30-39 Life/33 Library/33.01 Documents/"))
|
||
(add-hook 'latex-mode #'citar-capf-setup)
|
||
(add-hook 'org-mode #'citar-capf-setup))
|
||
|
||
(use-package citar-embark
|
||
:after (citar embark)
|
||
:straight t
|
||
:config
|
||
(citar-embark-mode))
|
||
#+end_src
|
||
|
||
**** org-ref
|
||
[[https://github.com/jkitchin/org-ref][org-ref]] is a package by John Kitchin that provides support for citations and cross-references in Org Mode.
|
||
|
||
I've switched to citar for citations because =org-ref= only works with Ivy and Helm. Fortunately, =org-ref= is designed to co-exist with =citar= and =org-cite=.
|
||
|
||
Also, at some point the package loaded Helm on start, so I exclude these files from the recipe.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-ref
|
||
:straight (:files (:defaults "citeproc" (:exclude "*helm*")))
|
||
:if (not my/remote-server)
|
||
:commands (org-ref-insert-link-hydra/body
|
||
org-ref-bibtex-hydra/body)
|
||
:init
|
||
(setq bibtex-dialect 'biblatex)
|
||
(add-hook 'bibtex-mode 'smartparens-mode)
|
||
:after (org)
|
||
:config
|
||
(general-define-key
|
||
:keymaps 'org-mode-map
|
||
"C-c l" #'org-ref-insert-link-hydra/body)
|
||
(general-define-key
|
||
:keymaps 'bibtex-mode-map
|
||
"M-RET" 'org-ref-bibtex-hydra/body)
|
||
(setq org-ref-insert-cite-function
|
||
(lambda ()
|
||
(call-interactively #'citar-insert-citation))))
|
||
#+end_src
|
||
|
||
*** Org Roam
|
||
[[https://github.com/org-roam/org-roam][org-roam]] is a plain-text knowledge database.
|
||
|
||
Things I tried with Org Roam:
|
||
- Managing projects. Ended up preferring plain Org.
|
||
- Writing a journal with =org-roam-dailies=.
|
||
Didn't work out as I expected, so I've made =org-journal-tags= after I understood better what I want.
|
||
|
||
Regardless, it turned out to be great for managing Zettelkasten, which is the original purpose of the package anyway. I didn't expect to ever get into something like this, but I guess I was wrong.
|
||
|
||
Some resources that helped me along the way (and still help):
|
||
- Sönke Ahrens' book "How to take smart notes"
|
||
- https://zettelkasten.de/ - a lot of useful stuff here, especially in the "Getting Started" section.
|
||
- [[https://www.youtube.com/watch?v=-TpWahIzueg][System Crafters Live! - Can You Apply Zettelkasten in Emacs?]]
|
||
|
||
**** Basic package configuration
|
||
| Guix dependency | Disabled |
|
||
|-----------------------+----------|
|
||
| emacs-emacsql-sqlite3 | t |
|
||
| graphviz | |
|
||
|
||
About installing the package on Guix (*CREDIT*: thanks @Ashraz on the SystemCrafters discord)
|
||
|
||
#+begin_quote
|
||
So, for all those interested: unfortunately, org-roam (or rather emacsql-sqlite) cannot compile the sqlite.c and emacsql.c due to missing headers (linux/falloc.h) on Guix. You would have to properly set all the include paths on Guix, and also adjust the PATH to have gcc actually find as later on in the compilation process.
|
||
|
||
Instead, you should remove all Org-Roam related packages from your Emacs installation (via M-x package-delete org-roam RET and M-x package-autoremove RET y RET) and then use the Guix package called emacs-org-roam.
|
||
#+end_quote
|
||
|
||
References:
|
||
- [[https://github.com/org-roam/org-roam/wiki/Hitchhiker%27s-Rough-Guide-to-Org-roam-V2][Hitchhiker's Rough Guide to Org roam V2]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package emacsql-sqlite
|
||
:defer t
|
||
:if (not my/remote-server)
|
||
:straight (:type built-in))
|
||
|
||
(use-package org-roam
|
||
:straight (:host github :repo "org-roam/org-roam"
|
||
:files (:defaults "extensions/*.el"))
|
||
:if (and
|
||
(not my/remote-server)
|
||
(file-directory-p org-roam-directory))
|
||
:after org
|
||
:init
|
||
(setq org-roam-file-extensions '("org"))
|
||
(setq org-roam-v2-ack t)
|
||
(setq org-roam-node-display-template (concat "${title:*} " (propertize "${tags:10}" 'face 'org-tag)))
|
||
:config
|
||
(org-roam-setup)
|
||
(require 'org-roam-protocol))
|
||
#+end_src
|
||
**** Capture templates
|
||
Capture templates for =org-roam-capture=. As for now, nothing too complicated here.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq org-roam-capture-templates
|
||
`(("d" "default" plain "%?"
|
||
:target (file+head "%<%Y%m%d%H%M%S>-${slug}.org" "#+title: ${title}\n")
|
||
:unnarrowed t)
|
||
("f" "fleeting" plain "%?"
|
||
:target (file+head "%<%Y%m%d%H%M%S>-${slug}.org" "#+title: ${title}\n#+filetags: :fleeting:\n")
|
||
:unnarrowed t)
|
||
("e" "encrypted" plain "%?"
|
||
:target (file+head "%<%Y%m%d%H%M%S>-${slug}.org.gpg" "#+title: ${title}\n")
|
||
:unnarrowed t)))
|
||
#+end_src
|
||
**** org-roam-ql
|
||
[[https://github.com/ahmed-shariff/org-roam-ql][org-roam-ql]] is a package to query =org-roam= files like =org-ql=.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-roam-ql
|
||
:straight t
|
||
:after (org-roam)
|
||
:config
|
||
(general-define-key
|
||
:states '(normal visual)
|
||
:keymaps '(org-roam-ql-mode-map)
|
||
"s" #'org-roam-ql-buffer-dispatch))
|
||
#+end_src
|
||
|
||
**** Finding nodes
|
||
Find and insert permanent nodes:
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-roam-node-find-permanent (&optional other-window)
|
||
(interactive current-prefix-arg)
|
||
(org-roam-node-find
|
||
other-window
|
||
nil
|
||
(lambda (node)
|
||
(not
|
||
(seq-contains-p
|
||
"fleeting"
|
||
(org-roam-node-tags node))))))
|
||
|
||
(defun my/org-roam-node-insert-permanent ()
|
||
(interactive)
|
||
(org-roam-node-insert
|
||
(lambda (node)
|
||
(not
|
||
(seq-contains-p
|
||
(org-roam-node-tags node)
|
||
"fleeting")))))
|
||
#+end_src
|
||
|
||
List unprocessed fleeting notes:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-roam-ql-fleeting ()
|
||
(interactive)
|
||
(org-roam-ql-search
|
||
'(tags "fleeting")
|
||
"Fleeting notes"))
|
||
#+end_src
|
||
|
||
**** Keybindings
|
||
A set of keybindings to quickly access things in Org Roam.
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org-roam
|
||
(my-leader-def
|
||
:infix "or"
|
||
"" '(:which-key "org-roam")
|
||
"i" #'my/org-roam-node-insert-permanent
|
||
"r" #'my/org-roam-node-find-permanent
|
||
"g" #'org-roam-graph
|
||
"c" #'org-roam-capture
|
||
"b" #'org-roam-buffer-toggle
|
||
"q" #'org-roam-ql-search
|
||
"f" #'my/org-roam-ql-fleeting)
|
||
(general-define-key
|
||
:keymaps 'org-roam-mode-map
|
||
:states '(normal)
|
||
"TAB" #'magit-section-toggle
|
||
"q" #'quit-window
|
||
"k" #'magit-section-backward
|
||
"j" #'magit-section-forward
|
||
"gr" #'revert-buffer
|
||
"RET" #'org-roam-buffer-visit-thing))
|
||
|
||
(with-eval-after-load 'org
|
||
(my-leader-def
|
||
:keymap 'org-mode-map
|
||
:infix "or"
|
||
"t" #'org-roam-tag-add
|
||
"T" #'org-roam-tag-remove
|
||
"s" #'org-roam-db-autosync-mode
|
||
"a" #'org-roam-alias-add)
|
||
(general-define-key
|
||
:keymap 'org-mode-map
|
||
"C-c i" #'my/org-roam-node-insert-permanent
|
||
"C-c I" #'org-roam-node-insert))
|
||
#+end_src
|
||
**** Backlinks count display
|
||
Occasionally I want to see how many backlinks a particular page has.
|
||
|
||
This idea came to my mind because I often write a note in the following form:
|
||
#+begin_example
|
||
According to <This Person>, <some opinion>
|
||
#+end_example
|
||
|
||
And I have a note called =#Personalities= that looks like that:
|
||
#+begin_example
|
||
Philosophers:
|
||
- <This Person>
|
||
- <That Person>
|
||
- <Another Person>
|
||
...
|
||
#+end_example
|
||
|
||
So I'm curious to see how many notes I have linked to each:
|
||
#+begin_example
|
||
Philosophers:
|
||
- <This Person> [30]
|
||
- <That Person> [40]
|
||
- <Another Person> [20]
|
||
...
|
||
#+end_example
|
||
|
||
The obvious way to implement that is via [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Overlays.html][overlays]]:
|
||
#+begin_src emacs-lisp
|
||
(defface my/org-roam-count-overlay-face
|
||
'((t :inherit tooltip))
|
||
"Face for Org Roam count overlay.")
|
||
|
||
(defun my/org-roam--count-overlay-make (pos count)
|
||
(let* ((overlay-value (concat
|
||
" "
|
||
(propertize
|
||
(format "%d" count)
|
||
'face 'my/org-roam-count-overlay-face)
|
||
" "))
|
||
(ov (make-overlay pos pos (current-buffer) nil t)))
|
||
(overlay-put ov 'roam-backlinks-count count)
|
||
(overlay-put ov 'priority 1)
|
||
(overlay-put ov 'after-string overlay-value)))
|
||
#+end_src
|
||
|
||
Also a function to remove them:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-roam--count-overlay-remove-all ()
|
||
(dolist (ov (overlays-in (point-min) (point-max)))
|
||
(when (overlay-get ov 'roam-backlinks-count)
|
||
(delete-overlay ov))))
|
||
#+end_src
|
||
|
||
Now we can iterate over all roam links in the buffer, count the number of backlinks via =org-roam-db-query= and invoke =my/org-roam--count-overlay-make= if that number is greater than zero:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-roam--count-overlay-make-all ()
|
||
(my/org-roam--count-overlay-remove-all)
|
||
(org-element-map (org-element-parse-buffer) 'link
|
||
(lambda (elem)
|
||
(when (string-equal (org-element-property :type elem) "id")
|
||
(let* ((id (org-element-property :path elem))
|
||
(count (caar
|
||
(org-roam-db-query
|
||
[:select (funcall count source)
|
||
:from links
|
||
:where (= dest $s1)
|
||
:and (= type "id")]
|
||
id))))
|
||
(when (< 0 count)
|
||
(my/org-roam--count-overlay-make
|
||
(org-element-property :end elem)
|
||
count)))))))
|
||
#+end_src
|
||
|
||
And a minor mode to toggle the display in a particular =org-roam= buffer.
|
||
#+begin_src emacs-lisp
|
||
(define-minor-mode my/org-roam-count-overlay-mode
|
||
"Display backlink count for org-roam links."
|
||
:after-hook
|
||
(if my/org-roam-count-overlay-mode
|
||
(progn
|
||
(my/org-roam--count-overlay-make-all)
|
||
(add-hook 'after-save-hook #'my/org-roam--count-overlay-make-all nil t))
|
||
(my/org-roam--count-overlay-remove-all)
|
||
(remove-hook 'after-save-hook #'my/org-roam--count-overlay-remove-all t)))
|
||
#+end_src
|
||
**** Extract all links from page
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-roam-extract-links ()
|
||
(interactive)
|
||
(let ((buffer (generate-new-buffer "*roam-links*"))
|
||
elems)
|
||
(org-element-map (org-element-parse-buffer) 'link
|
||
(lambda (elem)
|
||
(when (string-equal (org-element-property :type elem) "id")
|
||
(push elem elems))))
|
||
(with-current-buffer buffer
|
||
(cl-loop for elem in elems
|
||
for file-name =
|
||
(file-name-nondirectory
|
||
(caar
|
||
(org-roam-db-query
|
||
[:select [file]
|
||
:from nodes
|
||
:where (= id $s1)]
|
||
(org-element-property :path elem))))
|
||
do (insert file-name "\n")))
|
||
(switch-to-buffer buffer)))
|
||
#+end_src
|
||
**** Org Roam UI
|
||
A browser frontend to visualize the Roam database as a graph.
|
||
|
||
Actually, I don't find this quite as useful as structure nodes, because over time my graph grew somewhat convoluted. But it looks impressive.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-roam-ui
|
||
:straight (:host github :repo "org-roam/org-roam-ui" :branch "main" :files ("*.el" "out"))
|
||
:if (not my/remote-server)
|
||
:after org-roam
|
||
;; :hook (org-roam . org-roam-ui-mode)
|
||
:init
|
||
(my-leader-def "oru" #'org-roam-ui-mode))
|
||
#+end_src
|
||
**** Deft
|
||
[[https://github.com/jrblevin/deft][Deft]] is an Emacs package to quickly find notes. I use it as a full-text search engine for =org-roam=.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package deft
|
||
:straight t
|
||
:if (not my/remote-server)
|
||
:commands (deft)
|
||
:after (org)
|
||
:init
|
||
(my-leader-def "ord" #'deft)
|
||
:config
|
||
(setq deft-directory org-roam-directory)
|
||
(setq deft-recursive t)
|
||
(setq deft-use-filter-string-for-filename t)
|
||
(add-hook 'deft-mode-hook
|
||
(lambda () (display-line-numbers-mode -1)))
|
||
(general-define-key
|
||
:keymaps 'deft-mode-map
|
||
:states '(normal motion)
|
||
"q" #'quit-window
|
||
"r" #'deft-refresh
|
||
"s" #'deft-filter
|
||
"d" #'deft-filter-clear
|
||
"y" #'deft-filter-yank
|
||
"t" #'deft-toggle-incremental-search
|
||
"o" #'deft-toggle-sort-method))
|
||
#+end_src
|
||
|
||
The default deft view does not look that great because of various Roam metadata. To improve that, we can tweak =deft-strip-summary-regexp=:
|
||
#+begin_src emacs-lisp
|
||
(setq deft-strip-summary-regexp
|
||
(rx (or
|
||
(: ":PROPERTIES:" (* anything) ":END:")
|
||
(: "#+" (+ alnum) ":" (* nonl))
|
||
(regexp "[\n\t]"))))
|
||
#+end_src
|
||
|
||
And advise =deft-parse-summary= to filter out Org links:
|
||
#+begin_src emacs-lisp
|
||
(defun my/deft-parse-summary-around (fun contents title)
|
||
(funcall fun (org-link-display-format contents) title))
|
||
|
||
(with-eval-after-load 'deft
|
||
(advice-add #'deft-parse-summary :around #'my/deft-parse-summary-around))
|
||
#+end_src
|
||
|
||
Advise =deft-parse-title= to be able to extract title from the Org property:
|
||
#+begin_src emacs-lisp
|
||
(defun my/deft-parse-title (file contents)
|
||
(with-temp-buffer
|
||
(insert contents)
|
||
(goto-char (point-min))
|
||
(if (search-forward-regexp (rx (| "#+title:" "#+TITLE:")) nil t)
|
||
(string-trim (buffer-substring-no-properties (point) (line-end-position)))
|
||
file)))
|
||
|
||
(defun my/deft-parse-title-around (fun file contents)
|
||
(or (my/deft-parse-title file contents)
|
||
(funcall fun file contents)))
|
||
|
||
(with-eval-after-load 'deft
|
||
(advice-add #'deft-parse-title :around #'my/deft-parse-title-around))
|
||
#+end_src
|
||
**** Notes display
|
||
I decided to borrow a few UX things from [[https://obsidian.md/][Obsidian]], namely hiding syntax when cursor leaves the line.
|
||
|
||
[[https://github.com/awth13/org-appear?tab=readme-ov-file][org-appear]] and [[https://github.com/io12/org-fragtog][org-fragtog]] do pretty much that.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-roam-node-setup ()
|
||
(setq-local org-hide-emphasis-markers t)
|
||
(org-appear-mode 1)
|
||
(when (display-graphic-p)
|
||
(org-fragtog-mode 1)
|
||
(org-latex-preview '(16))))
|
||
|
||
(with-eval-after-load 'org
|
||
(add-hook 'org-roam-find-file-hook 'my/org-roam-node-setup))
|
||
#+end_src
|
||
|
||
*** Review workflow
|
||
Tiago Forte has several few interesting blog posts:
|
||
- [[https://fortelabs.com/blog/the-weekly-review-is-an-operating-system/][The Weekly Review is an Operating System]]
|
||
- [[https://fortelabs.com/blog/the-design-of-a-weekly-review/][The Design of a Weekly Review]]
|
||
|
||
This is probably my third time to implement a weekly review.
|
||
|
||
I want two general things from the workflow:
|
||
- to perform maintainance operations, such as clearing various inboxes;
|
||
- to reflect on what I've done over the past week.
|
||
|
||
For the second point I'll try to collect data from various sources and add the data to my review template.
|
||
|
||
**** Data from git
|
||
First, as I have [[file:Console.org::=autocommit=][autocommit]] set up in my org directory, here is a function to get an alist of changed files in a form =(status . path)=. The =rev= parameter can be a commit, tag, etc. but here I'm interested in the date form (e.g. =@{2021-08-30}=).
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq my/git-diff-status
|
||
'(("A" . added)
|
||
("C" . copied)
|
||
("D" . deleted)
|
||
("M" . modified)
|
||
("R" . renamed)
|
||
("R100" . moved)
|
||
("T" . type-changed)
|
||
("U" . unmerged)))
|
||
|
||
(defun my/get-files-status (rev)
|
||
(let ((files (shell-command-to-string (concat "git diff --name-status " rev))))
|
||
(mapcar
|
||
(lambda (file)
|
||
(let ((elems (split-string file "\t")))
|
||
(cons
|
||
(cdr (assoc (car elems) my/git-diff-status))
|
||
(car (last elems)))))
|
||
(split-string files "\n" t))))
|
||
#+end_src
|
||
|
||
I'll use it to get a list of added and changed files in the Org directory since the last review. The date should be in a format =YYYY-MM-DD=.
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-changed-files-since-date (date)
|
||
(let ((default-directory org-directory))
|
||
(my/get-files-status (format "@{%s}" date))))
|
||
#+end_src
|
||
|
||
**** Data from org-roam
|
||
I'll use data from git to get the list of what I've been working on. The directories include =org-roam= itself and =inbox-notes=, where my in-process notes live.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-review--org-roam-get-changes (date)
|
||
(let ((changes (my/org-changed-files-since-date date))
|
||
(nodes (org-roam-node-list))
|
||
(nodes-by-file (make-hash-table :test #'equal)))
|
||
(cl-loop for node in nodes
|
||
for file = (org-roam-node-file node)
|
||
do (puthash file node nodes-by-file))
|
||
(let* ((changed-nodes
|
||
(thread-last
|
||
changes
|
||
(mapcar (lambda (c)
|
||
(cons (car c)
|
||
(gethash
|
||
(concat org-directory "/" (cdr c))
|
||
nodes-by-file))))
|
||
(seq-filter #'cdr)))
|
||
(changed-inbox
|
||
(thread-last
|
||
changes
|
||
(seq-filter
|
||
(lambda (file) (string-match-p (rx bos "inbox-notes") (cdr file))))))
|
||
(changed-fleeting
|
||
(thread-last
|
||
changed-nodes
|
||
(seq-filter (lambda (c)
|
||
(seq-contains-p (org-roam-node-tags (cdr c))
|
||
"fleeting")))
|
||
(seq-sort-by (lambda (c) (concat (symbol-name (car c))
|
||
(org-roam-node-title (cdr c))))
|
||
#'string-lessp)))
|
||
(changed-permanent
|
||
(thread-last
|
||
changed-nodes
|
||
(seq-filter (lambda (c)
|
||
(not (seq-contains-p (org-roam-node-tags (cdr c))
|
||
"fleeting"))))
|
||
(seq-sort-by (lambda (c) (concat (symbol-name (car c))
|
||
(org-roam-node-title (cdr c))))
|
||
#'string-lessp))))
|
||
(list
|
||
changed-inbox
|
||
changed-fleeting
|
||
changed-permanent))))
|
||
|
||
(defun my/org-review-org-roam-format (date)
|
||
(let* ((data (my/org-review--org-roam-get-changes date))
|
||
(changed-inbox (nth 0 data))
|
||
(changed-fleeting (nth 1 data))
|
||
(changed-permanent (nth 2 data)))
|
||
(concat
|
||
"Changes in inbox:\n"
|
||
(thread-last
|
||
changed-inbox
|
||
(mapcar (lambda (change)
|
||
(format "- %s :: %s\n"
|
||
(cond
|
||
((or (member (car change) '(deleted moved))
|
||
(string-match-p "figured-out" (cdr change)))
|
||
"Processed")
|
||
(t (capitalize (symbol-name (car change)))))
|
||
(cdr change))))
|
||
(apply #'concat))
|
||
"\nChanges in fleeting notes:\n"
|
||
(thread-last
|
||
changed-fleeting
|
||
(mapcar (lambda (c)
|
||
(format "- %s :: [[id:%s][%s]]\n"
|
||
(capitalize (symbol-name (car c)))
|
||
(org-roam-node-id (cdr c))
|
||
(org-roam-node-title (cdr c)))))
|
||
(apply #'concat))
|
||
"\nChanges in permanent notes:\n"
|
||
(thread-last
|
||
changed-permanent
|
||
(mapcar (lambda (c)
|
||
(format "- %s :: [[id:%s][%s]]\n"
|
||
(capitalize (symbol-name (car c)))
|
||
(org-roam-node-id (cdr c))
|
||
(org-roam-node-title (cdr c)))))
|
||
(apply #'concat)))))
|
||
#+end_src
|
||
**** General review logic
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-review-get-last-review-date (kind)
|
||
(let* ((start-of-day (- (time-convert nil #'integer)
|
||
(% (time-convert nil #'integer)
|
||
(* 24 60 60))))
|
||
(query-res (org-journal-tags-query
|
||
:tag-names (list (format "review.%s" kind))
|
||
:start-date (pcase kind
|
||
('weekly
|
||
(- start-of-day
|
||
(* 21 24 60 60)))
|
||
('zk
|
||
(- start-of-day
|
||
(* 45 24 60 60)))
|
||
(_ (error "Unsupported kind: %s" kind)))
|
||
:location 'section
|
||
:order 'descending)))
|
||
(if query-res
|
||
(org-journal-tag-reference-date (car query-res))
|
||
(pcase kind
|
||
('weekly (- start-of-day (* 7 24 60 60)))
|
||
('zk (- start-of-day (* 45 24 60 60)))))))
|
||
#+end_src
|
||
**** Weekly review
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-review-set-weekly-record ()
|
||
(save-excursion
|
||
(let ((last-review-date (my/org-review-get-last-review-date 'weekly)))
|
||
(org-journal-tags-prop-apply-delta :add '("review.weekly"))
|
||
(insert "Weekly Review")
|
||
(goto-char (point-max))
|
||
|
||
(insert "Last review date: "
|
||
(format-time-string
|
||
"[%Y-%m-%d]"
|
||
(seconds-to-time last-review-date)))
|
||
(insert "
|
||
|
||
Review checklist (/delete this/):
|
||
- [ ] Clear email inbox
|
||
- [ ] Reconcile ledger
|
||
- [ ] Clear [[file:~/Downloads][downloads]] and [[file:~/00-Scratch][scratch]] folders
|
||
- [ ] Process [[file:~/30-39 Life/35 Photos/35.00 Inbox/][photo inbox]]
|
||
- [ ] Process [[file:../inbox.org][inbox]]
|
||
- [ ] Create [[file:../recurring.org][recurring tasks]] for next week
|
||
- [ ] Check agenda (-1 / +2 weeks): priorities, deadlines
|
||
- [ ] Check TODOs: priorities, deadlines
|
||
- [[org-ql-search:todo%3A?buffers-files=%22org-agenda-files%22&super-groups=%28%28%3Aauto-outline-path-file%20t%29%29&sort=%28priority%20todo%20deadline%29][org-ql-search: All TODOs]]
|
||
- [[org-ql-search:(and (todo) (not (tags \"nots\")) (not (ts :from -14)) (not (todo \"MAYBE\")))?buffers-files=%22org-agenda-files%22&super-groups=%28%28%3Aauto-outline-path-file%20t%29%29&sort=%28priority%20todo%20deadline%29][org-ql-search: Stale tasks]]
|
||
- [[org-ql-search:todo%3AWAIT?buffers-files=%22org-agenda-files%22&super-groups=%28%28%3Aauto-outline-path-file%20t%29%29&sort=%28priority%20todo%20deadline%29][org-ql-search: WAIT]]
|
||
- [[org-ql-search:todo%3AMAYBE?buffers-files=%22org-agenda-files%22&super-groups=%28%28%3Aauto-outline-path-file%20t%29%29&sort=%28priority%20todo%20deadline%29][org-ql-search: MAYBE]]
|
||
- [ ] Run auto-archiving
|
||
- [ ] Review journal records
|
||
")
|
||
|
||
(insert "
|
||
,*** Summary
|
||
TODO Write something, maybe? "))))
|
||
|
||
(defun my/org-review-weekly ()
|
||
(interactive)
|
||
(let ((org-journal-after-entry-create-hook
|
||
`(,@org-journal-after-entry-create-hook
|
||
my/org-review-set-weekly-record)))
|
||
(org-journal-new-entry nil)
|
||
(org-fold-show-subtree)))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org-journal
|
||
(my-leader-def "ojw" #'my/org-review-weekly))
|
||
#+end_src
|
||
**** Daily review
|
||
My attempt at a daily review, or an end-of-day routine.
|
||
|
||
I try to keep it under 10-15 minutes.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/kill-messengers ()
|
||
(interactive)
|
||
(when (get-buffer telega-root-buffer-name)
|
||
(telega-kill t))
|
||
(call-process-shell-command "pkill -f rocketchat-desktop")
|
||
(call-process-shell-command "pkill -f 'bwrap --args 36 element'")
|
||
(call-process-shell-command "pkill -f element-desktop"))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-review-set-daily-record ()
|
||
(let* ((today (format-time-string
|
||
"%Y-%m-%d"
|
||
(days-to-time
|
||
(- (org-today) (time-to-days 0)))))
|
||
(roam-changes (my/org-review--org-roam-get-changes today)))
|
||
(save-excursion
|
||
(org-journal-tags-prop-apply-delta :add '("review.daily"))
|
||
(insert "Daily Review")
|
||
(goto-char (point-max))
|
||
|
||
(insert "
|
||
Maintenance checklist (/delete this/):
|
||
- [ ] [[elisp:(my/kill-messengers)][Close all messengers]]
|
||
- [ ] Process [[file:../inbox.org][inbox]]
|
||
- [ ] Check if clocked tasks are properly annotated
|
||
- [[elisp:(my/org-ql-clocked-today)][Tasks clocked today]]
|
||
- [[elisp:(my/org-ql-closed-today)][Tasks closed today]]
|
||
- [ ] Check agenda for the current week
|
||
|
||
/Remember, all of the following headers are optional./
|
||
|
||
,*** Happened today
|
||
Happened to me:
|
||
- /Anything interesting?/
|
||
Happened to the world:
|
||
- /Anything important?/
|
||
|
||
,*** New ideas
|
||
/Write them down in org-roam with the \"fleeting\" tag; leave links here. Perhaps note what sparked that idea?/
|
||
"
|
||
(thread-last
|
||
(nth 1 roam-changes)
|
||
(seq-filter (lambda (c) (eq 'added (car c))))
|
||
(mapcar (lambda (c)
|
||
(format "- [[id:%s][%s]]\n"
|
||
(org-roam-node-id (cdr c))
|
||
(org-roam-node-title (cdr c)))))
|
||
(apply #'concat))
|
||
"
|
||
,*** Interactions today
|
||
/Any meaninginful interactions, conflicts or tensions?/
|
||
|
||
,*** Emotions today
|
||
/How did I feel?/
|
||
"))))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-review-daily ()
|
||
(interactive)
|
||
(let ((org-journal-after-entry-create-hook
|
||
`(,@org-journal-after-entry-create-hook
|
||
my/org-review-set-daily-record)))
|
||
(org-journal-new-entry nil)
|
||
(org-fold-show-subtree)))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org-journal
|
||
(my-leader-def "ojd" #'my/org-review-daily))
|
||
#+end_src
|
||
**** ZK review
|
||
Mostly incorporating or discarding my fleeting notes here.
|
||
|
||
This function formats the list of notes to review:
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-review-org-roam-format-zk-before (date)
|
||
(let* ((data (my/org-review--org-roam-get-changes date))
|
||
(changed-inbox (nth 0 data))
|
||
(changed-fleeting (nth 1 data))
|
||
(changed-permanent (nth 2 data)))
|
||
(concat
|
||
(when changed-inbox
|
||
(concat
|
||
"Process these changes in inbox:\n"
|
||
(thread-last
|
||
changed-inbox
|
||
(mapcar (lambda (change)
|
||
(format "- [ ] %s :: %s\n"
|
||
(cond
|
||
((or (member (car change) '(deleted moved))
|
||
(string-match-p "figured-out" (cdr change)))
|
||
"Processed")
|
||
(t (capitalize (symbol-name (car change)))))
|
||
(cdr change))))
|
||
(apply #'concat))
|
||
"\n"))
|
||
(when changed-fleeting
|
||
(concat
|
||
"Process these fleeting notes:\n"
|
||
(thread-last
|
||
changed-fleeting
|
||
(mapcar (lambda (c)
|
||
(format "- [ ] %s :: [[id:%s][%s]]\n"
|
||
(capitalize (symbol-name (car c)))
|
||
(org-roam-node-id (cdr c))
|
||
(org-roam-node-title (cdr c)))))
|
||
(apply #'concat))
|
||
"\n"))
|
||
(when changed-permanent
|
||
(concat
|
||
"Check these changes in permanent notes:\n"
|
||
(thread-last
|
||
changed-permanent
|
||
(mapcar (lambda (c)
|
||
(format "- [ ] %s :: [[id:%s][%s]]\n"
|
||
(capitalize (symbol-name (car c)))
|
||
(org-roam-node-id (cdr c))
|
||
(org-roam-node-title (cdr c)))))
|
||
(apply #'concat)))))))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-review-org-roam-finish (date)
|
||
(org-roam-db-sync)
|
||
(save-excursion
|
||
(org-back-to-heading)
|
||
(replace-regexp
|
||
(rx
|
||
":BEGIN_REVIEW:" (* anything) ":END_REVIEW:")
|
||
(string-trim
|
||
(my/org-review-org-roam-format date)))))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-review-set-zk-record ()
|
||
(save-excursion
|
||
(let ((last-review-date (my/org-review-get-last-review-date 'zk)))
|
||
(org-journal-tags-prop-apply-delta :add '("review.zk"))
|
||
(insert "Zettelkasten Review")
|
||
(goto-char (point-max))
|
||
|
||
(insert "Last review date: "
|
||
(format-time-string
|
||
"[%Y-%m-%d]"
|
||
(seconds-to-time last-review-date)))
|
||
|
||
(insert "\n\n:BEGIN_REVIEW:\n"
|
||
"Process all the required categories in this block, then execute \"Finish review\".\n\n"
|
||
(string-trim
|
||
(my/org-review-org-roam-format-zk-before last-review-date))
|
||
"\n\n[[elisp:(my/org-review-org-roam-finish \""
|
||
(format-time-string "%Y-%m-%d" last-review-date)
|
||
"\")][Finish review]]"
|
||
"\n:END_REVIEW:\n"))))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-review-zk ()
|
||
(interactive)
|
||
(let ((org-journal-after-entry-create-hook
|
||
`(,@org-journal-after-entry-create-hook
|
||
my/org-review-set-zk-record)))
|
||
(org-journal-new-entry nil)
|
||
(org-fold-show-subtree)))
|
||
|
||
(with-eval-after-load 'org-journal
|
||
(my-leader-def "ojz" #'my/org-review-zk))
|
||
#+end_src
|
||
|
||
*** Contacts
|
||
=org-contacts= is a package to store contacts in an org file.
|
||
|
||
It seems the package has been somewhat revived in the recent months. It used things like =lexical-let= when I first found it.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-contacts
|
||
:straight (:type git :repo "https://repo.or.cz/org-contacts.git")
|
||
:if (not my/remote-server)
|
||
:after (org)
|
||
:config
|
||
(setq org-contacts-files (list
|
||
(concat org-directory "/contacts.org"))))
|
||
#+end_src
|
||
|
||
An example contact entry can look like this:
|
||
#+begin_example
|
||
,* Pavel Korytov
|
||
:PROPERTIES:
|
||
:TYPE: person
|
||
:EMAIL: thexcloud@gmail.com
|
||
:EMAIL+: pvkorytov@etu.ru
|
||
:BIRTHDAY: [1998-08-14]
|
||
:END:
|
||
#+end_example
|
||
*** Calendar view
|
||
[[https://github.com/kiwanami/emacs-calfw][calfw]] is a nice package that displays calendars in Emacs.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/calfw-setup-buffer ()
|
||
(display-line-numbers-mode -1))
|
||
|
||
(use-package calfw
|
||
:straight t
|
||
:defer t
|
||
:config
|
||
(add-hook 'cfw:calendar-mode-hook #'my/calfw-setup-buffer))
|
||
|
||
(use-package calfw-org
|
||
:after (calfw org)
|
||
:straight t)
|
||
#+end_src
|
||
*** org-timeblock
|
||
#+begin_src emacs-lisp
|
||
(defun my/org-timeblock-conf ()
|
||
(display-line-numbers-mode -1))
|
||
|
||
(use-package org-timeblock
|
||
:straight (:host github :repo "ichernyshovvv/org-timeblock")
|
||
:commands (org-timeblock-mode)
|
||
:init
|
||
(my-leader-def "ot" #'org-timeblock)
|
||
:config
|
||
(add-hook 'org-timeblock-mode-hook #'my/org-timeblock-conf)
|
||
(general-define-key
|
||
:keymaps '(org-timeblock-mode-map)
|
||
:states '(normal visual)
|
||
"j" #'org-timeblock-forward-block
|
||
"h" #'org-timeblock-backward-column
|
||
"l" #'org-timeblock-forward-column
|
||
"k" #'org-timeblock-backward-block
|
||
"M-[" #'org-timeblock-day-earlier
|
||
"M-]" #'org-timeblock-day-later
|
||
"H" #'org-timeblock-day-earlier
|
||
"L" #'org-timeblock-day-later
|
||
"RET" #'org-timeblock-goto
|
||
"t" #'org-timeblock-todo-set
|
||
"q" #'quit-window))
|
||
#+end_src
|
||
*** org-drill
|
||
Trying to learn stuff with this.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package org-drill
|
||
:straight t
|
||
:commands (org-drill)
|
||
:after (org))
|
||
#+end_src
|
||
|
||
** Export
|
||
:PROPERTIES:
|
||
:MODULE_NAME: org-export
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-org-export.el :comments links
|
||
:END:
|
||
*** Hugo
|
||
A package for exporting Org to Hugo. That's how I manage my [[https://sqrtminusone.xyz][sqrtminusone.xyz]].
|
||
|
||
References:
|
||
- [[https://ox-hugo.scripter.co/][ox-hugo homepage]]
|
||
- [[https://github.com/SqrtMinusOne/sqrtminusone.github.io][Source code for sqrtminusone.xyz]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package ox-hugo
|
||
:straight t
|
||
:if (not my/remote-server)
|
||
:after ox)
|
||
#+end_src
|
||
*** Jupyter Notebook
|
||
#+begin_src emacs-lisp
|
||
(use-package ox-ipynb
|
||
:straight (:host github :repo "jkitchin/ox-ipynb")
|
||
:if (not my/remote-server)
|
||
:disabled t
|
||
:after ox)
|
||
#+end_src
|
||
*** Html export
|
||
#+begin_src emacs-lisp
|
||
(use-package htmlize
|
||
:straight t
|
||
:after ox
|
||
:if (not my/remote-server)
|
||
:config
|
||
(setq org-html-htmlize-output-type 'css))
|
||
#+end_src
|
||
*** org-ref
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org-ref
|
||
(setq org-ref-csl-default-locale "ru-RU")
|
||
(setq org-ref-csl-default-style (expand-file-name
|
||
(concat user-emacs-directory
|
||
"gost-r-7-0-5-2008-numeric.csl"))))
|
||
#+end_src
|
||
*** LaTeX
|
||
Add a custom LaTeX template without default packages. Packages are indented to be imported with function from [[Import *.sty]].
|
||
#+begin_src emacs-lisp
|
||
(defun my/setup-org-latex ()
|
||
(setq org-latex-prefer-user-labels t)
|
||
(setq org-latex-compiler "xelatex") ;; Probably not necessary
|
||
(setq org-latex-pdf-process '("latexmk -outdir=%o %f")) ;; Use latexmk
|
||
(setq org-latex-listings 'minted) ;; Use minted to highlight source code
|
||
(setq org-latex-minted-options ;; Some minted options I like
|
||
'(("breaklines" "true")
|
||
("tabsize" "4")
|
||
("autogobble")
|
||
("linenos")
|
||
("numbersep" "0.5cm")
|
||
("xleftmargin" "1cm")
|
||
("frame" "single")))
|
||
;; Use extarticle without the default packages
|
||
(add-to-list 'org-latex-classes
|
||
'("org-plain-extarticle"
|
||
"\\documentclass{extarticle}
|
||
[NO-DEFAULT-PACKAGES]
|
||
[PACKAGES]
|
||
[EXTRA]"
|
||
("\\section{%s}" . "\\section*{%s}")
|
||
("\\subsection{%s}" . "\\subsection*{%s}")
|
||
("\\subsubsection{%s}" . "\\subsubsection*{%s}")
|
||
("\\paragraph{%s}" . "\\paragraph*{%s}")
|
||
("\\subparagraph{%s}" . "\\subparagraph*{%s}")))
|
||
(add-to-list 'org-latex-classes
|
||
'("org-plain-extreport"
|
||
"\\documentclass{extreport}
|
||
[NO-DEFAULT-PACKAGES]
|
||
[PACKAGES]
|
||
[EXTRA]"
|
||
("\\chapter{%s}" . "\\chapter*{%s}")
|
||
("\\section{%s}" . "\\section*{%s}")
|
||
("\\subsection{%s}" . "\\subsection*{%s}")
|
||
("\\subsubsection{%s}" . "\\subsubsection*{%s}")
|
||
("\\paragraph{%s}" . "\\paragraph*{%s}")))
|
||
;; Use beamer without the default packages
|
||
(add-to-list 'org-latex-classes
|
||
'("org-latex-beamer"
|
||
"\\documentclass{beamer}
|
||
[NO-DEFAULT-PACKAGES]
|
||
[PACKAGES]
|
||
[EXTRA]"
|
||
("beamer" "\\documentclass[presentation]{beamer}"
|
||
("\\section{%s}" . "\\section*{%s}")
|
||
("\\subsection{%s}" . "\\subsection*{%s}")
|
||
("\\subsubsection{%s}" . "\\subsubsection*{%s}")))))
|
||
|
||
;; Make sure to eval the function when org-latex-classes list already exists
|
||
(with-eval-after-load 'ox-latex
|
||
(my/setup-org-latex))
|
||
#+end_src
|
||
|
||
**** Fix Russian dictionary
|
||
No idea why, but somehow the exported file uses english words if there isn't =:default= key in the dictionary.
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'ox
|
||
(setq org-export-dictionary
|
||
(cl-loop for item in org-export-dictionary collect
|
||
(cons
|
||
(car item)
|
||
(cl-loop for entry in (cdr item)
|
||
if (and (equal (car entry) "ru")
|
||
(plist-get (cdr entry) :utf-8))
|
||
collect (list "ru" :default (plist-get (cdr entry) :utf-8))
|
||
else collect entry)))))
|
||
#+end_src
|
||
|
||
** System configuration
|
||
Functions related to literate configuration.
|
||
|
||
*** Tables for Guix Dependencies
|
||
This section deals with using [[https://guix.gnu.org/en/cookbook/en/html_node/Advanced-package-management.html#Advanced-package-management][using profiles in GNU Guix]].
|
||
|
||
A "profile" in Guix is a way to group package installations. For instance, I have a "music" profile that has software like [[https://www.musicpd.org/][MPD]], [[https://github.com/ncmpcpp/ncmpcpp][ncmpcpp]] that I'm still occasionally using because of its tag editor, etc. Corresponding to that profile, there's a manifest named =music.scm= that looks like this:
|
||
#+begin_src scheme
|
||
(specifications->manifest
|
||
'(
|
||
"flac"
|
||
"cuetools"
|
||
"shntool"
|
||
"mpd-mpc"
|
||
"mpd-watcher"
|
||
"picard"
|
||
"ncmpcpp"
|
||
"mpd"))
|
||
#+end_src
|
||
|
||
I could generate this file with =org-babel= as any other, but that is often not so convenient. For example, I have a [[https://github.com/polybar/polybar][polybar]] module that uses [[https://github.com/risacher/sunwait][sunwait]] to show sunset and sunrise times, and ideally, I want to declare =sunwait= to be in the "desktop-polybar" profile in the same section that has the polybar module definition and the bash script.
|
||
|
||
So here's an approach I came up with. The relevant section of the config looks like this:
|
||
#+begin_example
|
||
,*** sun
|
||
| Category | Guix dependency |
|
||
|-----------------+-----------------|
|
||
| desktop-polybar | sunwait |
|
||
|
||
Prints out the time of sunrise/sunset. Uses [[https://github.com/risacher/sunwait][sunwait]]
|
||
|
||
,#+begin_src bash :tangle ./bin/polybar/sun.sh :noweb yes
|
||
...script...
|
||
,#+end_src
|
||
|
||
,#+begin_src conf-windows :noweb yes
|
||
...polybar module definition...
|
||
,#+end_src
|
||
#+end_example
|
||
|
||
So =sunwait= is declared in an Org table with =Guix dependency= in the header. Such tables are spread through my configuration files.
|
||
|
||
Thus I made a function that extracts packages from all such tables from the current Org buffer. The rules are as follows:
|
||
- If a column name matches =[G|g]uix.*dep=, its contents are added to the result.
|
||
- If =CATEGORY= is passed, a column with name =[C|c]ategory= is used to filter results. That way, one Org file can be used to produce multiple manifests.
|
||
- If =CATEGORY= is not passed, entries with the non-empty category are filtered out
|
||
- If there is a =[D|d]isabled= column, entries that have a non-empty value in this column are filtered out.
|
||
|
||
And here is the implementation:
|
||
#+begin_src emacs-lisp :noweb-ref guix-tables
|
||
(defun my/extract-guix-dependencies (&optional category)
|
||
(let ((dependencies '()))
|
||
(org-table-map-tables
|
||
(lambda ()
|
||
(let* ((table
|
||
(seq-filter
|
||
(lambda (q) (not (eq q 'hline)))
|
||
(org-table-to-lisp)))
|
||
(dep-name-index
|
||
(cl-position
|
||
nil
|
||
(mapcar #'substring-no-properties (nth 0 table))
|
||
:test (lambda (_ elem)
|
||
(string-match-p "[G|g]uix.*dep" elem))))
|
||
(category-name-index
|
||
(cl-position
|
||
nil
|
||
(mapcar #'substring-no-properties (nth 0 table))
|
||
:test (lambda (_ elem)
|
||
(string-match-p ".*[C|c]ategory.*" elem))))
|
||
(disabled-name-index
|
||
(cl-position
|
||
nil
|
||
(mapcar #'substring-no-properties (nth 0 table))
|
||
:test (lambda (_ elem)
|
||
(string-match-p ".*[D|d]isabled.*" elem)))))
|
||
(when dep-name-index
|
||
(dolist (elem (cdr table))
|
||
(when
|
||
(and
|
||
;; Category
|
||
(or
|
||
;; Category not set and not present in the table
|
||
(and
|
||
(or (not category) (string-empty-p category))
|
||
(not category-name-index))
|
||
;; Category is set and present in the table
|
||
(and
|
||
category-name-index
|
||
(not (string-empty-p category))
|
||
(string-match-p category (nth category-name-index elem))))
|
||
;; Not disabled
|
||
(or
|
||
(not disabled-name-index)
|
||
(string-empty-p (nth disabled-name-index elem))))
|
||
(add-to-list
|
||
'dependencies
|
||
(substring-no-properties (nth dep-name-index elem)))))))))
|
||
dependencies))
|
||
#+end_src
|
||
|
||
To make it work in the configuration, it is necessary to format the list so that Scheme could read it:
|
||
#+begin_src emacs-lisp :noweb-ref guix-tables
|
||
(defun my/format-guix-dependencies (&optional category)
|
||
(mapconcat
|
||
(lambda (e) (concat "\"" e "\""))
|
||
(my/extract-guix-dependencies category)
|
||
"\n"))
|
||
#+end_src
|
||
|
||
And we need an Org snippet such as this:
|
||
#+begin_example
|
||
#+NAME: packages
|
||
#+begin_src emacs-lisp :tangle no :var category=""
|
||
(my/format-guix-dependencies category)
|
||
#+end_src
|
||
#+end_example
|
||
|
||
Now, creating a manifest, for example, for the =desktop-polybar= profile is as simple as:
|
||
#+begin_example
|
||
#+begin_src scheme :tangle ~/.config/guix/manifests/desktop-polybar.scm :noweb yes
|
||
(specifications->manifest
|
||
'(
|
||
<<packages("desktop-polybar")>>))
|
||
#+end_src
|
||
#+end_example
|
||
|
||
There's a newline symbol between "(" and =<<packages("desktop-polybar")>>= because whenever a noweb expression expands into multiple lines, for each new line noweb duplicates contents between the start of the line and the start of the expression.
|
||
|
||
One reason this is so is to support languages where indentation is a part of the syntax, for instance, Python:
|
||
#+begin_src python
|
||
class TestClass:
|
||
<<class-contents>>
|
||
#+end_src
|
||
|
||
So every line of =<<class-contents>>= will be indented appropriately. In our case though, it is a minor inconvenience to be aware of.
|
||
*** Noweb evaluations
|
||
One note is that by default running these commands will require the user to confirm evaluation of each code block. To avoid that, I set =org-confirm-babel-evaluate= to =nil=:
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq my/org-config-files
|
||
(mapcar
|
||
#'expand-file-name
|
||
'("~/Emacs.org"
|
||
"~/Desktop.org"
|
||
"~/Console.org"
|
||
"~/Guix.org"
|
||
"~/Mail.org")))
|
||
|
||
(add-hook 'org-mode-hook
|
||
(lambda ()
|
||
(when (member (buffer-file-name) my/org-config-files)
|
||
(setq-local org-confirm-babel-evaluate nil))))
|
||
#+end_src
|
||
*** yadm hook
|
||
A script to run tangle from CLI.
|
||
|
||
#+begin_src emacs-lisp :tangle ~/.config/yadm/hooks/run-tangle.el :noweb yes
|
||
(require 'org)
|
||
|
||
(org-babel-do-load-languages
|
||
'org-babel-load-languages
|
||
'((emacs-lisp . t)
|
||
(shell . t)))
|
||
|
||
;; Do not ask to confirm evaluations
|
||
(setq org-confirm-babel-evaluate nil)
|
||
|
||
<<guix-tables>>
|
||
|
||
;; A few dummy modes to avoid being prompted for comment systax
|
||
(define-derived-mode fish-mode prog-mode "Fish"
|
||
(setq-local comment-start "# ")
|
||
(setq-local comment-start-skip "#+[\t ]*"))
|
||
|
||
(define-derived-mode yaml-mode text-mode "YAML"
|
||
(setq-local comment-start "# ")
|
||
(setq-local comment-start-skip "#+ *"))
|
||
|
||
(mapcar #'org-babel-tangle-file
|
||
'("/home/pavel/Emacs.org"
|
||
"/home/pavel/Desktop.org"
|
||
"/home/pavel/Console.org"
|
||
"/home/pavel/Guix.org"
|
||
"/home/pavel/Mail.org"))
|
||
#+end_src
|
||
|
||
To launch from CLI, run:
|
||
#+begin_src bash :tangle no
|
||
emacs -Q --batch -l run-tangle.el
|
||
#+end_src
|
||
|
||
|
||
I have added this line to yadm's =post_alt= hook, so to run tangle after =yadm alt=
|
||
*** Regenerate desktop config
|
||
Somewhat similar to the previous one... Occasinally I want to re-tangle all desktop configuration files, for instance to apply a new theme.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/regenerate-desktop ()
|
||
(interactive)
|
||
(org-babel-tangle-file "/home/pavel/Desktop.org")
|
||
(org-babel-tangle-file "/home/pavel/Console.org")
|
||
(call-process "xrdb" nil nil nil "-load" "/home/pavel/.Xresources")
|
||
(call-process "~/bin/polybar.sh")
|
||
(call-process "pkill" nil nil nil "dunst")
|
||
(call-process "herd" nil nil nil "restart" "xsettingsd")
|
||
(when (fboundp #'my/exwm-set-alpha)
|
||
(if (my/light-p)
|
||
(my/exwm-set-alpha 100)
|
||
(my/exwm-set-alpha 90))))
|
||
#+end_src
|
||
|
||
* Applications
|
||
** Dired
|
||
:PROPERTIES:
|
||
:MODULE_NAME: dired
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-dired.el :comments links
|
||
:END:
|
||
Dired is the built-in Emacs file manager. It's so good that it's strange that, to my knowledge, no one tried to replicate it outside of Emacs.
|
||
|
||
I currently use it as my primary file manager.
|
||
|
||
*** Basic config & keybindings
|
||
My config mostly follows ranger's and vifm's keybindings which I'm used to.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package dired
|
||
:ensure nil
|
||
:custom ((dired-listing-switches "-alh --group-directories-first"))
|
||
:commands (dired)
|
||
:config
|
||
(setq dired-dwim-target t)
|
||
(setq wdired-allow-to-change-permissions t)
|
||
(setq wdired-create-parent-directories t)
|
||
(setq dired-recursive-copies 'always)
|
||
(setq dired-recursive-deletes 'always)
|
||
(setq dired-kill-when-opening-new-dired-buffer t)
|
||
(add-hook 'dired-mode-hook
|
||
(lambda ()
|
||
(setq truncate-lines t)
|
||
(visual-line-mode nil)))
|
||
|
||
(when my/is-termux
|
||
(add-hook 'dired-mode-hook #'dired-hide-details-mode))
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'dired-mode-map
|
||
"h" #'dired-up-directory
|
||
"l" #'dired-find-file
|
||
"=" #'dired-narrow
|
||
"-" #'my/dired-create-empty-file-subtree
|
||
"~" #'eshell
|
||
"M-r" #'wdired-change-to-wdired-mode
|
||
"<left>" #'dired-up-directory
|
||
"<right>" #'dired-find-file
|
||
"M-<return>" #'my/dired-open-xdg))
|
||
|
||
(defun my/dired-home ()
|
||
"Open dired at $HOME"
|
||
(interactive)
|
||
(dired (expand-file-name "~")))
|
||
|
||
(my-leader-def
|
||
"ad" #'dired
|
||
"aD" #'my/dired-bookmark-open)
|
||
#+end_src
|
||
*** Addons
|
||
I used to use [[https://www.emacswiki.org/emacs/DiredPlus][dired+]], which provides a lot of extensions for dired functionality, but it also creates some new problems, so I opt out of it. Fortunately, the one feature I want from this package - adding more colors to dired buffers - is available as a separate package.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package diredfl
|
||
:straight t
|
||
:after (dired)
|
||
:config
|
||
(diredfl-global-mode 1))
|
||
#+end_src
|
||
|
||
[[https://github.com/Fuco1/dired-hacks#dired-subtree][dired-subtree]] is a package that enables managing Dired buffers in a tree-like manner. By default =evil-collection= maps =dired-subtree-toggle= to =TAB=.
|
||
#+begin_src emacs-lisp
|
||
(use-package dired-subtree
|
||
:after (dired)
|
||
:straight t)
|
||
|
||
(defun my/dired-create-empty-file-subtree ()
|
||
(interactive)
|
||
(let ((default-directory (dired-current-directory)))
|
||
(dired-create-empty-file
|
||
(read-file-name "Create empty file: "))))
|
||
#+end_src
|
||
|
||
[[https://github.com/jojojames/dired-sidebar][dired-sidebar]] enables opening Dired in sidebar. For me, with dired-subtree this makes dired a better option than Treemacs.
|
||
#+begin_src emacs-lisp
|
||
(defun my/dired-sidebar-toggle ()
|
||
(interactive)
|
||
(if (not current-prefix-arg)
|
||
(dired-sidebar-toggle-sidebar)
|
||
(let ((dired-sidebar-follow-file-at-point-on-toggle-open
|
||
current-prefix-arg)
|
||
(current-prefix-arg nil))
|
||
(dired-sidebar-toggle-sidebar))))
|
||
|
||
(use-package dired-sidebar
|
||
:straight t
|
||
:after (dired)
|
||
:commands (dired-sidebar-toggle-sidebar)
|
||
:init
|
||
(setq dired-sidebar-follow-file-at-point-on-toggle-open nil)
|
||
(general-define-key
|
||
:keymaps '(normal override global)
|
||
"C-n" `(my/dired-sidebar-toggle
|
||
:wk "dired-sidebar"))
|
||
:config
|
||
(setq dired-sidebar-width 45)
|
||
(defun my/dired-sidebar-setup ()
|
||
(toggle-truncate-lines 1)
|
||
(display-line-numbers-mode -1)
|
||
(setq-local dired-subtree-use-backgrounds nil)
|
||
(setq-local window-size-fixed nil))
|
||
(general-define-key
|
||
:keymaps 'dired-sidebar-mode-map
|
||
:states '(normal emacs)
|
||
"l" #'dired-sidebar-find-file
|
||
"h" #'dired-sidebar-up-directory
|
||
"=" #'dired-narrow)
|
||
(add-hook 'dired-sidebar-mode-hook #'my/dired-sidebar-setup)
|
||
(advice-add #'dired-create-empty-file :after 'dired-sidebar-refresh-buffer))
|
||
#+end_src
|
||
|
||
[[https://github.com/vifon/dired-recent.el][dired-recent.el]] adds history to dired.
|
||
#+begin_src emacs-lisp
|
||
(use-package dired-recent
|
||
:straight t
|
||
:after dired
|
||
:config
|
||
(dired-recent-mode)
|
||
(general-define-key
|
||
:keymaps 'dired-recent-mode-map
|
||
"C-x C-d" nil))
|
||
#+end_src
|
||
|
||
Display icons for files.
|
||
|
||
| Note | Type |
|
||
|-----------+----------------------------------------------------------|
|
||
| *ACHTUNG* | This plugin is slow as hell with TRAMP or in =gnu/store= |
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package all-the-icons-dired
|
||
:straight t
|
||
:disabled t
|
||
:after (dired)
|
||
:if (display-graphic-p)
|
||
:hook (dired-mode . (lambda ()
|
||
(unless (string-match-p "/gnu/store" default-directory)
|
||
(all-the-icons-dired-mode)))))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package nerd-icons-dired
|
||
:straight t
|
||
:after (dired)
|
||
:hook (dired-mode . (lambda ()
|
||
(unless (or (file-remote-p default-directory)
|
||
(string-match-p "/gnu/store" default-directory))
|
||
(nerd-icons-dired-mode))))
|
||
:config
|
||
(advice-add #'dired-create-empty-file :around #'nerd-icons-dired--refresh-advice))
|
||
#+end_src
|
||
|
||
Provides stuff like =dired-open-xdg=
|
||
#+begin_src emacs-lisp
|
||
(use-package dired-open
|
||
:straight t
|
||
:after (dired)
|
||
:commands (dired-open-xdg))
|
||
#+end_src
|
||
|
||
[[https://elpa.gnu.org/packages/dired-du.html][dired-du]] is a package that shows directory sizes
|
||
#+begin_src emacs-lisp
|
||
(use-package dired-du
|
||
:straight t
|
||
:commands (dired-du-mode)
|
||
:config
|
||
(setq dired-du-size-format t))
|
||
#+end_src
|
||
|
||
vifm-like filter
|
||
#+begin_src emacs-lisp
|
||
(use-package dired-narrow
|
||
:straight t
|
||
:commands (dired-narrow)
|
||
:config
|
||
(general-define-key
|
||
:keymaps 'dired-narrow-map
|
||
[escape] 'keyboard-quit))
|
||
#+end_src
|
||
|
||
Display git info, such as the last commit for file and stuff. It's pretty useful but also slows down Dired a bit, hence I don't turn it out by default.
|
||
#+begin_src emacs-lisp
|
||
(use-package dired-git-info
|
||
:straight t
|
||
:after dired
|
||
:config
|
||
(general-define-key
|
||
:keymap 'dired-mode-map
|
||
:states '(normal emacs)
|
||
")" 'dired-git-info-mode))
|
||
#+end_src
|
||
|
||
[[https://github.com/SqrtMinusOne/avy-dired][avy-dired]] is my experimentation with Avy & Dired. It's somewhat unstable.
|
||
#+begin_src emacs-lisp
|
||
(use-package avy-dired
|
||
:straight (:host github :repo "SqrtMinusOne/avy-dired")
|
||
:after (dired)
|
||
:init
|
||
(my-leader-def "aa" #'avy-dired-goto-line))
|
||
#+end_src
|
||
|
||
[[https://github.com/stsquad/dired-rsync][dired-rsync]] allows using =rsync= instead of the default synchronous copy operation. The only trouble is that it doesn't replace =dired-do-copy= completely, so...
|
||
#+begin_src emacs-lisp
|
||
(defun my/dired-rsync--refresh ()
|
||
(cl-loop for window being the windows
|
||
do (with-current-buffer (window-buffer window)
|
||
(when (derived-mode-p 'dired-mode)
|
||
(revert-buffer)))))
|
||
|
||
(use-package dired-rsync
|
||
:straight t
|
||
:after (dired)
|
||
:config
|
||
(add-to-list 'global-mode-string '(:eval dired-rsync-modeline-status))
|
||
(add-hook 'dired-rsync-success-hook #'my/dired-rsync--refresh)
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps '(dired-mode-map)
|
||
"C" #'dired-rsync
|
||
"gC" #'dired-rsync-transient
|
||
"gd" #'dired-do-copy))
|
||
|
||
(use-package dired-rsync-transient
|
||
:straight t
|
||
:after (dired))
|
||
#+end_src
|
||
*** Subdirectories
|
||
Subdirectories are one of the interesting features of Dired. It allows displaying multiple folders on the same window.
|
||
|
||
I add my own keybindings and some extra functionality.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/dired-open-this-subdir ()
|
||
(interactive)
|
||
(dired (dired-current-directory)))
|
||
|
||
(defun my/dired-kill-all-subdirs ()
|
||
(interactive)
|
||
(let ((dir dired-directory))
|
||
(kill-buffer (current-buffer))
|
||
(dired dir)))
|
||
|
||
(with-eval-after-load 'dired
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'dired-mode-map
|
||
"s" nil
|
||
"ss" 'dired-maybe-insert-subdir
|
||
"sl" 'dired-maybe-insert-subdir
|
||
"sq" 'dired-kill-subdir
|
||
"sk" 'dired-prev-subdir
|
||
"sj" 'dired-next-subdir
|
||
"sS" 'my/dired-open-this-subdir
|
||
"sQ" 'my/dired-kill-all-subdirs
|
||
(kbd "TAB") 'dired-hide-subdir))
|
||
#+end_src
|
||
*** Other functions
|
||
Goto project root.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/dired-goto-project-root ()
|
||
(interactive)
|
||
(dired--find-possibly-alternative-file (projectile-project-root)))
|
||
|
||
(with-eval-after-load 'dired
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'dired-mode-map
|
||
"H" #'my/dired-goto-project-root))
|
||
#+end_src
|
||
|
||
Open a file with =xdg-open=. I used =dired-open= for this before, but I've had to abandon the package because it switched from =start-process= to =call-process=, which blocks my EXWM.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/dired-open-xdg ()
|
||
"Try to run `xdg-open' to open the file under point."
|
||
(interactive)
|
||
(when (executable-find "xdg-open")
|
||
(let ((file (ignore-errors (dired-get-file-for-visit))))
|
||
(start-process "dired-open" nil
|
||
"xdg-open" (file-truename file)))))
|
||
#+end_src
|
||
|
||
Run a command on the marked files. Unlike =dired-do-async-shell-command=, this uses =start-process= instead of =shell-command=, which prevents the output buffer from popping up.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/dired-do-async-shell-command (command &optional arg file-list)
|
||
(interactive
|
||
(let ((files (dired-get-marked-files t current-prefix-arg nil nil t)))
|
||
(list
|
||
;; Want to give feedback whether this file or marked files are used:
|
||
(dired-read-shell-command "& on %s: " current-prefix-arg files)
|
||
current-prefix-arg
|
||
files)))
|
||
(start-process-shell-command
|
||
"*Dired Command*" nil
|
||
(dired-shell-stuff-it command file-list arg)))
|
||
|
||
(with-eval-after-load 'dired
|
||
(general-define-key
|
||
:states '(normal insert)
|
||
:keymaps '(dired-mode-map)
|
||
"&" #'my/dired-do-async-shell-command))
|
||
#+end_src
|
||
|
||
*** Bookmarks
|
||
A simple bookmark list for Dired, mainly to use with TRAMP. I may look into a proper bookmarking system later.
|
||
|
||
Bookmarks are listed in the [[file:.emacs.d/private.el][private.el]] file, which has an expression like this:
|
||
#+begin_example emacs-lisp :tangle no
|
||
(setq my/dired-bookmarks
|
||
'(("HOME" . "~")
|
||
("sudo" . "/sudo::/")))
|
||
#+end_example
|
||
|
||
The file itself is encrypted with yadm.
|
||
#+begin_src emacs-lisp
|
||
(defun my/dired-bookmark-open ()
|
||
(interactive)
|
||
(let ((bookmarks
|
||
(mapcar
|
||
(lambda (el) (cons (format "%-30s %s" (car el) (cdr el)) (cdr el)))
|
||
my/dired-bookmarks)))
|
||
(dired
|
||
(cdr
|
||
(assoc
|
||
(completing-read "Dired: " bookmarks nil nil "^")
|
||
bookmarks)))))
|
||
#+end_src
|
||
*** Integrations
|
||
A few functions to send files from Dired to various places.
|
||
|
||
First, a function to get the target buffer.
|
||
#+begin_src emacs-lisp
|
||
(defun my/get-good-buffer (buffer-major-mode prompt)
|
||
(or
|
||
(cl-loop
|
||
for buf being the buffers
|
||
if (eq (buffer-local-value 'major-mode buf) buffer-major-mode)
|
||
collect buf into all-buffers
|
||
if (and (eq (buffer-local-value 'major-mode buf) buffer-major-mode)
|
||
(get-buffer-window buf t))
|
||
collect buf into visible-buffers
|
||
finally return (if (= (length visible-buffers) 1)
|
||
(car visible-buffers)
|
||
(if (= (length all-buffers) 1)
|
||
(car all-buffers)
|
||
(when-let ((buffers-by-name (mapcar (lambda (b)
|
||
(cons (buffer-name b) b))
|
||
all-buffers)))
|
||
(cdr
|
||
(assoc
|
||
(completing-read prompt buffers-by-name nil t)
|
||
buffers-by-name))))))
|
||
(user-error "No buffer found!")))
|
||
#+end_src
|
||
|
||
Attach file to telega.
|
||
#+begin_src emacs-lisp
|
||
(defun my/dired-attach-to-telega (files telega-buffer)
|
||
(interactive
|
||
(list (dired-get-marked-files nil nil #'dired-nondirectory-p)
|
||
(my/get-good-buffer 'telega-chat-mode "Telega buffer: ")))
|
||
(unless files
|
||
(user-error "No (non-directory) files selected"))
|
||
(with-current-buffer telega-buffer
|
||
(dolist (file files)
|
||
(telega-chatbuf-attach-file file))))
|
||
#+end_src
|
||
|
||
Save a telega file to a dired buffer.
|
||
#+begin_src emacs-lisp
|
||
(defun my/telega-save-to-dired (msg arg)
|
||
(interactive
|
||
(list (telega-msg-for-interactive)
|
||
(prefix-numeric-value current-prefix-arg)))
|
||
(if (eq arg 4)
|
||
(progn
|
||
(setq telega-msg-save-dir
|
||
(with-current-buffer (my/get-good-buffer 'dired-mode "Dired buffer: ")
|
||
(dired-current-directory)))
|
||
(telega-msg-save msg))
|
||
(setq default-directory (expand-file-name "~"))
|
||
(setq telega-msg-save-dir nil)
|
||
(telega-msg-save msg)))
|
||
#+end_src
|
||
|
||
Attach files to notmuch.
|
||
#+begin_src emacs-lisp
|
||
(defun my/dired-attach-to-notmuch (files notmuch-buffer)
|
||
(interactive
|
||
(list (dired-get-marked-files nil nil #'dired-nondirectory-p)
|
||
(my/get-good-buffer 'notmuch-message-mode "Notmuch message buffer: ")))
|
||
(unless files
|
||
(user-error "No (non-directory) files selected"))
|
||
(with-current-buffer notmuch-buffer
|
||
(goto-char (point-max))
|
||
(dolist (file files)
|
||
(let ((type
|
||
(or (mm-default-file-type file)
|
||
"application/octet-stream")))
|
||
(mml-attach-file
|
||
file
|
||
type
|
||
(mml-minibuffer-read-description)
|
||
(mml-minibuffer-read-disposition type nil file))))))
|
||
#+end_src
|
||
|
||
Save a notmuch file to a dired buffer.
|
||
#+begin_src emacs-lisp
|
||
(defun my/notmuch-save-to-dired (arg)
|
||
(interactive
|
||
(list (prefix-numeric-value current-prefix-arg)))
|
||
(if (eq arg 4)
|
||
(let ((default-directory
|
||
(with-current-buffer (my/get-good-buffer 'dired-mode "Dired buffer: ")
|
||
(dired-current-directory))))
|
||
(notmuch-show-save-part))
|
||
(notmuch-show-save-part)))
|
||
#+end_src
|
||
|
||
Attach files to ement.
|
||
#+begin_src emacs-lisp
|
||
(defun my/dired-attach-to-ement (files ement-buffer)
|
||
(interactive
|
||
(list (dired-get-marked-files nil nil #'dired-nondirectory-p)
|
||
(my/get-good-buffer 'ement-room-mode "Ement room buffer: ")))
|
||
(unless files
|
||
(user-error "No (non-directory) files selected"))
|
||
(with-current-buffer ement-buffer
|
||
(ement-with-room-and-session
|
||
(dolist (file files)
|
||
(ement-room-send-file
|
||
file
|
||
(read-from-minibuffer (format "Message body for %s: " file))
|
||
ement-room
|
||
ement-session)))))
|
||
#+end_src
|
||
|
||
Attach files to mastodon.
|
||
#+begin_src emacs-lisp
|
||
(defun my/dired-attach-to-mastodon (files mastodon-buffer)
|
||
(interactive
|
||
(list (dired-get-marked-files nil nil #'dired-nondirectory-p)
|
||
(or (cl-loop for buf being the buffers
|
||
if (eq (buffer-local-value 'mastodon-toot-mode buf) t)
|
||
return buf)
|
||
(user-error "No buffer found!"))))
|
||
(unless files
|
||
(user-error "No (non-directory) files selected"))
|
||
(with-current-buffer mastodon-buffer
|
||
(dolist (file files)
|
||
(mastodon-toot-attach-media
|
||
file
|
||
(read-from-minibuffer (format "Description for %s: " file))))))
|
||
#+end_src
|
||
|
||
And the keybindings:
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'dired
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'dired-mode-map
|
||
"a" nil
|
||
"at" #'my/dired-attach-to-telega
|
||
"am" #'my/dired-attach-to-notmuch
|
||
"ai" #'my/dired-attach-to-ement
|
||
"an" #'my/dired-attach-to-mastodon))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'telega
|
||
(general-define-key
|
||
:keymaps 'telega-msg-button-map
|
||
"S" #'my/telega-save-to-dired))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'notmuch
|
||
(general-define-key
|
||
:keymaps 'notmuch-show-mode-map
|
||
:states 'normal
|
||
". s" #'my/notmuch-save-to-dired))
|
||
#+end_src
|
||
** TRAMP
|
||
:PROPERTIES:
|
||
:MODULE_NAME: tramp
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-tramp.el :comments links
|
||
:END:
|
||
TRAMP is a package that provides remote editing capacities. It is particularly useful for remote server management.
|
||
|
||
*** General settings
|
||
Some settings I copy-pasted from somewhere:
|
||
#+begin_src emacs-lisp
|
||
(setq remote-file-name-inhibit-cache nil)
|
||
(setq vc-ignore-dir-regexp
|
||
(format "\\(%s\\)\\|\\(%s\\)"
|
||
vc-ignore-dir-regexp
|
||
tramp-file-name-regexp))
|
||
#+end_src
|
||
|
||
Also, a hack to make TRAMP find =ls= on Guix:
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'tramp
|
||
(setq tramp-remote-path
|
||
(append tramp-remote-path
|
||
'(tramp-own-remote-path))))
|
||
#+end_src
|
||
|
||
Set the default shell to =bin/bash= for TRAMP or on a remote server.
|
||
#+begin_src emacs-lisp
|
||
(when (or my/remote-server)
|
||
(setq explicit-shell-file-name "/bin/bash"))
|
||
#+end_src
|
||
|
||
*** Package optimizations
|
||
Unfortunately, many Emacs packages don't exactly moderate their rate of filesystem operations, and on TRAMP over network each operation adds additional overhead, so... it can get pretty slow. To debug these issues, set the following variable to 6:
|
||
#+begin_src emacs-lisp
|
||
(setq tramp-verbose 0)
|
||
#+end_src
|
||
|
||
I used to launch a separate Emacs instance for TRAMP to disable these packages with an environment variable, but my [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Advising-Functions.html][advice]]-fu got better since then.
|
||
|
||
So, to determine if the buffer is in TRAMP:
|
||
#+begin_src emacs-lisp
|
||
(defun my/tramp-p (&optional buffer)
|
||
(file-remote-p
|
||
(buffer-local-value 'default-directory (or buffer (current-buffer)))))
|
||
#+end_src
|
||
|
||
And advice to disable a function for TRAMP-related buffers:
|
||
#+begin_src emacs-lisp
|
||
(defun my/tramp-void-if-tramp (fun &rest args)
|
||
(unless (my/tramp-p)
|
||
(apply fun args)))
|
||
|
||
(defun my/tramp-void-if-file-is-tramp (fun &optional dir)
|
||
(unless (file-remote-p (or dir default-directory))
|
||
(funcall fun dir)))
|
||
#+end_src
|
||
|
||
=editorconfig=. This lovely package looks for =.editorconfig= in the file tree.
|
||
#+begin_src emacs-lisp
|
||
(defun my/editorconfig--advice-find-file-noselect-around (f f1 filename &rest args)
|
||
(if (file-remote-p filename)
|
||
(apply f1 filename args)
|
||
(apply f f1 filename args)))
|
||
|
||
(with-eval-after-load 'editorconfig
|
||
(advice-add #'editorconfig-apply :around #'my/tramp-void-if-tramp)
|
||
(advice-add #'editorconfig--disabled-for-filename
|
||
:around #'my/tramp-void-if-file-is-tramp)
|
||
(advice-add #'editorconfig--advice-find-file-noselect :around
|
||
#'my/editorconfig--advice-find-file-noselect-around))
|
||
#+end_src
|
||
|
||
=all-the-icons-dired= runs =test= on every file in the directory.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'all-the-icons-dired
|
||
(advice-add #'all-the-icons-dired-mode :around #'my/tramp-void-if-tramp))
|
||
#+end_src
|
||
|
||
=projectile= looks for =.git=, =.svn=, etc. to find the project root. Maybe I'll make a more economic implementation if I need one.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'projectile
|
||
(advice-add #'projectile-project-root :around #'my/tramp-void-if-file-is-tramp))
|
||
#+end_src
|
||
|
||
=lsp= does a whole lot of stuff. It probably can be used with TRAMP on faster connections, but not in my case.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'lsp-mode
|
||
(advice-add #'lsp :around #'my/tramp-void-if-tramp)
|
||
(advice-add #'lsp-deferred :around #'my/tramp-void-if-tramp))
|
||
#+end_src
|
||
|
||
=git-gutter= runs =git= a lot of times.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'git-gutter
|
||
(advice-add #'git-gutter--turn-on :around #'my/tramp-void-if-tramp))
|
||
#+end_src
|
||
|
||
=dired-git-info= is runs =git= a lot of times.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'dired-git-info
|
||
(advice-add #'dired-git-info-mode :around #'my/tramp-void-if-tramp))
|
||
#+end_src
|
||
|
||
=pipenv=. Don't remember what was the deal with this one.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'pipenv
|
||
(advice-add #'pipenv-mode :around #'my/tramp-void-if-tramp))
|
||
#+end_src
|
||
|
||
=eshell= works pretty well, but =company-mode= gets pretty slow.
|
||
#+begin_src emacs-lisp
|
||
(defun my/shell-maybe-configure-for-tramp ()
|
||
(when (my/tramp-p)
|
||
(setq company-idle-delay nil)))
|
||
|
||
(add-hook 'eshell-mode-hook #'my/shell-maybe-configure-for-tramp)
|
||
(add-hook 'shell-mode-hook #'my/shell-maybe-configure-for-tramp)
|
||
#+end_src
|
||
|
||
** Shells / Terminals
|
||
:PROPERTIES:
|
||
:MODULE_NAME: terms
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-terms.el :comments links
|
||
:END:
|
||
*** vterm
|
||
[[https://github.com/akermu/emacs-libvterm][vterm]] is a terminal emulator for Emacs.
|
||
|
||
References:
|
||
- [[https://github.com/akermu/emacs-libvterm][emacs-libvterm repo]]
|
||
|
||
**** Configuration
|
||
On Guix it makes more sense to use the Guix package to avoid building the vterm module, but obviously not an option on termux, hence this:
|
||
|
||
#+begin_src emacs-lisp
|
||
(when my/is-termux
|
||
(straight-use-package 'vterm))
|
||
#+end_src
|
||
|
||
The actual config:
|
||
#+begin_src emacs-lisp
|
||
(defun my/vterm-setup ()
|
||
(display-line-numbers-mode 0)
|
||
(setq-local term-prompt-regexp
|
||
(rx bol (| ">" "✕") " ")))
|
||
|
||
(use-package vterm
|
||
:commands (vterm vterm-other-window)
|
||
:config
|
||
(setq vterm-kill-buffer-on-exit t)
|
||
|
||
(setq vterm-environment '("IS_VTERM=1"))
|
||
|
||
(add-hook 'vterm-mode-hook #'my/vterm-setup)
|
||
|
||
;; (advice-add 'evil-collection-vterm-insert
|
||
;; :before (lambda (&rest args)
|
||
;; (ignore-errors
|
||
;; (apply #'vterm-reset-cursor-point args))))
|
||
|
||
(general-define-key
|
||
:keymaps 'vterm-mode-map
|
||
"M-q" 'vterm-send-escape
|
||
|
||
"C-h" 'evil-window-left
|
||
"C-l" 'evil-window-right
|
||
"C-k" 'evil-window-up
|
||
"C-j" 'evil-window-down
|
||
|
||
"C-<right>" 'evil-window-right
|
||
"C-<left>" 'evil-window-left
|
||
"C-<up>" 'evil-window-up
|
||
"C-<down>" 'evil-window-down
|
||
|
||
"M-<left>" 'vterm-send-left
|
||
"M-<right>" 'vterm-send-right
|
||
"M-<up>" 'vterm-send-up
|
||
"M-<down>" 'vterm-send-down)
|
||
|
||
(general-define-key
|
||
:keymaps 'vterm-mode-map
|
||
:states '(normal insert)
|
||
"<home>" 'vterm-beginning-of-line
|
||
"<end>" 'vterm-end-of-line)
|
||
|
||
(general-define-key
|
||
:keymaps 'vterm-mode-map
|
||
:states '(insert)
|
||
"C-r" 'vterm-send-C-r
|
||
"C-k" 'vterm-send-C-k
|
||
"C-j" 'vterm-send-C-j
|
||
"M-l" 'vterm-send-right
|
||
"M-h" 'vterm-send-left
|
||
"M-k" 'vterm-send-up
|
||
"M-j" 'vterm-send-down))
|
||
#+end_src
|
||
**** Subterminal
|
||
Open a terminal in the lower third of the frame with the =`= key.
|
||
|
||
I guess that's the first Emacs function I wrote!
|
||
|
||
#+begin_src emacs-lisp
|
||
(add-to-list 'display-buffer-alist
|
||
`(,"vterm-subterminal.*"
|
||
(display-buffer-reuse-window
|
||
display-buffer-in-side-window)
|
||
(side . bottom)
|
||
(reusable-frames . visible)
|
||
(window-height . 0.33)))
|
||
|
||
(defun my/toggle-vterm-subteminal ()
|
||
"Toogle subteminal."
|
||
(interactive)
|
||
(let ((vterm-window
|
||
(seq-find
|
||
(lambda (window)
|
||
(string-match
|
||
"vterm-subterminal.*"
|
||
(buffer-name (window-buffer window))))
|
||
(window-list))))
|
||
(if vterm-window
|
||
(if (eq (get-buffer-window (current-buffer)) vterm-window)
|
||
(kill-buffer (current-buffer))
|
||
(select-window vterm-window))
|
||
(vterm-other-window "vterm-subterminal"))))
|
||
|
||
;; (unless my/slow-ssh
|
||
;; (general-nmap "`" 'my/toggle-vterm-subteminal)
|
||
;; (general-nmap "~" 'vterm))
|
||
#+end_src
|
||
**** Dired integration
|
||
A function to get pwd for vterm. Couldn't find a built-in function for some reason, but this seems work fine:
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/vterm-get-pwd ()
|
||
(if vterm--process
|
||
(file-truename (format "/proc/%d/cwd" (process-id vterm--process)))
|
||
default-directory))
|
||
#+end_src
|
||
|
||
Now we can open dired for vterm pwd:
|
||
#+begin_src emacs-lisp
|
||
(defun my/vterm-dired-other-window ()
|
||
"Open dired in vterm pwd in other window"
|
||
(interactive)
|
||
(dired-other-window (my/vterm-get-pwd)))
|
||
|
||
(defun my/vterm-dired-replace ()
|
||
"Replace vterm with dired"
|
||
(interactive)
|
||
(let ((pwd (my/vterm-get-pwd)))
|
||
(kill-process vterm--process)
|
||
(dired pwd)))
|
||
#+end_src
|
||
|
||
The second function is particularly handy because that way I can alternate between vterm and dired.
|
||
|
||
Keybindings:
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'vterm
|
||
(general-define-key
|
||
:keymaps 'vterm-mode-map
|
||
:states '(normal)
|
||
"gd" #'my/vterm-dired-other-window
|
||
"gD" #'my/vterm-dired-replace))
|
||
#+end_src
|
||
**** With-editor integration
|
||
A package used by Magit to use the current Emacs instance as the =$EDITOR=.
|
||
|
||
That is, with the help of [[file:Console.org::Functions][this function]], I can just write =e <filename>=, edit the file, and then return to the same vterm buffer. No more running vim inside Emacs.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package with-editor
|
||
:straight t
|
||
:after (vterm)
|
||
:config
|
||
(add-hook 'vterm-mode-hook 'with-editor-export-editor))
|
||
#+end_src
|
||
*** eshell
|
||
[[https://www.gnu.org/software/emacs/manual/html_mono/eshell.html][eshell]] is a shell implemented in Emacs Lisp.
|
||
|
||
I'll try to use it as my primary shell for a few reasons. First, I just want to have a "normal" (...evil...) editing experience for shell prompts. My current shell setup is fish with vi bindings, which doesn't work well with vterm because the shell just accepts the keystrokes from the terminal, completely oblivious to the current Emacs state. So, I need to do stuff like entering insert state in Emacs, then entering normal state in the shell while keeping Emacs in insert state. That's just too much pain sometimes.
|
||
|
||
This setup also frequently messes with fish autosuggestions.
|
||
|
||
Second, I do want to be able to run =dired= or =find-file= from the terminal. I've sort of implemented that in the [[*vterm][vterm]] section, but an Emacs-integrated shell is obviously more convenient for that.
|
||
|
||
TODO:
|
||
- Configure it for TRAMP (=company-idle-delay= to a large value, what else?)
|
||
|
||
**** Initial configuration
|
||
Some initial configuration.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/configure-eshell ()
|
||
(add-hook 'eshell-pre-command-hook 'eshell-save-some-history)
|
||
(add-to-list 'eshell-output-filter-functions 'eshell-truncate-buffer)
|
||
|
||
(general-define-key
|
||
:states '(normal insert)
|
||
:keymaps 'eshell-mode-map
|
||
"<home>" #'eshell-bol)
|
||
|
||
(general-define-key
|
||
:keymaps 'eshell-mode-map
|
||
:states '(insert)
|
||
"<tab>" 'my/eshell-complete
|
||
"M-k" #'eshell-previous-matching-input-from-input
|
||
"M-j" #'eshell-next-matching-input-from-input)
|
||
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'eshell-mode-map
|
||
"C-h" 'evil-window-left
|
||
"C-l" 'evil-window-right
|
||
"C-k" 'evil-window-up
|
||
"C-j" 'evil-window-down)
|
||
;; XXX Did they forget to set it to nil?
|
||
(setq eshell-first-time-p nil))
|
||
|
||
(use-package eshell
|
||
:straight (:type built-in)
|
||
:after evil-collection
|
||
:commands (eshell)
|
||
:init
|
||
(setq eshell-history-size 10000)
|
||
(setq eshell-hist-ignoredups t)
|
||
(setq eshell-buffer-maximum-lines 10000)
|
||
:config
|
||
;; XXX 90 to override `evil-collection'
|
||
(add-hook 'eshell-first-time-mode-hook 'my/configure-eshell 90)
|
||
(setq eshell-command-aliases-list
|
||
'(("q" "exit")
|
||
("c" "clear")
|
||
("ll" "ls -la")
|
||
("e" "find-file")))
|
||
(setq eshell-banner-message "")
|
||
;; (setq eshell-visual-commands
|
||
;; `(,@eshell-visual-commands "jless"))
|
||
)
|
||
#+end_src
|
||
|
||
**** UI
|
||
I'll try reusing the [[https://starship.rs/][Starship]] prompt.
|
||
|
||
The executable can print out the text of the prompt, but somehow it refuses when there's =TERM=dumb= in the environment. I also advise Eshell to record the execution time for the =--cmd-duration= flag.
|
||
#+begin_src emacs-lisp
|
||
(defvar-local my/eshell-last-command-start-time nil)
|
||
|
||
(defun my/get-starship-prompt ()
|
||
(let ((cmd (format "TERM=xterm starship prompt --status=%d --cmd-duration=%d --logical-path=%s"
|
||
eshell-last-command-status
|
||
(if my/eshell-last-command-start-time
|
||
(let ((delta (float-time
|
||
(time-subtract
|
||
(current-time)
|
||
my/eshell-last-command-start-time))))
|
||
(setq my/eshell-last-command-start-time nil)
|
||
(round (* delta 1000)))
|
||
0)
|
||
(shell-quote-argument default-directory))))
|
||
(with-temp-buffer
|
||
(call-process "bash" nil t nil "-c" cmd)
|
||
(when my/is-termux
|
||
(let ((inhibit-message t))
|
||
(replace-string "\\[" "" nil (point-min) (point-max))
|
||
(replace-string "\\]" "" nil (point-min) (point-max))))
|
||
(thread-first "\n"
|
||
(concat (string-trim (buffer-string)))
|
||
(ansi-color-apply)))))
|
||
|
||
(defun my/eshell-set-start-time (&rest _args)
|
||
(setq-local my/eshell-last-command-start-time (current-time)))
|
||
|
||
(with-eval-after-load 'eshell
|
||
(advice-add #'eshell-send-input :before #'my/eshell-set-start-time))
|
||
#+end_src
|
||
|
||
Now this can go in =eshell-prompt-function= with two more options. First, =eshell-highlight-prompt= has to be set to nil because it screws up faces applied by =ansi-color.el=.
|
||
|
||
Second, =eshell-prompt-regexp= has to align with the =starship= configuration. The relevant part of mine looks like this:
|
||
#+begin_src toml
|
||
[character]
|
||
success_symbol = "[> ](bold green)"
|
||
error_symbol = "[✕ ](bold red)"
|
||
#+end_src
|
||
|
||
So my regex matches with either of these two prompts. IIRC the default value is different from mine.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'eshell
|
||
(setq eshell-prompt-regexp (rx bol (| ">" "✕") " "))
|
||
(setq eshell-prompt-function #'my/get-starship-prompt)
|
||
(setq eshell-highlight-prompt nil))
|
||
#+end_src
|
||
|
||
[[https://github.com/zwild/eshell-prompt-extras/][eshell-prompt-extras]] is an alternative to the above that doesn't depend on =starship=. I'll keep it here for now because I expect I won't be able to use starship everywhere.
|
||
#+begin_src emacs-lisp
|
||
(my/use-colors
|
||
(epe-pipeline-delimiter-face :foreground (my/color-value 'green))
|
||
(epe-pipeline-host-face :foreground (my/color-value 'blue))
|
||
(epe-pipeline-time-face :foreground (my/color-value 'yellow))
|
||
(epe-pipeline-user-face :foreground (my/color-value 'red)))
|
||
|
||
(use-package eshell-prompt-extras
|
||
:straight t
|
||
:after eshell
|
||
:disabled t
|
||
:config
|
||
(setq eshell-prompt-function 'epe-theme-pipeline))
|
||
#+end_src
|
||
|
||
[[https://github.com/akreisher/eshell-syntax-highlighting/][eshell-syntax-highlighting]] highlights things like correct/incorrect commands (like fish).
|
||
#+begin_src emacs-lisp
|
||
(use-package eshell-syntax-highlighting
|
||
:straight t
|
||
:after eshell
|
||
:config
|
||
(eshell-syntax-highlighting-global-mode))
|
||
#+end_src
|
||
|
||
[[https://github.com/ryuslash/eshell-fringe-status/][eshell-fringe-status]] shows the status of the last command in fringe. I've disabled it because I configured starship to use status from eshell.
|
||
#+begin_src emacs-lisp
|
||
(use-package eshell-fringe-status
|
||
:straight t
|
||
:after eshell
|
||
:disabled t
|
||
:config
|
||
(add-hook 'eshell-mode-hook 'eshell-fringe-status-mode))
|
||
#+end_src
|
||
|
||
**** Fish completions
|
||
[[https://github.com/LemonBreezes/emacs-fish-completion/][emacs-fish-completion]] uses =fish= to autocomplete prompts when the built-in completion fails. This way, it can autocomplete =docker=, =yarn=, etc., which is pretty cool.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package fish-completion
|
||
:straight t
|
||
:after eshell
|
||
:if (executable-find "fish")
|
||
:config
|
||
(global-fish-completion-mode))
|
||
#+end_src
|
||
|
||
**** Fish-like autosuggestions
|
||
I'm used to these fancy autosuggestions provided by =fish=.
|
||
|
||
There are two packages that do something like that:
|
||
- [[https://github.com/dieggsy/esh-autosuggest][esh-autosuggest]]. It just uses [[https://github.com/company-mode/company-mode][company-mode]] to display suggestions, which prevents using =company-mode= for anything else.
|
||
- [[https://elpa.gnu.org/packages/capf-autosuggest.html][capf-autosuggest]]. It mostly does what I want, but it doesn't verify its suggestion, e.g. it can suggest =cd= to a non-existing directory. I tried advising this functionality, but then the package became too slow because it fetches all candidates on each keystroke.
|
||
|
||
So, I've spent a ridiculous amount of time implementing this probably unnecessary feature, and I'm still not sure if I should keep it...
|
||
|
||
But anyway, here's my overlay-based solution inspired by [[https://github.com/copilot-emacs/copilot.el][copilot.el]]. First, we need to get the current input string:
|
||
#+begin_src emacs-lisp
|
||
(defun my/eshell-get-input ()
|
||
(save-excursion
|
||
(beginning-of-line)
|
||
(when (looking-at-p eshell-prompt-regexp)
|
||
(substring-no-properties (eshell-get-old-input)))))
|
||
#+end_src
|
||
|
||
In order to verify suggestions (for instance, to check whether the suggested directory exists), it's necessary to "unquote" strings because history stores them in the quoted form.
|
||
|
||
There's a built-in function called =shell-unquote-argument=, but it requires the current buffer to have a process for a seemingly Windows-related reason... So below is a copy of this function without that part.
|
||
#+begin_src emacs-lisp
|
||
(defun my/shell-unquote-argument-without-process (string)
|
||
(save-match-data
|
||
(let ((idx 0) next inside
|
||
(quote-chars (rx (| "'" "`" "\"" "\\"))))
|
||
(while (and (< idx (length string))
|
||
(setq next (string-match quote-chars string next)))
|
||
(cond ((= (aref string next) ?\\)
|
||
(setq string (replace-match "" nil nil string))
|
||
(setq next (1+ next)))
|
||
((and inside (= (aref string next) inside))
|
||
(setq string (replace-match "" nil nil string))
|
||
(setq inside nil))
|
||
(inside
|
||
(setq next (1+ next)))
|
||
(t
|
||
(setq inside (aref string next))
|
||
(setq string (replace-match "" nil nil string)))))
|
||
string)))
|
||
#+end_src
|
||
|
||
Now, verify one suggestion against the current input. At the moment, outside of checking the prefix, it does the following:
|
||
- If the suggestion is =cd= to directory, check if this directory exists
|
||
- If it's =git something=, check if we're in a git repo
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/eshell-history-is-good-suggestion (input suggestion)
|
||
(and (string-prefix-p input suggestion)
|
||
(if (string-prefix-p "cd " input)
|
||
(let ((suggested-dir
|
||
(my/shell-unquote-argument-without-process
|
||
(substring suggestion 3))))
|
||
(if (or (string-prefix-p "/" suggested-dir)
|
||
(string-prefix-p "~" suggested-dir))
|
||
(file-directory-p suggested-dir)
|
||
(file-directory-p (concat (eshell/pwd) "/" suggested-dir))))
|
||
t)
|
||
(if (string-prefix-p "git" suggestion)
|
||
;; How is this faster than 'magit-toplevel'?
|
||
(vc-git-root)
|
||
t)))
|
||
#+end_src
|
||
|
||
And propose one suggestion for the current =input=, because I don't need more. It users two sources:
|
||
- =eshell-history-ring=
|
||
- =pcomplete=, which is integrated with =eshell= out-of-the-box. It was soo painful to figure out... But it works.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/eshell-history-suggest-one (input)
|
||
(unless (seq-empty-p input)
|
||
(or
|
||
(when-let (s (cl-loop for elem in (ring-elements eshell-history-ring)
|
||
for proc-elem = (string-trim (substring-no-properties elem))
|
||
when (my/eshell-history-is-good-suggestion input proc-elem)
|
||
return proc-elem))
|
||
(substring s (length input)))
|
||
(ignore-errors
|
||
(when-let* ((pcomplete-stub input)
|
||
(completions (pcomplete-completions))
|
||
(one-completion (car (all-completions pcomplete-stub completions)))
|
||
(bound (car (completion-boundaries pcomplete-stub completions nil ""))))
|
||
(unless (zerop bound)
|
||
(setq one-completion (concat (substring pcomplete-stub 0 bound) one-completion)))
|
||
;; (message "%s %s" pcomplete-stub one-completion)
|
||
(comint-quote-filename
|
||
(substring one-completion (min
|
||
(length pcomplete-stub)
|
||
(length one-completion)))))))))
|
||
#+end_src
|
||
|
||
As I said, I want to use an overlay to display the suggestion. I tried to store the current overlay in a buffer-local variable, but somehow it was getting lost at times... And there aren't many overlays anyway, so this doesn't seem to slow down Emacs that much.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/eshell-overlay-get ()
|
||
(seq-find (lambda (ov)
|
||
(overlay-get ov 'my/eshell-completion-overlay))
|
||
(overlays-in (point-min) (point-max))))
|
||
#+end_src
|
||
|
||
Thanks [[https://emacs.stackexchange.com/questions/15078/inserting-before-an-after-string-overlay][this answer on StackExchange]] for pointing out the [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Special-Properties.html#index-cursor-_0028text-property_0029][cursor]] text property.
|
||
#+begin_src emacs-lisp
|
||
(defun my/eshell-overlay-update (pos value)
|
||
(let ((overlay-value (propertize value 'face 'shadow
|
||
'cursor t))
|
||
(overlay (my/eshell-overlay-get)))
|
||
(if overlay
|
||
(move-overlay overlay pos pos)
|
||
(setq overlay (make-overlay pos pos (current-buffer) nil t))
|
||
(overlay-put overlay 'my/eshell-completion-overlay t))
|
||
(overlay-put overlay 'after-string overlay-value)))
|
||
#+end_src
|
||
|
||
The function to remove the overlay:
|
||
#+begin_src emacs-lisp
|
||
(defun my/eshell-overlay-remove (&rest _)
|
||
(dolist (ov (overlays-in (point-min) (point-max)))
|
||
(when (overlay-get ov 'my/eshell-completion-overlay)
|
||
(delete-overlay ov))))
|
||
#+end_src
|
||
|
||
Putting that all together.
|
||
|
||
This also hides the overlay if =company= completion is active because =company= sometimes creates its own overlays that intersect with mine... I don't yet understand when it happens because sometimes =company= just creates the completion dialog with no overlay, and I couldn't find a way to check if the overlay is created or not.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/eshell-overlay-suggest (&rest _args)
|
||
(if-let* ((input (my/eshell-get-input))
|
||
(suggestion (my/eshell-history-suggest-one input))
|
||
(_ (not company-prefix)))
|
||
(my/eshell-overlay-update (line-end-position) suggestion)
|
||
(my/eshell-overlay-remove)))
|
||
#+end_src
|
||
|
||
The function can be added in =after-change-functions=, which is executed on every text modification. This shouldn't slow eshell down because =eshell-send-input= sets =inhibit-modification-hooks= to t.
|
||
|
||
#+begin_src emacs-lisp
|
||
(define-minor-mode my/eshell-overlay-suggest-mode
|
||
"Fish-like suggestions for eshell."
|
||
:after-hook
|
||
(if my/eshell-overlay-suggest-mode
|
||
(progn
|
||
(add-hook 'after-change-functions #'my/eshell-overlay-suggest nil t)
|
||
(add-hook 'company-completion-started-hook #'my/eshell-overlay-suggest nil t)
|
||
(add-hook 'company-after-completion-hook #'my/eshell-overlay-suggest nil t))
|
||
(remove-hook 'after-change-functions #'my/eshell-overlay-suggest t)
|
||
(add-hook 'company-completion-started-hook #'my/eshell-overlay-suggest t)
|
||
(add-hook 'company-after-completion-hook #'my/eshell-overlay-suggest t)
|
||
(my/eshell-overlay-remove)))
|
||
|
||
;; (add-hook 'eshell-mode-hook #'my/eshell-overlay-suggest-mode)
|
||
#+end_src
|
||
|
||
Finally, a function that inserts the overlay in buffer if it's available and calls =company-complete= if it's not. I've bound it to =<tab>=.
|
||
#+begin_src emacs-lisp
|
||
(defun my/eshell-complete ()
|
||
(interactive)
|
||
(if (and (= (point) (line-end-position)))
|
||
(if-let ((overlay (my/eshell-overlay-get)))
|
||
(progn
|
||
(delete-overlay overlay)
|
||
(insert (overlay-get overlay 'after-string)))
|
||
(company-complete))
|
||
(company-complete)))
|
||
#+end_src
|
||
**** Atuin integration
|
||
[[https://github.com/SqrtMinusOne/eshell-atuin][eshell-atuin]] is my package that integrates eshell with [[https://github.com/atuinsh/atuin][atuin]].
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package eshell-atuin
|
||
:straight (:host github :repo "SqrtMinusOne/eshell-atuin")
|
||
:after eshell
|
||
:config
|
||
(eshell-atuin-mode)
|
||
(setq eshell-atuin-search-fields '(time duration command))
|
||
(setq eshell-atuin-history-format "%-160c %t + %d")
|
||
(general-define-key
|
||
:states '(normal insert)
|
||
:keymaps 'eshell-mode-map
|
||
"C-r" #'eshell-atuin-history))
|
||
#+end_src
|
||
**** Dedicated buffer
|
||
Make a dedicated buffer for eshell in the bottom of the screen.
|
||
|
||
#+begin_src emacs-lisp
|
||
(add-to-list 'display-buffer-alist
|
||
'("eshell-dedicated.*"
|
||
(display-buffer-reuse-window
|
||
display-buffer-in-side-window)
|
||
(side . bottom)
|
||
(reusable-frames . visible)
|
||
(window-height . 0.33)))
|
||
|
||
(defun my/eshell-dedicated ()
|
||
(interactive)
|
||
;; XXX the byte-compiler freaks out if eshell is required within the
|
||
;; `let*' block because it binds `dedicated-buffer'... dynamically?
|
||
;; How?
|
||
(require 'eshell)
|
||
(let* ((eshell-buffer-name "eshell-dedicated")
|
||
(dedicated-buffer (get-buffer eshell-buffer-name)))
|
||
(if (not dedicated-buffer)
|
||
(eshell)
|
||
(let ((window (get-buffer-window dedicated-buffer)))
|
||
(if (eq (selected-window) window)
|
||
(kill-buffer-and-window)
|
||
(select-window window))))))
|
||
#+end_src
|
||
**** Custom commands
|
||
#+begin_src emacs-lisp
|
||
(defun eshell/prt ()
|
||
(if-let ((root (projectile-project-root)))
|
||
(eshell/cd root)
|
||
(message "Not in a project")))
|
||
#+end_src
|
||
**** Global keybindings
|
||
#+begin_src emacs-lisp
|
||
(general-define-key
|
||
:states '(normal)
|
||
"`" #'my/eshell-dedicated
|
||
"~" #'eshell)
|
||
#+end_src
|
||
|
||
*** eat
|
||
[[https://codeberg.org/akib/emacs-eat][eat]] is a terminal emulator written in Emacs Lisp.
|
||
|
||
It's slower than =vterm=, but it seems to be the second best option because of =line-mode=, which sends the input line-by-line allowing it to edit like in =eshell=. However, this obviously disables syntax higlighting and autosuggestions, which =eshell= with my configuration has.
|
||
|
||
Still, I'll probably switch to =eat= if =eshell= doesn't work for me.
|
||
#+begin_src emacs-lisp
|
||
(use-package eat
|
||
:straight (:files ("*.el" ("term" "term/*.el") "*.texi"
|
||
"*.ti" ("terminfo/e" "terminfo/e/*")
|
||
("terminfo/65" "terminfo/65/*")
|
||
("integration" "integration/*")
|
||
(:exclude ".dir-locals.el" "*-tests.el")))
|
||
:commands (eat eat-shell-mode)
|
||
:config
|
||
(setq eat-shell "/bin/bash"))
|
||
#+end_src
|
||
|
||
Yeah, and =eat= has integration with eshell too.
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'eshell-load-hook #'eat-eshell-mode)
|
||
#+end_src
|
||
|
||
*** shell
|
||
Interactive subshell (=M-x shell=) is a way to run commands with input and output through an Emacs buffer.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/setup-shell ()
|
||
(setq-local comint-use-prompt-regexp t)
|
||
(setq-local comint-prompt-read-only t))
|
||
|
||
(add-hook 'shell-mode-hook #'my/setup-shell)
|
||
#+end_src
|
||
** Managing dotfiles
|
||
:PROPERTIES:
|
||
:MODULE_NAME: dotfiles
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-dotfiles.el :comments links
|
||
:END:
|
||
A bunch of functions for managing dotfiles with yadm.
|
||
|
||
*** Open Emacs config
|
||
#+begin_src emacs-lisp
|
||
(general-define-key
|
||
;; "C-c c" (my/command-in-persp "Emacs.org" "conf" 1 (find-file "~/Emacs.org"))
|
||
"C-c c" `(,(lambda () (interactive) (find-file "~/Emacs.org")) :wk "Emacs.org"))
|
||
|
||
(my-leader-def
|
||
:infix "c"
|
||
"" '(:which-key "configuration")
|
||
;; "c" (my/command-in-persp "Emacs.org" "conf" 1 (find-file "~/Emacs.org"))
|
||
"c" `(,(lambda () (interactive) (find-file "~/Emacs.org")) :wk "Emacs.org"))
|
||
#+end_src
|
||
*** Open Magit for yadm
|
||
Idea:
|
||
|
||
- [[https://www.reddit.com/r/emacs/comments/gjukb3/yadm_magit/]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'tramp
|
||
(add-to-list 'tramp-methods
|
||
`("yadm"
|
||
(tramp-login-program "yadm")
|
||
(tramp-login-args (("enter")))
|
||
(tramp-login-env (("SHELL") "/bin/sh"))
|
||
(tramp-remote-shell "/bin/sh")
|
||
(tramp-remote-shell-args ("-c")))))
|
||
|
||
|
||
(defun my/yadm-magit ()
|
||
(interactive)
|
||
(magit-status "/yadm::"))
|
||
|
||
(my-leader-def "cm" 'my/yadm-magit)
|
||
#+end_src
|
||
*** Open a dotfile
|
||
Open a file managed by yadm.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/open-yadm-file ()
|
||
"Open a file managed by yadm"
|
||
(interactive)
|
||
(find-file
|
||
(concat
|
||
(file-name-as-directory (getenv "HOME"))
|
||
(completing-read
|
||
"yadm files: "
|
||
(split-string
|
||
(shell-command-to-string "yadm ls-files $HOME --full-name") "\n")))))
|
||
|
||
(general-define-key
|
||
"C-c f" '(my/open-yadm-file :wk "yadm file"))
|
||
|
||
(my-leader-def
|
||
"cf" '(my/open-yadm-file :wk "yadm file"))
|
||
#+end_src
|
||
** Elfeed
|
||
:PROPERTIES:
|
||
:MODULE_NAME: elfeed
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-elfeed.el :comments links
|
||
:END:
|
||
[[https://github.com/skeeto/elfeed][elfeed]] is one of the most popular Emacs packages, and it's also one in which I ended up investing a lot of effort.
|
||
|
||
There's a lot of stuff in this section, so it's here and not in "Internet and Multimedia".
|
||
|
||
*** General settings
|
||
The advice there sets =shr-use-fonts= to nil while rendering HTML, so the =elfeed-show= buffer will use monospace font.
|
||
|
||
Using my own fork until the modifications are merged into master.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package elfeed
|
||
:straight (:repo "SqrtMinusOne/elfeed" :host github)
|
||
:if (not (or my/is-termux my/remote-server))
|
||
:commands (elfeed)
|
||
:init
|
||
(my-leader-def "ae" #'elfeed-summary)
|
||
(my/persp-add-rule
|
||
elfeed-summary-mode 0 "elfeed"
|
||
elfeed-search-mode 0 "elfeed"
|
||
elfeed-show-mode 0 "elfeed")
|
||
(setq shr-max-image-proportion 0.5)
|
||
:config
|
||
(setq elfeed-db-directory "~/.elfeed")
|
||
(setq elfeed-enclosure-default-dir (expand-file-name "~/Downloads"))
|
||
;; (advice-add #'elfeed-insert-html
|
||
;; :around
|
||
;; (lambda (fun &rest r)
|
||
;; (let ((shr-use-fonts nil))
|
||
;; (apply fun r))))
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'elfeed-search-mode-map
|
||
"o" #'my/elfeed-search-filter-source
|
||
"c" #'elfeed-search-clear-filter
|
||
"gl" (lambda () (interactive) (elfeed-search-set-filter "+later")))
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'elfeed-show-mode-map
|
||
"ge" #'my/elfeed-show-visit-eww))
|
||
#+end_src
|
||
|
||
[[https://github.com/remyhonig/elfeed-org][elfeed-org]] allows configuring Elfeed feeds with an Org file.
|
||
#+begin_src emacs-lisp
|
||
(use-package elfeed-org
|
||
:straight t
|
||
:after (elfeed)
|
||
:config
|
||
(setq rmh-elfeed-org-files '("~/.emacs.d/private.org"))
|
||
(elfeed-org))
|
||
#+end_src
|
||
*** Some additions
|
||
Filter elfeed search buffer by the feed under the cursor.
|
||
#+begin_src emacs-lisp
|
||
(defun my/elfeed-search-filter-source (entry)
|
||
"Filter elfeed search buffer by the feed under cursor."
|
||
(interactive (list (elfeed-search-selected :ignore-region)))
|
||
(when (elfeed-entry-p entry)
|
||
(elfeed-search-set-filter
|
||
(concat
|
||
"@6-months-ago "
|
||
"+unread "
|
||
"="
|
||
(replace-regexp-in-string
|
||
(rx "?" (* not-newline) eos)
|
||
""
|
||
(elfeed-feed-url (elfeed-entry-feed entry)))))))
|
||
#+end_src
|
||
|
||
Open a URL with eww.
|
||
#+begin_src emacs-lisp
|
||
(defun my/elfeed-show-visit-eww ()
|
||
"Visit the current entry in eww"
|
||
(interactive)
|
||
(let ((link (elfeed-entry-link elfeed-show-entry)))
|
||
(when link
|
||
(eww link))))
|
||
#+end_src
|
||
*** Custom faces
|
||
Setting up custom faces for certain tags to make the feed look a bit nicer.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defface elfeed-videos-entry nil
|
||
"Face for the elfeed entries with tag \"videos\"")
|
||
|
||
(defface elfeed-twitter-entry nil
|
||
"Face for the elfeed entries with tah \"twitter\"")
|
||
|
||
(defface elfeed-emacs-entry nil
|
||
"Face for the elfeed entries with tah \"emacs\"")
|
||
|
||
(defface elfeed-music-entry nil
|
||
"Face for the elfeed entries with tah \"music\"")
|
||
|
||
(defface elfeed-podcasts-entry nil
|
||
"Face for the elfeed entries with tag \"podcasts\"")
|
||
|
||
(defface elfeed-blogs-entry nil
|
||
"Face for the elfeed entries with tag \"blogs\"")
|
||
|
||
(defface elfeed-govt-entry nil
|
||
"Face for the elfeed entries with tag \"blogs\"")
|
||
|
||
(my/use-colors
|
||
(elfeed-search-tag-face :foreground (my/color-value 'yellow))
|
||
(elfeed-videos-entry :foreground (my/color-value 'red))
|
||
(elfeed-twitter-entry :foreground (my/color-value 'blue))
|
||
(elfeed-emacs-entry :foreground (my/color-value 'magenta))
|
||
(elfeed-music-entry :foreground (my/color-value 'green))
|
||
(elfeed-podcasts-entry :foreground (my/color-value 'yellow))
|
||
(elfeed-blogs-entry :foreground (my/color-value 'orange))
|
||
(elfeed-govt-entry :foreground (my/color-value 'dark-cyan)))
|
||
|
||
(with-eval-after-load 'elfeed
|
||
(setq elfeed-search-face-alist
|
||
'((podcasts elfeed-podcasts-entry)
|
||
(music elfeed-music-entry)
|
||
(gov elfeed-govt-entry)
|
||
(twitter elfeed-twitter-entry)
|
||
(videos elfeed-videos-entry)
|
||
(emacs elfeed-emacs-entry)
|
||
(blogs elfeed-blogs-entry)
|
||
(unread elfeed-search-unread-title-face))))
|
||
#+end_src
|
||
*** elfeed-summary
|
||
[[https://github.com/SqrtMinusOne/elfeed-summary][elfeed-summary]] is my package that provides a feed summary interface for elfeed.
|
||
|
||
The default interface of elfeed is just a list of all entries, so it gets hard to navigate when there are a lot of sources with varying frequencies of posts. This is my attempt to address this issue.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package elfeed-summary
|
||
:commands (elfeed-summary)
|
||
:straight t
|
||
:config
|
||
(setq elfeed-summary-filter-by-title t)
|
||
(setq elfeed-summary-skip-sync-tag 'skip))
|
||
#+end_src
|
||
*** elfeed-sync
|
||
[[https://github.com/SqrtMinusOne/elfeed-sync][elfeed-sync]] is my package to sync elfeed with [[https://tt-rss.org/][tt-rss]].
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package elfeed-sync
|
||
:straight (:host github :repo "SqrtMinusOne/elfeed-sync")
|
||
:if (not my/remote-server)
|
||
:after elfeed
|
||
:config
|
||
(elfeed-sync-mode)
|
||
(setq elfeed-sync-tt-rss-instance "https://sqrtminusone.xyz/tt-rss")
|
||
(setq elfeed-sync-tt-rss-login "sqrtminusone")
|
||
(setq elfeed-sync-tt-rss-password (my/password-store-get "Selfhosted/tt-rss")))
|
||
#+end_src
|
||
*** YouTube, podcasts & EMMS
|
||
Previously this block was opening MPV with =start-process=, but now I've managed to hook up MPV with EMMS. So there is the EMMS+elfeed "integration".
|
||
|
||
There are multiple kinds of entries that I want to be opened by EMMS. First, a function that returns a YouTube URL:
|
||
#+begin_src emacs-lisp
|
||
(defun my/get-youtube-url (entry)
|
||
(let ((watch-id (cadr
|
||
(assoc "watch?v"
|
||
(url-parse-query-string
|
||
(substring
|
||
(url-filename
|
||
(url-generic-parse-url (elfeed-entry-link entry)))
|
||
1))))))
|
||
(when watch-id
|
||
(concat "https://www.youtube.com/watch?v=" watch-id))))
|
||
#+end_src
|
||
|
||
Second, a function that returns a URL to an enclosure. This is generally how podcasts are distributed.
|
||
#+begin_src emacs-lisp
|
||
(defun my/get-enclosures-url (entry)
|
||
(caar (elfeed-entry-enclosures entry)))
|
||
#+end_src
|
||
|
||
And a package called [[https://github.com/karthink/elfeed-tube][elfeed-tube]] to fetch some additional data from YouTUbe.
|
||
#+begin_src emacs-lisp
|
||
(use-package elfeed-tube
|
||
:straight t
|
||
:after elfeed
|
||
:config
|
||
(setq elfeed-tube-auto-fetch-p nil)
|
||
(elfeed-tube-setup)
|
||
(general-define-key
|
||
:states 'normal
|
||
:keymaps '(elfeed-search-mode-map elfeed-show-mode-map)
|
||
"gf" #'elfeed-tube-fetch))
|
||
#+end_src
|
||
|
||
Now, a function to add a YouTube link with metadata from elfeed to EMMS.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'emms
|
||
(define-emms-source elfeed (entry)
|
||
(let ((url (or (my/get-enclosures-url entry)
|
||
(my/get-youtube-url entry))))
|
||
(unless url
|
||
(error "URL not found"))
|
||
(let ((track (emms-track 'url url)))
|
||
(emms-track-set track 'info-title (elfeed-entry-title entry))
|
||
(emms-playlist-insert-track track)))))
|
||
|
||
(defun my/elfeed-add-emms ()
|
||
(interactive)
|
||
(emms-add-elfeed elfeed-show-entry)
|
||
(elfeed-tag elfeed-show-entry 'watched)
|
||
(elfeed-show-refresh))
|
||
|
||
(with-eval-after-load 'elfeed
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'elfeed-show-mode-map
|
||
"gm" #'my/elfeed-add-emms))
|
||
#+end_src
|
||
*** rdrview
|
||
[[https://github.com/eafer/rdrview][rdrview]] is a command-line tool to strip webpages from clutter, extracting only parts related to the actual content. It's a standalone port of the corresponding feature of Firefox, called [[https://support.mozilla.org/en-US/kb/firefox-reader-view-clutter-free-web-pages][Reader View]].
|
||
|
||
| Guix dependency |
|
||
|-----------------|
|
||
| rdrview |
|
||
|
||
It seems like the tool [[https://repology.org/project/rdrview/versions][isn't available]] in a whole lot of package repositories, but it's pretty easy to compile. I've put together a [[https://github.com/SqrtMinusOne/channel-q/blob/master/rdrview.scm][Guix definition]], which /one day/ I'll submit to the upstream.
|
||
|
||
**** Integrating rdrview with Emacs
|
||
Let's start by integrating =rdrview= with Emacs. In the general case, we want to fetch both metadata and the actual content from the page.
|
||
|
||
However, the interface of =rdrview= is a bit awkward in this part, so we have the following options:
|
||
- call =rdrview= two times: with =-M= flag to fetch the metadata, and without the flag to fetch the HTML;
|
||
- call =rdrview= with =-T= flag to append the metadata to the resulting HTML.
|
||
|
||
I've decided to go with the second option. Here is a function that calls rdrview with the required flags:
|
||
#+begin_src emacs-lisp
|
||
(defun my/rdrview-get (url callback)
|
||
"Get the rdrview representation of URL.
|
||
|
||
Call CALLBACK with the output."
|
||
(let* ((buffer (generate-new-buffer "rdrview"))
|
||
(proc (start-process "rdrview" buffer "rdrview"
|
||
url "-T" "title,sitename,body"
|
||
"-H")))
|
||
(set-process-sentinel
|
||
proc
|
||
(lambda (process _msg)
|
||
(let ((status (process-status process))
|
||
(code (process-exit-status process)))
|
||
(cond ((and (eq status 'exit) (= code 0))
|
||
(progn
|
||
(funcall callback
|
||
(with-current-buffer (process-buffer process)
|
||
(buffer-string)))
|
||
(kill-buffer (process-buffer process))) )
|
||
((or (and (eq status 'exit) (> code 0))
|
||
(eq status 'signal))
|
||
(let ((err (with-current-buffer (process-buffer process)
|
||
(buffer-string))))
|
||
(kill-buffer (process-buffer process))
|
||
(user-error "Error in rdrview: %s" err)))))))
|
||
proc))
|
||
#+end_src
|
||
|
||
The function calls =callback= with the output of =rdrview=. This usually doesn't take long, but it's still nice to avoid freezing Emacs that way.
|
||
|
||
Now we have to parse the output. The =-T= flag puts the title in the =<h1>= tag, the site name site in the =<h2>= tag, and the content in a =<div>=. What's more, headers of the content are often shifted, e.g. the top-level header may well end up being and =<h2>= or =<h3>=, which does not look great in LaTeX.
|
||
|
||
With that said, here's a function that does the required changes:
|
||
#+begin_src emacs-lisp
|
||
(defun my/rdrview-parse (dom-string)
|
||
(let ((dom (with-temp-buffer
|
||
(insert dom-string)
|
||
(libxml-parse-html-region (point-min) (point-max)))))
|
||
(let (title sitename content (i 0))
|
||
(dolist (child (dom-children (car (dom-by-id dom "readability-page-1"))))
|
||
(when (listp child)
|
||
(cond
|
||
((eq (car child) 'h1)
|
||
(setq title (dom-text child)))
|
||
((eq (car child) 'h2)
|
||
(setq sitename (dom-text child)))
|
||
((eq (car child) 'div)
|
||
(setq content child)))))
|
||
(while (and
|
||
(not (dom-by-tag content 'h1))
|
||
(dom-search
|
||
content
|
||
(lambda (el)
|
||
(when (listp el)
|
||
(pcase (car el)
|
||
('h2 (setf (car el) 'h1))
|
||
('h3 (setf (car el) 'h2))
|
||
('h4 (setf (car el) 'h3))
|
||
('h5 (setf (car el) 'h4))
|
||
('h6 (setf (car el) 'h5))))))))
|
||
`((title . ,title)
|
||
(sitename . ,sitename)
|
||
(content . ,(with-temp-buffer
|
||
(dom-print content)
|
||
(buffer-string)))))))
|
||
#+end_src
|
||
|
||
**** Using rdrview from elfeed
|
||
Because I didn't find a smart way to advise the desired behavior into elfeed, here's a modification of the =elfeed-show-refresh--mail-style= function with two changes:
|
||
- it uses =rdrview= to fetch the HTML;
|
||
- it saves the resulting HTML into a buffer-local variable (we'll need that later).
|
||
|
||
#+begin_src emacs-lisp
|
||
(defvar-local my/elfeed-show-rdrview-html nil)
|
||
|
||
(defun my/rdrview-elfeed-show ()
|
||
(interactive)
|
||
(unless elfeed-show-entry
|
||
(user-error "No elfeed entry in this buffer!"))
|
||
(my/rdrview-get
|
||
(elfeed-entry-link elfeed-show-entry)
|
||
(lambda (result)
|
||
(let* ((data (my/rdrview-parse result))
|
||
(inhibit-read-only t)
|
||
(title (elfeed-entry-title elfeed-show-entry))
|
||
(date (seconds-to-time (elfeed-entry-date elfeed-show-entry)))
|
||
(authors (elfeed-meta elfeed-show-entry :authors))
|
||
(link (elfeed-entry-link elfeed-show-entry))
|
||
(tags (elfeed-entry-tags elfeed-show-entry))
|
||
(tagsstr (mapconcat #'symbol-name tags ", "))
|
||
(nicedate (format-time-string "%a, %e %b %Y %T %Z" date))
|
||
(content (alist-get 'content data))
|
||
(feed (elfeed-entry-feed elfeed-show-entry))
|
||
(feed-title (elfeed-feed-title feed))
|
||
(base (and feed (elfeed-compute-base (elfeed-feed-url feed)))))
|
||
(erase-buffer)
|
||
(insert (format (propertize "Title: %s\n" 'face 'message-header-name)
|
||
(propertize title 'face 'message-header-subject)))
|
||
(when elfeed-show-entry-author
|
||
(dolist (author authors)
|
||
(let ((formatted (elfeed--show-format-author author)))
|
||
(insert
|
||
(format (propertize "Author: %s\n" 'face 'message-header-name)
|
||
(propertize formatted 'face 'message-header-to))))))
|
||
(insert (format (propertize "Date: %s\n" 'face 'message-header-name)
|
||
(propertize nicedate 'face 'message-header-other)))
|
||
(insert (format (propertize "Feed: %s\n" 'face 'message-header-name)
|
||
(propertize feed-title 'face 'message-header-other)))
|
||
(when tags
|
||
(insert (format (propertize "Tags: %s\n" 'face 'message-header-name)
|
||
(propertize tagsstr 'face 'message-header-other))))
|
||
(insert (propertize "Link: " 'face 'message-header-name))
|
||
(elfeed-insert-link link link)
|
||
(insert "\n")
|
||
(cl-loop for enclosure in (elfeed-entry-enclosures elfeed-show-entry)
|
||
do (insert (propertize "Enclosure: " 'face 'message-header-name))
|
||
do (elfeed-insert-link (car enclosure))
|
||
do (insert "\n"))
|
||
(insert "\n")
|
||
(if content
|
||
(elfeed-insert-html content base)
|
||
(insert (propertize "(empty)\n" 'face 'italic)))
|
||
(setq-local my/elfeed-show-rdrview-html content)
|
||
(goto-char (point-min))))))
|
||
#+end_src
|
||
|
||
That way, calling =M-x my/rdrview-elfeed-show= replaces the original content with one from =rdrview=.
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'elfeed
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'elfeed-show-mode-map
|
||
"gp" #'my/rdrview-elfeed-show))
|
||
#+end_src
|
||
|
||
**** How well does it work?
|
||
Rather ironically, it works well with sites that already ship with proper RSS, like [[https://protesilaos.com/][Protesilaos Stavrou's]] or [[https://karthinks.com/software/simple-folding-with-hideshow/][Karthik Chikmagalur's]] blogs or [[https://www.theatlantic.com/world/][The Atlantic]] magazine.
|
||
|
||
Of my other subscriptions, it does a pretty good job with [[https://www.theverge.com/][The Verge]], which by default sends entries truncated by the words "Read the full article". For [[https://arstechnica.com/][Ars Technica]], it works only if the story is not large enough, otherwise the site returns its HTML-based pagination interface.
|
||
|
||
For paywalled sites such as [[https://www.nytimes.com/][New York Times]] or [[https://www.economist.com/][The Economist]], this usually doesn't work (by the way, what's the problem with providing individual RSS feeds for subscribers?). If you need this kind of thing, I'd suggest using the [[https://github.com/RSS-Bridge/rss-bridge][RSS-Bridge]] project. And if something is not available, contributing business logic there definitely makes more sense than implementing workarounds in Emacs Lisp.
|
||
*** LaTeX and pandoc
|
||
However, I also find that I'm not really a fan of reading articles from Emacs. Somehow what works for program code doesn't work that well for natural text. When I have to, I usually switch the Emacs theme to a light one.
|
||
|
||
But the best solution I've found so far is to render the required articles as PDFs. I may even print out some large articles I want to read.
|
||
|
||
**** Template
|
||
So first, we need a LaTeX template. Pandoc already ships with one, but I don't like it too much, so I've put up a template from my LaTeX styles, targeting my preferred XeLaTeX engine.
|
||
|
||
The code for the template is available [[file:.emacs.d/rdrview.tex][dotfiles repo]]. If you use LaTeX, you'll probably be better off using your own setup. Be sure to define the following variables:
|
||
- =main-lang= and =other-lang= for polyglossia (or remove them if you have only one language)
|
||
- =title=
|
||
- =subtitle=
|
||
- =author=
|
||
- =date=
|
||
|
||
**** Invoking pandoc
|
||
Now that we have the template, let's save it somewhere and store the path to a variable:
|
||
#+begin_src emacs-lisp
|
||
(setq my/rdrview-template (expand-file-name
|
||
(concat user-emacs-directory "rdrview.tex")))
|
||
#+end_src
|
||
|
||
And let's invoke pandoc. We need to pass the following flags:
|
||
- =--pdf-engine=xelatex=, of course
|
||
- =--template <path-to-template>=;
|
||
- =-o <path-to-pdf>=;
|
||
- =--variable key=value=.
|
||
|
||
In fact, pandoc is a pretty awesome tool in the sense that it allows for feeding custom variables to rich-language templates.
|
||
|
||
So, the rendering function is as follows:
|
||
#+begin_src emacs-lisp
|
||
(cl-defun my/rdrview-render (content type variables callback
|
||
&key file-name overwrite)
|
||
"Render CONTENT with pandoc.
|
||
|
||
TYPE is a file extension as supported by pandoc, for instance,
|
||
html or txt. VARIABLES is an alist that is fed into the
|
||
template. After the rendering is complete successfully, CALLBACK
|
||
is called with the resulting PDF.
|
||
|
||
FILE-NAME is a path to the resulting PDF. If nil it's generated
|
||
randomly.
|
||
|
||
If a file with the given FILE-NAME already exists, the function will
|
||
invoke CALLBACK straight away without doing the rendering, unless
|
||
OVERWRITE is non-nil."
|
||
(unless file-name
|
||
(setq file-name (format "/tmp/%d.pdf" (random 100000000))))
|
||
(let (params
|
||
(temp-file-name (format "/tmp/%d.%s" (random 100000000) type)))
|
||
(cl-loop for (key . value) in variables
|
||
when value
|
||
do (progn
|
||
(push "--variable" params)
|
||
(push (format "%s=%s" key value) params)))
|
||
(setq params (nreverse params))
|
||
(if (and (file-exists-p file-name) (not overwrite))
|
||
(funcall callback file-name)
|
||
(with-temp-file temp-file-name
|
||
(insert content))
|
||
(let ((proc (apply #'start-process
|
||
"pandoc" (get-buffer-create "*Pandoc*") "pandoc"
|
||
temp-file-name "-o" file-name
|
||
"--pdf-engine=xelatex" "--template" my/rdrview-template
|
||
params)))
|
||
(set-process-sentinel
|
||
proc
|
||
(lambda (process _msg)
|
||
(let ((status (process-status process))
|
||
(code (process-exit-status process)))
|
||
(cond ((and (eq status 'exit) (= code 0))
|
||
(progn
|
||
(message "Done!")
|
||
(funcall callback file-name)))
|
||
((or (and (eq status 'exit) (> code 0))
|
||
(eq status 'signal))
|
||
(user-error "Error in pandoc. Check the *Pandoc* buffer"))))))))))
|
||
#+end_src
|
||
|
||
**** Opening elfeed entries
|
||
Now we have everything required to open elfeed entries.
|
||
|
||
Also, in my case elfeed entries come in two languages, so I have to set =main-lang= and =other-lang= variables accordingly. Here's the main function:
|
||
#+begin_src emacs-lisp
|
||
(setq my/elfeed-pdf-dir (expand-file-name "~/.elfeed/pdf/"))
|
||
|
||
(defun my/elfeed-open-pdf (entry overwrite)
|
||
"Open the current elfeed ENTRY with a pdf viewer.
|
||
|
||
If OVERWRITE is non-nil, do the rendering even if the resulting
|
||
PDF already exists."
|
||
(interactive (list elfeed-show-entry current-prefix-arg))
|
||
(let ((authors (mapcar (lambda (m) (plist-get m :name)) (elfeed-meta entry :authors)))
|
||
(feed-title (elfeed-feed-title (elfeed-entry-feed entry)))
|
||
(tags (mapconcat #'symbol-name (elfeed-entry-tags entry) ", "))
|
||
(date (format-time-string "%a, %e %b %Y"
|
||
(seconds-to-time (elfeed-entry-date entry))))
|
||
(content (elfeed-deref (elfeed-entry-content entry)))
|
||
(file-name (concat my/elfeed-pdf-dir
|
||
(elfeed-ref-id (elfeed-entry-content entry))
|
||
".pdf"))
|
||
(main-language "english")
|
||
(other-language "russian"))
|
||
(unless content
|
||
(user-error "No content!"))
|
||
(setq subtitle
|
||
(cond
|
||
((seq-empty-p authors) feed-title)
|
||
((and (not (seq-empty-p (car authors)))
|
||
(string-match-p (regexp-quote (car authors)) feed-title)) feed-title)
|
||
(t (concat (string-join authors ", ") "\\\\" feed-title))))
|
||
(when (member 'ru (elfeed-entry-tags entry))
|
||
(setq main-language "russian")
|
||
(setq other-language "english"))
|
||
(my/rdrview-render
|
||
(if (bound-and-true-p my/elfeed-show-rdrview-html)
|
||
my/elfeed-show-rdrview-html
|
||
content)
|
||
(elfeed-entry-content-type entry)
|
||
`((title . ,(elfeed-entry-title entry))
|
||
(subtitle . ,subtitle)
|
||
(date . ,date)
|
||
(tags . ,tags)
|
||
(main-lang . ,main-language)
|
||
(other-lang . ,other-language))
|
||
(lambda (file-name)
|
||
(start-process "xdg-open" nil "xdg-open" file-name))
|
||
:file-name file-name
|
||
:overwrite current-prefix-arg)))
|
||
#+end_src
|
||
|
||
If the =my/elfeed-show-rdrview-html= variable is bound and true, then the content in this buffer was retrieved via =rdrview=, so we'll use that instead of the output of =elfeed-deref=.
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'elfeed
|
||
(general-define-key
|
||
:keymaps '(elfeed-show-mode-map)
|
||
:states '(normal)
|
||
"gv" #'my/elfeed-open-pdf))
|
||
#+end_src
|
||
|
||
Now we can open elfeed entries in a PDF viewer, which I find much nicer to read. Given that RSS feeds generally ship with simpler HTML than the regular websites, results usually look awesome.
|
||
|
||
**** Opening arbitrary sites
|
||
As you may have noticed, we also can display arbitrary web pages with this setup, so let's go ahead and implement that:
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/get-languages (url)
|
||
(let ((main-lang "english")
|
||
(other-lang "russian"))
|
||
(when (string-match-p (rx ".ru") url)
|
||
(setq main-lang "russian"
|
||
other-lang "english"))
|
||
(list main-lang other-lang)))
|
||
|
||
(defun my/rdrview-open (url overwrite)
|
||
(interactive
|
||
(let ((url (read-from-minibuffer
|
||
"URL: "
|
||
(if (bound-and-true-p elfeed-show-entry)
|
||
(elfeed-entry-link elfeed-show-entry)))))
|
||
(when (string-empty-p url)
|
||
(user-error "URL is empty"))
|
||
(list url current-prefix-arg)))
|
||
(my/rdrview-get
|
||
url
|
||
(lambda (res)
|
||
(let ((data (my/rdrview-parse res))
|
||
(langs (my/get-languages url)))
|
||
(my/rdrview-render
|
||
(alist-get 'content data)
|
||
'html
|
||
`((title . ,(alist-get 'title data))
|
||
(subtitle . ,(alist-get 'sitename data))
|
||
(main-lang . ,(nth 0 langs))
|
||
(other-lang . ,(nth 1 langs)))
|
||
(lambda (file-name)
|
||
(start-process "xdg-open" nil "xdg-open" file-name)))))))
|
||
#+end_src
|
||
|
||
Unfortunately, this part doesn't work that well, so we can't just uninstall Firefox or Chromium and browse the web from a PDF viewer.
|
||
|
||
The most common problem I've encountered is incorrectly formed pictures, such as =.png= files without the boundary info. I'm sure you've also come across this if you ever tried to insert a lot of Internet pictures into a LaTeX document.
|
||
|
||
However, sans the pictures issue, for certain sites like Wikipedia this is usable.
|
||
*** YouTube transcripts
|
||
**** Getting subtitles
|
||
Finally, let's get to transcripts.
|
||
|
||
| Guix dependency |
|
||
|-------------------------------|
|
||
| python-youtube-transcript-api |
|
||
|
||
In principle, the YouTube API allows for downloading subtitles, but I've found [[https://github.com/jdepoix/youtube-transcript-api][this awesome Python script]] which does the same. You can install it from =pip=, or here's mine [[https://github.com/SqrtMinusOne/channel-q/blob/master/youtube-transcript-api.scm][Guix definition]] once again.
|
||
|
||
Much like the previous cases, we need to invoke the program and save the output. The [[https://en.wikipedia.org/wiki/WebVTT][WebVTT]] format will work well enough for our purposes. Here comes the function:
|
||
#+begin_src emacs-lisp
|
||
(cl-defun my/youtube-subtitles-get (video-id callback &key file-name overwrite)
|
||
"Get subtitles for VIDEO-ID in WebVTT format.
|
||
|
||
Call CALLBACK when done.
|
||
|
||
FILE-NAME is a path to the resulting WebVTT file. If nil it's
|
||
generated randomly.
|
||
|
||
If a file with the given FILE-NAME already exists, the function will
|
||
invoke CALLBACK straight away without doing the rendering, unless
|
||
OVERWRITE is non-nil."
|
||
(interactive (list (read-string "Video ID: ")
|
||
(lambda (file-name)
|
||
(find-file file-name))
|
||
:file-name nil
|
||
:overwrite t))
|
||
(unless file-name
|
||
(setq file-name (format "/tmp/%d.vtt" (random 100000000))))
|
||
(if (and (file-exists-p file-name) (not overwrite))
|
||
(funcall callback file-name)
|
||
(let* ((buffer (generate-new-buffer "youtube-transcripts"))
|
||
(proc (start-process "youtube_transcript_api" buffer
|
||
"youtube_transcript_api" video-id
|
||
"--languages" "en" "ru" "de"
|
||
"--format" "webvtt")))
|
||
(set-process-sentinel
|
||
proc
|
||
(lambda (process _msg)
|
||
(let ((status (process-status process))
|
||
(code (process-exit-status process)))
|
||
(cond ((and (eq status 'exit) (= code 0))
|
||
(progn
|
||
(with-current-buffer (process-buffer process)
|
||
(setq buffer-file-name file-name)
|
||
(save-buffer))
|
||
(kill-buffer (process-buffer process))
|
||
(funcall callback file-name)))
|
||
((or (and (eq status 'exit) (> code 0))
|
||
(eq status 'signal))
|
||
(let ((err (with-current-buffer (process-buffer process)
|
||
(buffer-string))))
|
||
(kill-buffer (process-buffer process))
|
||
(user-error "Error in youtube_transcript_api: %s" err)))))))
|
||
proc)))
|
||
#+end_src
|
||
**** elfeed and subed
|
||
Now that we have a standalone function, let's invoke it with the current =elfeed-show-entry=:
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq my/elfeed-srt-dir (expand-file-name "~/.elfeed/srt/"))
|
||
|
||
(defun my/elfeed-youtube-subtitles (entry &optional arg)
|
||
"Get subtitles for the current elfeed ENTRY.
|
||
|
||
Works only in the entry is a YouTube video.
|
||
|
||
If ARG is non-nil, re-fetch the subtitles regardless of whether
|
||
they were fetched before."
|
||
(interactive (list elfeed-show-entry current-prefix-arg))
|
||
(let ((video-id (cadr
|
||
(assoc "watch?v"
|
||
(url-parse-query-string
|
||
(substring
|
||
(url-filename
|
||
(url-generic-parse-url (elfeed-entry-link entry)))
|
||
1))))))
|
||
(unless video-id
|
||
(user-error "Can't get video ID from the entry"))
|
||
(my/youtube-subtitles-get
|
||
video-id
|
||
(lambda (file-name)
|
||
(with-current-buffer (find-file-other-window file-name)
|
||
(setq-local elfeed-show-entry entry)
|
||
(goto-char (point-min))))
|
||
:file-name (concat my/elfeed-srt-dir
|
||
(elfeed-ref-id (elfeed-entry-content entry))
|
||
".vtt")
|
||
:overwrite arg)))
|
||
#+end_src
|
||
|
||
That opens up a =.vtt= buffer with the subtitles for the current video, which means now we can use the functionality of Sacha Chua's awesome package called [[https://github.com/sachac/subed][subed]].
|
||
|
||
This package, besides syntax highlighting, allows for controlling the MPV playback, for instance by moving the cursor in the subtitles buffer. Using that requires having the URL of the video in this buffer, which necessitates the line with =setq-local= in the previous function.
|
||
|
||
Also, the package launches its own instance of MPV to control it via JSON-IPC, so there seems to be no easy way to integrate it with EMMS. But at least I can reuse the =emms-player-mpv-parameters= variable, the method of setting which I've discussed above. The function is as follows:
|
||
#+begin_src emacs-lisp
|
||
(defun my/subed-elfeed (entry)
|
||
"Open the video file from elfeed ENTRY in MPV.
|
||
|
||
This has to be launched from inside the subtitles buffer, opened
|
||
by the `my/elfeed-youtube-subtitles' function."
|
||
(interactive (list elfeed-show-entry))
|
||
(unless entry
|
||
(user-error "No entry!"))
|
||
(unless (derived-mode-p 'subed-mode)
|
||
(user-error "Not subed mode!"))
|
||
(setq-local subed-mpv-arguments
|
||
(seq-uniq
|
||
(append subed-mpv-arguments emms-player-mpv-parameters)))
|
||
(setq-local subed-mpv-video-file (elfeed-entry-link entry))
|
||
(subed-mpv--play subed-mpv-video-file))
|
||
#+end_src
|
||
|
||
Keep in mind that this function has to be launched inside the buffer opened by the =my/elfeed-youtube-subtitles= function.
|
||
** Internet & Multimedia
|
||
*** Notmuch
|
||
:PROPERTIES:
|
||
:MODULE_NAME: mail
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-mail.el :comments links
|
||
:END:
|
||
My notmuch config now resides in [[file:Mail.org][Mail.org]].
|
||
|
||
#+begin_src emacs-lisp
|
||
(unless (or my/remote-server)
|
||
(let ((mail-file (expand-file-name "mail.el" user-emacs-directory)))
|
||
(if (file-exists-p mail-file)
|
||
(load-file mail-file)
|
||
(message "Can't load mail.el"))))
|
||
#+end_src
|
||
*** Gnus
|
||
:PROPERTIES:
|
||
:MODULE_NAME: gnus
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-gnus.el :comments links
|
||
:END:
|
||
[[https://www.gnu.org/software/emacs/manual/html_node/gnus/index.html][Gnus]] is an Emacs newsreader.
|
||
|
||
I'll try to use it for NTTP for now. Will see if I can do more with it.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package gnus
|
||
:straight t
|
||
:init
|
||
(my-leader-def "au" #'gnus)
|
||
:commands (gnus)
|
||
:config
|
||
(my/persp-add-rule
|
||
gnus-summary-mode 0 "gnus"
|
||
;; gnus-article-edit-mode 0 "gnus"
|
||
gnus-browse-mode 0 "gnus"
|
||
gnus-server-mode 0 "gnus"
|
||
gnus-article-mode 0 "gnus"
|
||
gnus-group-mode 0 "gnus"
|
||
gnus-category-mode 0 "gnus")
|
||
(let ((gnus-directory (concat user-emacs-directory "gnus")))
|
||
(unless (file-directory-p gnus-directory)
|
||
(make-directory gnus-directory))
|
||
(setq gnus-dribble-directory (concat gnus-directory "/dribble"))
|
||
(setq gnus-init-file (concat gnus-directory "/gnus.el"))
|
||
(setq gnus-startup-file (concat gnus-directory "/newsrc")))
|
||
;; Sources
|
||
(setq gnus-select-method '(nntp "news.gwene.org"))
|
||
;; Dribble
|
||
(setq gnus-always-read-dribble-file t)
|
||
;; Agent
|
||
(setq gnus-agent-article-alist-save-format 1)
|
||
(setq gnus-agent-cache t))
|
||
#+end_src
|
||
|
||
**** Groups
|
||
Toggle current topic.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/gnus-topic-toggle-topic ()
|
||
(interactive "" gnus-topic-mode)
|
||
(when (gnus-group-topic-p)
|
||
(let ((topic (gnus-topic-find-topology (gnus-current-topic))))
|
||
(if (eq (cadadr topic) 'visible)
|
||
(progn
|
||
(gnus-topic-goto-topic (gnus-current-topic))
|
||
(gnus-topic-remove-topic nil nil))
|
||
(gnus-topic-remove-topic t nil)))))
|
||
#+end_src
|
||
|
||
Custom keybindings.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'gnus-group
|
||
;; Group
|
||
(add-hook 'gnus-group-mode-hook #'gnus-topic-mode)
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps '(gnus-group-mode-map)
|
||
"a" #'gnus-group-toggle-subscription-at-point)
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps '(gnus-topic-mode-map)
|
||
"TAB" #'my/gnus-topic-toggle-topic
|
||
"r" #'gnus-topic-catchup-articles))
|
||
#+end_src
|
||
**** Summary
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'gnus-summary
|
||
(setq gnus-summary-line-format "%U%R%z%I%(%[%4L: %-23,23f%]%) %s\n")
|
||
(setq gnus-sum-thread-tree-false-root "> ")
|
||
(setq gnus-sum-thread-tree-indent " ")
|
||
(setq gnus-sum-thread-tree-single-indent " ")
|
||
(setq gnus-sum-thread-tree-leaf-with-other "+-> ")
|
||
(setq gnus-sum-thread-tree-root "> ")
|
||
(setq gnus-sum-thread-tree-single-leaf "\\-> ")
|
||
(setq gnus-sum-thread-tree-vertical "| "))
|
||
#+end_src
|
||
*** EMMS
|
||
:PROPERTIES:
|
||
:MODULE_NAME: emms
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-emms.el :comments links
|
||
:END:
|
||
EMMS is the Emacs Multi-Media System. I use it to control MPD & MPV.
|
||
|
||
References:
|
||
- [[https://www.gnu.org/software/emms/manual/][EMMS Manual]]
|
||
- [[https://www.youtube.com/watch?v=xTVN8UDScqk][Uncle Dave's video]]
|
||
|
||
#+begin_src emacs-lisp :noweb yes
|
||
(use-package emms
|
||
:straight t
|
||
:if (not (or my/remote-server my/is-termux))
|
||
:commands (emms-smart-browse
|
||
emms-browser
|
||
emms-add-url
|
||
emms-add-file
|
||
emms-add-find)
|
||
:init
|
||
(my-leader-def
|
||
:infix "as"
|
||
"" '(:which-key "emms")
|
||
"s" (my/command-in-persp "EMMS" "EMMS" 0 (emms-smart-browse))
|
||
"b" #'emms-browser
|
||
"p" #'emms-pause
|
||
"q" #'emms-stop
|
||
;; "h" #'emms-previous
|
||
;; "l" #'emms-next
|
||
"u" #'emms-player-mpd-connect
|
||
"ww" #'emms-lyrics
|
||
"wb" #'emms-lyrics-toggle-display-on-minibuffer
|
||
"wm" #'emms-lyrics-toggle-display-on-modeline
|
||
"k" #'emms-volume-raise
|
||
"j" #'emms-volume-lower)
|
||
(my/persp-add-rule
|
||
emms-browser-mode 0 "EMMS"
|
||
emms-playlist-mode 0 "EMMS")
|
||
(setq emms-mode-line-icon-enabled-p nil)
|
||
:config
|
||
(require 'emms-setup)
|
||
(require 'emms-player-mpd)
|
||
(require 'emms-player-mpv)
|
||
(emms-all)
|
||
;; MPD setup
|
||
<<emms-mpd-setup>>
|
||
;; MPV setup
|
||
<<emms-mpv-setup>>
|
||
;; evil-lion and evil-commentary shadow some gX bindings
|
||
;; (add-hook 'emms-browser-mode-hook
|
||
;; (lambda ()
|
||
;; (evil-lion-mode -1)
|
||
;; (evil-commentary-mode -1)
|
||
;; ))
|
||
;; <I've just read the line below as "I hate everything">
|
||
;; I have everything I need in polybar
|
||
(emms-mode-line-mode -1)
|
||
(emms-playing-time-display-mode -1)
|
||
<<emms-fixes>>)
|
||
#+end_src
|
||
**** MPD
|
||
:PROPERTIES:
|
||
:header-args:emacs-lisp: :tangle no :noweb-ref emms-mpd-setup
|
||
:END:
|
||
[[https://www.musicpd.org/][mpd]] is a server for playing music. It has a couple of first-class clients, including curses-based [[https://github.com/ncmpcpp/ncmpcpp][ncmpcpp]], but of course, I want to use Emacs.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq emms-source-file-default-directory (expand-file-name "~/Music/"))
|
||
(add-to-list 'emms-info-functions 'emms-info-mpd)
|
||
(add-to-list 'emms-player-list 'emms-player-mpd)
|
||
(setq emms-player-mpd-server-name "localhost")
|
||
(setq emms-player-mpd-server-port "6600")
|
||
(setq emms-player-mpd-music-directory "~/Music")
|
||
#+end_src
|
||
|
||
Connect on setup. For some reason, it stops the mpd playback whenever it connects, but it is not a big issue.
|
||
#+begin_src emacs-lisp
|
||
(emms-player-mpd-connect)
|
||
#+end_src
|
||
|
||
Clear MPD playlist on clearing EMMS playlist. IDK if this is fine for MPD library playlist, I don't use them anyhow.
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'emms-playlist-cleared-hook 'emms-player-mpd-clear)
|
||
#+end_src
|
||
|
||
Set a custom regex for MPD. EMMS sets up the default one from MPD's diagnostic output so that regex opens basically everything, including videos, https links, etc. That is fine if MPD is the only player in EMMS, but as I want to use MPV as well, I override the regex.
|
||
#+begin_src emacs-lisp
|
||
(emms-player-set emms-player-mpd
|
||
'regex
|
||
(rx (or (: "https://" (* nonl) (or "acast.com") (* nonl))
|
||
(+ (? (or "https://" "http://"))
|
||
(* nonl)
|
||
(regexp (eval (emms-player-simple-regexp
|
||
"m3u" "ogg" "flac" "mp3" "wav" "mod" "au" "aiff" "m4a")))))))
|
||
#+end_src
|
||
|
||
After all this is done, run =M-x emms-cache-set-from-mpd-all= to set cache from MPD. If everything is correct, EMMS browser will be populated with MPD database.
|
||
**** MPV
|
||
:PROPERTIES:
|
||
:header-args:emacs-lisp: :tangle no :noweb-ref emms-mpv-setup
|
||
:END:
|
||
| Guix dependency |
|
||
|-----------------|
|
||
| mpv |
|
||
| yt-dlp |
|
||
|
||
[[https://mpv.io/][mpv]] is a decent media player, which has found a place in this configuration because it integrates with +youtube-dl+ yt-dlp.
|
||
|
||
It looks like YouTube has started to throttle youtube-dl, and yt-dlp has a workaround for that particular case. Just don't forget to add the following like to the mpv config:
|
||
#+begin_src conf-unix :tangle ~/.config/mpv/mpv.conf
|
||
script-opts=ytdl_hook-ytdl_path=yt-dlp
|
||
#+end_src
|
||
|
||
It seems a bit strange to keep the MPV config in this file, but I don't use the program outside Emacs.
|
||
|
||
#+begin_src emacs-lisp
|
||
(add-to-list 'emms-player-list 'emms-player-mpv t)
|
||
#+end_src
|
||
|
||
Also a custom regex. My demands for MPV include running =yt-dlp=, so there is a regex that matches youtube.com or some of the video formats.
|
||
#+begin_src emacs-lisp
|
||
(emms-player-set emms-player-mpv
|
||
'regex
|
||
(rx (or (: "https://" (* nonl) "youtube.com" (* nonl))
|
||
(+ (? (or "https://" "http://"))
|
||
(* nonl)
|
||
(regexp (eval (emms-player-simple-regexp
|
||
"mp4" "mov" "wmv" "webm" "flv" "avi" "mkv")))))))
|
||
#+end_src
|
||
|
||
By default, MPV plays the video in the best possible quality, which may be pretty high, even too high with limited bandwidth. So here is the logic to choose the quality.
|
||
#+begin_src emacs-lisp
|
||
(setq my/youtube-dl-quality-list
|
||
'("bestvideo[height<=720]+bestaudio/best[height<=720]"
|
||
"bestvideo[height<=480]+bestaudio/best[height<=480]"
|
||
"bestvideo[height<=1080]+bestaudio/best[height<=1080]"))
|
||
|
||
(setq my/default-emms-player-mpv-parameters
|
||
'("--quiet" "--really-quiet" "--no-audio-display"))
|
||
|
||
(defun my/set-emms-mpd-youtube-quality (quality)
|
||
(interactive "P")
|
||
(unless quality
|
||
(setq quality (completing-read "Quality: " my/youtube-dl-quality-list nil t)))
|
||
(setq emms-player-mpv-parameters
|
||
`(,@my/default-emms-player-mpv-parameters ,(format "--ytdl-format=%s" quality))))
|
||
|
||
(my/set-emms-mpd-youtube-quality (car my/youtube-dl-quality-list))
|
||
#+end_src
|
||
|
||
Now =emms-add-url= should work on YouTube URLs just fine. Just keep in mind that it will only add the URL to the playlist, not play it right away.
|
||
**** Cache cleanup
|
||
All the added URLs reside in the EMMS cache after being played. I don't want them to stay there for a long time, so here is a handy function to clean it.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/emms-cleanup-urls ()
|
||
(interactive)
|
||
(let ((keys-to-delete '()))
|
||
(maphash (lambda (key value)
|
||
(when (eq (cdr (assoc 'type value)) 'url)
|
||
(add-to-list 'keys-to-delete key)))
|
||
emms-cache-db)
|
||
(dolist (key keys-to-delete)
|
||
(remhash key emms-cache-db)))
|
||
(setq emms-cache-dirty t))
|
||
|
||
(my-leader-def "asc" #'my/emms-cleanup-urls)
|
||
#+end_src
|
||
**** Fetching lyrics
|
||
My package for fetching EMMS lyrics and album covers.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package lyrics-fetcher
|
||
:straight t
|
||
:after (emms)
|
||
:init
|
||
(my-leader-def
|
||
"ast" #'lyrics-fetcher-show-lyrics
|
||
"asT" #'lyrics-fetcher-show-lyrics-query)
|
||
:config
|
||
(setq lyrics-fetcher-genius-access-token
|
||
(my/password-store-get "My_Online/APIs/genius.com"))
|
||
(general-define-key
|
||
:states '(emacs normal)
|
||
:keymaps 'emms-browser-mode-map
|
||
"gr" #'emms-browse-by-artist
|
||
"gl" 'lyrics-fetcher-emms-browser-show-at-point
|
||
"gC" 'lyrics-fetcher-emms-browser-fetch-covers-at-point
|
||
"go" 'lyrics-fetcher-emms-browser-open-large-cover-at-point)
|
||
|
||
(advice-add #'emms-lyrics-mode-line
|
||
:override #'my/emms-lyrics-mode-line-override))
|
||
#+end_src
|
||
|
||
Also advice to change the location of the lyrics in the mode line.
|
||
#+begin_src emacs-lisp
|
||
(defun my/emms-lyrics-mode-line-override ()
|
||
(add-to-list 'global-mode-string
|
||
'(:eval emms-lyrics-mode-line-string)))
|
||
|
||
(defun my/emms-lyrics-restore-mode-line-override ()
|
||
"Restore the mode line."
|
||
(setq global-mode-string
|
||
(remove '(:eval emms-lyrics-mode-line-string) global-mode-string))
|
||
(force-mode-line-update))
|
||
|
||
(with-eval-after-load 'emms-lyrics
|
||
(advice-add #'emms-lyrics-mode-line
|
||
:override #'my/emms-lyrics-mode-line-override)
|
||
(advice-add #'emms-lyrics-restore-mode-line
|
||
:override #'my/emms-lyrics-restore-mode-line-override))
|
||
#+end_src
|
||
**** Some keybindings
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'emms-browser
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'emms-browser-mode-map
|
||
"q" 'quit-window))
|
||
|
||
(with-eval-after-load 'emms
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'emms-playlist-mode-map
|
||
"q" 'quit-window))
|
||
#+end_src
|
||
**** Setting volume
|
||
#+begin_src emacs-lisp
|
||
(defun my/set-volume (value)
|
||
(start-process "ponymix" nil "ponymix"
|
||
(if (< 0 value) "increase" "decrease")
|
||
(number-to-string (abs value))
|
||
"--max-volume" "150"))
|
||
|
||
(setq emms-volume-change-function #'my/set-volume)
|
||
(setq emms-volume-change-amount 5)
|
||
#+end_src
|
||
**** EMMS & mpd Fixes
|
||
+Some fixes until I submit a patch.+ I've submitted a patch for with these fixes, so I'll remove this section eventually.
|
||
|
||
For some reason EMMS doesn't fetch =albumartist= from MPD. Overriding this function fixes that.
|
||
|
||
#+begin_src emacs-lisp :tangle no :noweb-ref emms-fixes
|
||
(defun emms-info-mpd-process (track info)
|
||
(dolist (data info)
|
||
(let ((name (car data))
|
||
(value (cdr data)))
|
||
(setq name (cond ((string= name "artist") 'info-artist)
|
||
((string= name "albumartist") 'info-albumartist)
|
||
((string= name "composer") 'info-composer)
|
||
((string= name "performer") 'info-performer)
|
||
((string= name "title") 'info-title)
|
||
((string= name "album") 'info-album)
|
||
((string= name "track") 'info-tracknumber)
|
||
((string= name "disc") 'info-discnumber)
|
||
((string= name "date") 'info-year)
|
||
((string= name "genre") 'info-genre)
|
||
((string= name "time")
|
||
(setq value (string-to-number value))
|
||
'info-playing-time)
|
||
(t nil)))
|
||
(when name
|
||
(emms-track-set track name value)))))
|
||
#+end_src
|
||
|
||
Also, =emms-player-mpd-get-alists= has an interesting bug. This function parses the response to =listallinfo=, which looks something like this:
|
||
#+begin_example
|
||
tag1: value1
|
||
tag2: value2
|
||
...
|
||
tag1: value1'
|
||
tag2: value2'
|
||
#+end_example
|
||
|
||
This structure has to be converted to list of alists, which looks like:
|
||
#+begin_example
|
||
(("tag1" . "value1"
|
||
"tag2" . "value2")
|
||
("tag1" . "value1'"
|
||
("tag2" . "value2'")))
|
||
#+end_example
|
||
|
||
The original implementation creates a new alist whenever it encounters a tag it has already put in the current alist. Which doesn't work too well if some tags don't repeat, if the order is messed up, etc.
|
||
|
||
Fortunately, according to the [[https://mpd.readthedocs.io/en/latest/protocol.html#command-lsinfo][protocol specification]], each new record has to start with =file=, =directory= or =playlist=. I've overridden the function with that in mind and it fixed the import, at least for my case.
|
||
|
||
#+begin_src emacs-lisp :tangle no :noweb-ref emms-fixes
|
||
(defun emms-player-mpd-get-alists (info)
|
||
"Turn the given parsed INFO from MusicPD into an list of alists.
|
||
|
||
The list will be in reverse order."
|
||
(when (and info
|
||
(null (car info)) ; no error has occurred
|
||
(cdr info)) ; data exists
|
||
(let ((alists nil)
|
||
(alist nil)
|
||
cell)
|
||
(dolist (line (cdr info))
|
||
(when (setq cell (emms-player-mpd-parse-line line))
|
||
(if (member (car cell) '("file" "directory" "playlist"))
|
||
(setq alists (cons alist alists)
|
||
alist (list cell))
|
||
(setq alist (cons cell alist)))))
|
||
(when alist
|
||
(setq alists (cons alist alists)))
|
||
alists)))
|
||
#+end_src
|
||
*** EWW
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-internet
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-internet.el :comments links
|
||
:END:
|
||
Emacs built-in web browser. +I wonder if anyone actually uses it.+
|
||
|
||
I use it occasionally to open links in elfeed.
|
||
|
||
Toggle using fonts in buffer:
|
||
#+begin_src emacs-lisp
|
||
(defun my/toggle-shr-use-fonts ()
|
||
"Toggle the shr-use-fonts variable in buffer"
|
||
(interactive)
|
||
(setq-local shr-use-fonts (not shr-use-fonts)))
|
||
#+end_src
|
||
|
||
Setting the default font.
|
||
#+begin_src emacs-lisp
|
||
(defface my/shr-face
|
||
`((t :inherit variable-pitch))
|
||
"Default face for shr rendering.")
|
||
|
||
(my/use-colors
|
||
(my/shr-face :foreground (my/color-value 'blue)))
|
||
|
||
(defun my/shr-insert-around (fun &rest args)
|
||
(let ((shr-current-font (or shr-current-font 'my/shr-face)))
|
||
(apply fun args)))
|
||
|
||
(defun my/shr-urlify-around (fun start url &optional title)
|
||
(funcall fun start url title)
|
||
(let ((faces (get-text-property start 'face)))
|
||
(put-text-property
|
||
start (point)
|
||
'face
|
||
(mapcar
|
||
(lambda (face)
|
||
(if (eq face 'my/shr-face)
|
||
'link
|
||
face))
|
||
(if (sequencep faces) faces (list faces))))))
|
||
|
||
(with-eval-after-load 'shr
|
||
(advice-add #'shr-insert :around #'my/shr-insert-around)
|
||
(advice-add #'shr-urlify :around #'my/shr-urlify-around))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(my-leader-def "aw" 'eww)
|
||
(my/persp-add-rule
|
||
eww-mode 2 "browser")
|
||
|
||
(with-eval-after-load 'eww
|
||
(general-define-key
|
||
:keymaps '(eww-mode-map)
|
||
:states '(normal emacs)
|
||
"f" #'ace-link-eww
|
||
"+" 'text-scale-increase
|
||
"-" 'text-scale-decrease))
|
||
#+end_src
|
||
*** ERC
|
||
:PROPERTIES:
|
||
:MODULE_NAME: erc
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-erc.el :comments links
|
||
:END:
|
||
ERC is a built-it Emacs IRC client.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package erc
|
||
:commands (erc erc-tls)
|
||
:straight (:type built-in)
|
||
:config
|
||
(setq erc-log-channels-directory "~/.erc/logs")
|
||
(setq erc-save-buffer-on-part t)
|
||
(add-to-list 'erc-modules 'autojoin)
|
||
(add-to-list 'erc-modules 'notifications)
|
||
(add-to-list 'erc-modules 'log)
|
||
(erc-update-modules)
|
||
(setq erc-autojoin-channels-alist
|
||
`((,(rx "libera.chat")
|
||
"#systemcrafters" "#systemcrafters-emacs")))
|
||
(setq erc-kill-buffer-on-part t)
|
||
(setq erc-track-shorten-start 8))
|
||
#+end_src
|
||
|
||
Exclude everything but actual messages from notifications.
|
||
#+begin_src emacs-lisp
|
||
(setq erc-track-exclude-types '("NICK" "JOIN" "LEAVE" "QUIT" "PART"
|
||
"301" ; away notice
|
||
"305" ; return from awayness
|
||
"306" ; set awayness
|
||
"324" ; modes
|
||
"329" ; channel creation date
|
||
"332" ; topic notice
|
||
"333" ; who set the topic
|
||
"353" ; Names notice
|
||
))
|
||
#+end_src
|
||
|
||
A plugin to highlight IRC nicknames:
|
||
#+begin_src emacs-lisp
|
||
(use-package erc-hl-nicks
|
||
:hook (erc-mode . erc-hl-nicks-mode)
|
||
:after (erc)
|
||
:straight t)
|
||
#+end_src
|
||
|
||
ZNC support. Seems to provide a few nice features for ZNC.
|
||
#+begin_src emacs-lisp
|
||
(use-package znc
|
||
:straight t
|
||
:commands (znc-erc)
|
||
:init
|
||
;; (my-leader-def "ai" #'znc-erc)
|
||
(my/persp-add-rule
|
||
erc-mode 3 "ERC")
|
||
:config
|
||
(setq znc-servers
|
||
`(("sqrtminusone.xyz" 6697 t
|
||
((libera "sqrtminusone"
|
||
,(my/password-store-get "Selfhosted/ZNC")))))))
|
||
#+end_src
|
||
|
||
Send =/detach= to all servers. Kinda strange that there's no such function already
|
||
#+begin_src emacs-lisp
|
||
(defun my/erc-detach-all ()
|
||
(interactive)
|
||
(cl-loop for buf being the buffers
|
||
if (eq (buffer-local-value 'major-mode buf) 'erc-mode)
|
||
do (with-current-buffer buf
|
||
(when (erc-server-process-alive)
|
||
(let ((tgt (erc-default-target)))
|
||
(erc-server-send (format "DETACH %s" tgt) nil tgt))))))
|
||
|
||
#+end_src
|
||
*** Mastodon
|
||
:PROPERTIES:
|
||
:MODULE_NAME: mastodon
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-mastodon.el :comments links
|
||
:END:
|
||
Mastodon is a decentralized social media network. I use an instance called [[https://emacs.ch/][emacs.ch]].
|
||
|
||
**** Package configuration
|
||
[[https://codeberg.org/martianh/mastodon.el][mastodon.el]] is an Emacs client for Mastodon.
|
||
|
||
The default UI is rather rough, but Nicolas Rougier's [[https://github.com/rougier/mastodon-alt][mastodon-alt]] package makes things a bit more how I would like to see them.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package mastodon
|
||
:straight t
|
||
:commands (my/mastodon)
|
||
:init
|
||
(my-leader-def "an" #'my/mastodon)
|
||
:config
|
||
(setq mastodon-instance-url "https://mastodon.bsd.cafe")
|
||
(setq mastodon-active-user "sqrtminusone")
|
||
(my/persp-add-rule mastodon-mode 0 "mastodon")
|
||
;; Hide spoilers by default
|
||
;; (setq-default mastodon-toot--content-warning nil)
|
||
(setq mastodon-media--avatar-height 40)
|
||
(setq mastodon-tl--timeline-posts-count "40")
|
||
(setq mastodon-tl--show-avatars t)
|
||
(setq mastodon-tl--horiz-bar
|
||
(make-string shr-max-width
|
||
(if (char-displayable-p ?―) ?― ?-)))
|
||
;; The default emojis take two characters for me
|
||
(mapcar (lambda (item)
|
||
(setf (alist-get (car item) mastodon-tl--symbols)
|
||
(cdr item)))
|
||
'((reply "" . "R")
|
||
(boost "" . "B")
|
||
(favourite "" . "F")
|
||
(bookmark "" . "K")
|
||
(media "" . "[media]")
|
||
(verified "" . "V")
|
||
(locked "" . "[locked]")
|
||
(private "" . "[followers]")
|
||
(direct "" . "[direct]")
|
||
(edited "" . "[edited]"))))
|
||
|
||
(use-package mastodon-alt
|
||
:straight (:host github :repo "rougier/mastodon-alt")
|
||
:disabled t
|
||
:after (mastodon)
|
||
:config
|
||
(mastodon-alt-tl-activate))
|
||
|
||
(use-package transient
|
||
:straight t
|
||
:defer t)
|
||
#+end_src
|
||
|
||
Compatibility with termux.
|
||
#+begin_src emacs-lisp
|
||
(unless (display-graphic-p)
|
||
(defun image-transforms-p () nil)
|
||
(setq image-types '(svg png gif tiff jpeg xpm xbm pbm)))
|
||
#+end_src
|
||
|
||
**** UI and keymaps
|
||
=display-line-numbers-mode= screws the UI for some reason.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/mastodon-configure ()
|
||
(display-line-numbers-mode -1))
|
||
|
||
(add-hook 'mastodon-mode-hook #'my/mastodon-configure)
|
||
#+end_src
|
||
|
||
Kill processes. Useful when the package stops working due to unstable connection.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/mastodon-reset ()
|
||
(interactive)
|
||
(cl-loop for process in (process-list)
|
||
if (string-match-p "emacs.ch" (process-name process))
|
||
do (delete-process process)))
|
||
#+end_src
|
||
|
||
The package also doesn't have evil bindings. I implement a few basic bindings here:
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'mastodon
|
||
(general-define-key
|
||
:states '(normal motion)
|
||
:keymaps '(mastodon-mode-map)
|
||
"J" #'mastodon-tl-goto-next-item
|
||
"K" #'mastodon-tl-goto-prev-item
|
||
"M-j" #'mastodon-tl-next-tab-item
|
||
"M-k" #'mastodon-tl-prev-tab-item
|
||
"<tab>" #'mastodon-tl-next-tab-item
|
||
"<backtab>" #'mastodon-tl-previous-tab-item
|
||
"o" #'my/mastodon-toot
|
||
"r" 'mastodon-tl-update
|
||
"c" #'mastodon-tl-toggle-spoiler-text-in-toot
|
||
"q" #'kill-current-buffer))
|
||
#+end_src
|
||
**** Modeline segment
|
||
This is my attempt to make a modeline indicator for new mastodon notifications.
|
||
|
||
Edit [2023-07-28 Fri]: I'll probably remove that, don't feel like it's actually useful.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defvar my/mastodon-mode-string "")
|
||
|
||
(defvar my/mastodon-mode-line-unread-ids nil)
|
||
|
||
(defvar my/mastodon-mode-line-saved-ids nil)
|
||
|
||
(defvar my/mastodon-mode-line-timer nil)
|
||
|
||
(defvar my/mastodon-mode-line-file
|
||
(concat no-littering-var-directory "mastodon/notif-ids"))
|
||
|
||
(defun my/mastodon-mode-line-load-meta ()
|
||
(when (file-exists-p my/mastodon-mode-line-file)
|
||
(ignore-errors
|
||
(with-temp-buffer
|
||
(insert-file-contents my/mastodon-mode-line-file)
|
||
(setq my/mastodon-mode-line-saved-ids
|
||
(read (current-buffer)))))))
|
||
|
||
(defun my/mastodon-mode-line-persist-meta ()
|
||
(mkdir (file-name-directory my/mastodon-mode-line-file) t)
|
||
(let ((coding-system-for-write 'utf-8))
|
||
(ignore-errors
|
||
(with-temp-file my/mastodon-mode-line-file
|
||
(let ((standard-output (current-buffer))
|
||
(print-level nil)
|
||
(print-length nil)
|
||
(print-circle nil))
|
||
(princ ";;; Mastodon Saved Notifications\n\n")
|
||
(prin1 my/mastodon-mode-line-saved-ids))))))
|
||
|
||
(defun my/mastodon-mode-line-update ()
|
||
(if my/mastodon-mode-line-unread-ids
|
||
(setq my/mastodon-mode-string
|
||
(concat "["
|
||
(propertize (number-to-string
|
||
(length my/mastodon-mode-line-unread-ids))
|
||
'face 'success)
|
||
"]"))
|
||
(setq my/mastodon-mode-string "")))
|
||
|
||
(defun my/mastodon-mode-line-update-fetch ()
|
||
(mastodon-http--get-json-async
|
||
(mastodon-http--api "notifications") nil
|
||
(lambda (data)
|
||
(let ((fetched-ids
|
||
(cl-loop for datum in data collect (alist-get 'id datum))))
|
||
(setq my/mastodon-mode-line-unread-ids
|
||
(seq-difference fetched-ids my/mastodon-mode-line-saved-ids))
|
||
(setq my/mastodon-mode-line-saved-ids
|
||
(seq-intersection my/mastodon-mode-line-saved-ids fetched-ids)))
|
||
(my/mastodon-mode-line-update))))
|
||
|
||
(defun my/mastodon-notifications--timeline-before (toots)
|
||
(let* ((all-ids (seq-uniq
|
||
(append
|
||
my/mastodon-mode-line-saved-ids
|
||
(cl-loop for datum in toots
|
||
collect (alist-get 'id datum))))))
|
||
(setq my/mastodon-mode-line-unread-ids
|
||
(seq-difference my/mastodon-mode-line-unread-ids all-ids))
|
||
(setq my/mastodon-mode-line-saved-ids all-ids))
|
||
(my/mastodon-mode-line-update))
|
||
|
||
(with-eval-after-load 'mastodon
|
||
(define-minor-mode my/mastodon-mode-line
|
||
"Display mastodon notification count in mode line."
|
||
:require 'mastodon
|
||
:global t
|
||
:group 'mastodon
|
||
:after-hook
|
||
(progn
|
||
(when (timerp my/mastodon-mode-line-timer)
|
||
(cancel-timer my/mastodon-mode-line-timer))
|
||
(if my/mastodon-mode-line
|
||
(progn
|
||
(add-to-list 'mode-line-misc-info '(:eval my/mastodon-mode-string) t)
|
||
(my/mastodon-mode-line-load-meta)
|
||
(setq my/mastodon-mode-line-timer
|
||
(run-with-timer 0 150 #'my/mastodon-mode-line-update-fetch))
|
||
(advice-add #'mastodon-notifications--timeline :before
|
||
#'my/mastodon-notifications--timeline-before)
|
||
(add-hook 'kill-emacs-hook #'my/mastodon-mode-line-persist-meta))
|
||
(setq mode-line-misc-info (delete '(:eval my/mastodon-mode-string)
|
||
mode-line-misc-info))
|
||
(advice-remove #'mastodon-notifications--timeline
|
||
#'my/mastodon-notifications--timeline-before)
|
||
(remove-hook 'kill-emacs-hook #'my/mastodon-mode-line-persist-meta)
|
||
(my/mastodon-mode-line-persist-meta)))))
|
||
#+end_src
|
||
**** Timeline Transient
|
||
The default =mastodon-tl--get-home-timeline= allows only to hide replies, and not boosted posts.
|
||
|
||
So here's a custom update function:
|
||
#+begin_src emacs-lisp
|
||
(defun my/mastodon-get-update-funciton (hide-replies hide-boosts)
|
||
(lambda (toots)
|
||
(let* ((is-profile (eq (mastodon-tl--get-buffer-type) 'profile-statuses))
|
||
(hide-replies (and (not is-profile) hide-replies))
|
||
(hide-boosts (and (not is-profile) hide-boosts))
|
||
(toots (seq-filter
|
||
(lambda (toot)
|
||
(and
|
||
(or (not hide-replies)
|
||
(not (mastodon-tl--is-reply toot)))
|
||
(or (not hide-boosts)
|
||
(not (alist-get 'reblog toot)))))
|
||
toots))
|
||
(start-pos (point)))
|
||
(mapc #'mastodon-tl--toot toots)
|
||
(when mastodon-tl--display-media-p
|
||
(save-excursion
|
||
(mastodon-media--inline-images start-pos (point)))))))
|
||
#+end_src
|
||
|
||
In order to use it, the function has to be passed to =mastodon-tl--init=:
|
||
#+begin_src emacs-lisp
|
||
(defun my/mastodon-tl--get-home (hide-replies hide-boosts)
|
||
(mastodon-tl--init
|
||
"home"
|
||
"timelines/home"
|
||
(my/mastodon-get-update-funciton hide-replies hide-boosts)
|
||
nil
|
||
`(("limit" . ,mastodon-tl--timeline-posts-count))
|
||
nil))
|
||
#+end_src
|
||
|
||
And a transient to use it.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'mastodon
|
||
(require 'transient)
|
||
(transient-define-prefix my/mastodon-tl ()
|
||
["Home timeline params"
|
||
("-r" "--hide-replies" "--hide-replies" :init-value
|
||
(lambda (obj) (oset obj value "--hide-replies")))
|
||
("-b" "--hide-boosts" "--hide-boosts" :init-value
|
||
(lambda (obj) (oset obj value "--hide-boosts")))]
|
||
["Timelines"
|
||
:class transient-row
|
||
("t" "Home" (lambda (args)
|
||
(interactive (list (transient-args transient-current-command)))
|
||
(my/mastodon-tl--get-home
|
||
(seq-contains-p args "--hide-replies")
|
||
(seq-contains-p args "--hide-boosts"))))
|
||
("l" "Local" mastodon-tl-get-local-timeline)
|
||
("f" "Federated" mastodon-tl-get-federated-timeline)
|
||
("g" "One tag" mastodon-tl-get-tag-timeline)
|
||
("a" "Followed tags" mastodon-tl-followed-tags-timeline)
|
||
("s" "Some followed tags" mastodon-tl-some-followed-tags-timeline)]
|
||
["Misc"
|
||
:class transient-row
|
||
("q" "Quit" transient-quit-one)]))
|
||
#+end_src
|
||
**** Main Transient
|
||
Also, there are so many commands that it's hard to remember all of them. So I define two transient prefixes.
|
||
|
||
The first dispatches "general" actions:
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'mastodon
|
||
(require 'transient)
|
||
(transient-define-prefix my/mastodon ()
|
||
"Mastodon."
|
||
["Various views"
|
||
:class transient-row
|
||
("m" "Mastodon" mastodon)
|
||
("t" "Timelines" my/mastodon-tl)
|
||
("n" "Notifications" mastodon-notifications-get)
|
||
("s" "Search query" mastodon-search-query)]
|
||
["Tags"
|
||
:class transient-row
|
||
("aa" "Followed tags" mastodon-tl-list-followed-tags)
|
||
("af" "Follow tag" mastodon-tl-follow-tag)
|
||
("aF" "Unfollow tag" mastodon-tl-unfollow-tag)]
|
||
["Own profile"
|
||
:class transient-row
|
||
("c" "Toot" mastodon-toot)
|
||
("o" "My profile" mastodon-profile-my-profile)
|
||
("u" "Update profile note" mastodon-profile-update-user-profile-note)
|
||
("f" "Favourites" mastodon-profile-view-favourites)
|
||
("b" "Bookmarks" mastodon-profile-view-bookmarks)]
|
||
["Minor views"
|
||
:class transient-row
|
||
("F" "Follow requests" mastodon-views-view-follow-requests)
|
||
("S" "Scheduled toots" mastodon-views-view-scheduled-toots)
|
||
("I" "Filters" mastodon-views-view-filters)
|
||
("G" "Follow suggestions" mastodon-views-view-follow-suggestions)
|
||
("L" "Lists" mastodon-views-view-lists)]
|
||
["Misc"
|
||
:class transient-row
|
||
("/" "Switch to buffer" mastodon-switch-to-buffer)
|
||
("Q" "Kill all buffers" mastodon-kill-all-buffers)
|
||
("q" "Quit" transient-quit-one)]))
|
||
#+end_src
|
||
**** Toot Transient
|
||
And the second one dispatches actions related to particular toot / profile.
|
||
|
||
Also, some actions don't have any confirmations, so here's a macro that wraps a function with =y-or-n-p=:
|
||
#+begin_src emacs-lisp
|
||
(defmacro my/def-confirmer (func text)
|
||
`(defun ,(intern (concat "my/" (symbol-name func) "-confirm")) ()
|
||
(interactive)
|
||
(when (y-or-n-p ,text)
|
||
(call-interactively #',func))))
|
||
#+end_src
|
||
|
||
A function to open the toot in browser:
|
||
#+begin_src emacs-lisp
|
||
(defun my/mastodon-toot--browse ()
|
||
"Copy URL of toot at point.
|
||
If the toot is a fave/boost notification, copy the URLof the
|
||
base toot."
|
||
(interactive)
|
||
(let* ((toot (or (mastodon-tl--property 'base-toot)
|
||
(mastodon-tl--property 'toot-json)))
|
||
(url (if (mastodon-tl--field 'reblog toot)
|
||
(alist-get 'url (alist-get 'reblog toot))
|
||
(alist-get 'url toot))))
|
||
(browse-url url)))
|
||
#+end_src
|
||
|
||
And the prefix itself:
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'mastodon
|
||
(my/def-confirmer mastodon-toot-toggle-boost "Toggle boost for this post? ")
|
||
(my/def-confirmer mastodon-toot-toggle-favourite "Toggle favourite this post? ")
|
||
(my/def-confirmer mastodon-toot-toggle-bookmark "Toggle bookmark this post? ")
|
||
(my/def-confirmer mastodon-tl-follow-user "Follow this user? ")
|
||
(my/def-confirmer mastodon-tl-unfollow-user "Unfollow this user? ")
|
||
(my/def-confirmer mastodon-tl-block-user "Block this user? ")
|
||
(my/def-confirmer mastodon-tl-unblock-user "Unblock this user? ")
|
||
(my/def-confirmer mastodon-tl-mute-user "Mute this user? ")
|
||
(my/def-confirmer mastodon-tl-unmute-user "Unmute this user? ")
|
||
(my/def-confirmer mastodon-tl-unmute-user "Unmute this user? ")
|
||
|
||
(transient-define-prefix my/mastodon-toot ()
|
||
"Mastodon toot actions."
|
||
["View"
|
||
:class transient-row
|
||
("o" "Thread" mastodon-tl-thread)
|
||
("w" "Browser" my/mastodon-toot--browse)
|
||
("le" "List edits" mastodon-toot-view-toot-edits)
|
||
("lf" "List favouriters" mastodon-toot-list-favouriters)
|
||
("lb" "List boosters" mastodon-toot-list-boosters)]
|
||
["Toot Actions"
|
||
:class transient-row
|
||
("r" "Reply" mastodon-toot-reply)
|
||
("v" "Vote" mastodon-tl-poll-vote)
|
||
("b" "Boost" my/mastodon-toot-toggle-boost-confirm)
|
||
("f" "Favourite" my/mastodon-toot-toggle-favourite-confirm)
|
||
("k" "Bookmark" my/mastodon-toot-toggle-bookmark-confirm)]
|
||
["My Toot Actions"
|
||
:class transient-row
|
||
("md" "Delete" mastodon-toot-delete-toot)
|
||
("mD" "Delete and redraft" mastodon-toot-delete-and-redraft-toot)
|
||
("mp" "Pin" mastodon-toot-pin-toot-toggle)
|
||
("me" "Edit" mastodon-toot-edit-toot-at-point)]
|
||
["Profile Actions"
|
||
:class transient-row
|
||
("pp" "Profile" mastodon-profile-show-user)
|
||
("pf" "List followers" mastodon-profile-open-followers)
|
||
("pF" "List following" mastodon-profile-open-following)
|
||
("ps" "List statues (no reblogs)" mastodon-profile-open-statuses-no-reblogs)]
|
||
["User Actions"
|
||
:class transient-row
|
||
("uf" "Follow user" my/mastodon-tl-follow-user-confirm)
|
||
("uF" "Unfollow user" my/mastodon-tl-unfollow-user-confirm)
|
||
("ub" "Block user" my/mastodon-tl-block-user-confirm)
|
||
("uB" "Unblock user" my/mastodon-tl-unblock-user-confirm)
|
||
("um" "Mute user" my/mastodon-tl-mute-user-confirm)
|
||
("uB" "Unmute user" my/mastodon-tl-unmute-user-confirm)]
|
||
["Misc"
|
||
:class transient-row
|
||
("q" "Quit" transient-quit-one)]))
|
||
#+end_src
|
||
*** wallabag
|
||
:PROPERTIES:
|
||
:MODULE_NAME: wallabag
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-wallabag.el :comments links
|
||
:END:
|
||
[[https://github.com/wallabag/wallabag][wallabag]] is a self-hosted "read it later" app.
|
||
|
||
This might be the best online reading advice I've heard:
|
||
|
||
#+begin_quote
|
||
I have a different approach: waiting periods. Every time I come across something I may want to read/watch, I’m totally allowed to. No limits! The only requirement is I have to save it to Pocket, and then choose to consume it at a later time.
|
||
#+end_quote
|
||
Source: [[https://fortelabs.com/blog/the-secret-power-of-read-it-later-apps/][Tiago Forte - The Secret Power of ‘Read It Later’ Apps]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package wallabag
|
||
:straight (:host github :repo "chenyanming/wallabag.el" :files (:defaults "default.css" "emojis.alist"))
|
||
:init
|
||
(my-leader-def "aE" #'wallabag)
|
||
:commands (wallabag wallabag-add-entry)
|
||
:config
|
||
(setq wallabag-host "https://wallabag.sqrtminusone.xyz")
|
||
(setq wallabag-username "sqrtminusone")
|
||
(setq wallabag-password (my/password-store-get "Selfhosted/wallabag"))
|
||
(setq wallabag-clientid (password-store-get-field "Selfhosted/wallabag" "client_id"))
|
||
(setq wallabag-secret (password-store-get-field "Selfhosted/wallabag" "client_secret")))
|
||
#+end_src
|
||
|
||
*** ement.el
|
||
:PROPERTIES:
|
||
:MODULE_NAME: ement
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-ement.el :comments links
|
||
:END:
|
||
[[https://github.com/alphapapa/ement.el][ement.el]] is a Matrix client for Emacs. This package turned out to be somewhat complicated to setup.
|
||
|
||
**** General config
|
||
#+begin_src emacs-lisp
|
||
(use-package plz
|
||
:straight (:host github :repo "alphapapa/plz.el")
|
||
:defer t)
|
||
|
||
(defun my/ement ()
|
||
(interactive)
|
||
(ement-connect
|
||
:user-id "@sqrtminusone:matrix.org"
|
||
:password (my/password-store-get "My_Online/Accounts/matrix")))
|
||
|
||
(use-package ement
|
||
:straight (:host github :repo "alphapapa/ement.el")
|
||
:commands (ement-connect)
|
||
:init
|
||
(my-leader-def "ax" #'my/ement)
|
||
:config
|
||
(setq ement-room-list-auto-update t)
|
||
(setq ement-room-mark-rooms-read 'send)
|
||
(my/persp-add-rule
|
||
ement-room-mode 3 "ement"
|
||
ement-describe-room-mode 3 "ement"
|
||
ement-room-occur-mode 3 "ement"
|
||
ement-room-list-mode 3 "ement")
|
||
;; Room UI
|
||
(setq ement-room-message-format-spec "%S> %W%B%r%R[%t]")
|
||
(setq ement-room-left-margin-width 0)
|
||
(setq ement-room-right-margin-width 10)
|
||
(setq ement-room-sender-in-left-margin nil)
|
||
(setq ement-room-sender-headers nil)
|
||
(setq ement-room-sender-in-headers nil)
|
||
(setq ement-room-wrap-prefix "-> ")
|
||
;; Changing some default faces
|
||
(set-face-attribute 'ement-room-reactions nil :height 'unspecified)
|
||
(set-face-attribute 'ement-room-reactions-key nil :height 'unspecified)
|
||
(set-face-attribute 'ement-room-timestamp nil :inherit 'font-lock-function-name-face)
|
||
(set-face-attribute 'ement-room-membership nil :height 0.9
|
||
:inherit 'font-lock-warning-face)
|
||
(set-face-attribute 'ement-room-wrap-prefix nil :inherit 'unspecified)
|
||
(set-face-attribute 'ement-room-timestamp-header nil :height 'unspecified)
|
||
(set-face-attribute 'ement-room-wrap-prefix nil :inherit 'unspecified)
|
||
;; Notify only on mentions
|
||
(setq ement-notify-notification-predicates
|
||
'(ement-notify--event-mentions-session-user-p
|
||
ement-notify--event-mentions-room-p
|
||
ement-notify--room-unread-p))
|
||
;; Fix the anti-synergy with major mode re-activation in `ement-room-list-revert'
|
||
(advice-add #'ement-room-list-revert
|
||
:around #'my/perspective-assign-ignore-advice))
|
||
#+end_src
|
||
|
||
**** Keybindings
|
||
Some custom keymaps for room lists:
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'ement-room-list
|
||
(general-define-key
|
||
:states '(normal visual)
|
||
:keymaps '(ement-room-list-mode-map)
|
||
"<tab>" #'magit-section-toggle
|
||
"C-j" #'magit-section-forward
|
||
"C-k" #'magit-section-backward
|
||
"q" #'quit-window
|
||
"gr" #'revert-buffer
|
||
"RET" #'ement-room-list-RET))
|
||
|
||
(with-eval-after-load 'ement-tabulated-room-list
|
||
(general-define-key
|
||
:states '(normal visual)
|
||
:keymaps '(ement-tabulated-room-list-mode-map)
|
||
"q" #'quit-window))
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/ement-room-send-reaction (key position)
|
||
(interactive (list
|
||
(completing-read "Add reaction: " (append telega-emoji-reaction-list '("👋")))
|
||
(point)))
|
||
(ement-room-send-reaction key position))
|
||
|
||
(defun my/ement-room-compose-quit ()
|
||
(interactive)
|
||
(when (or (string-empty-p (buffer-string))
|
||
(y-or-n-p "Quit compose? "))
|
||
(quit-window t)))
|
||
|
||
(defun my/ement-room-compose-setup ()
|
||
(ement-room-compose-org)
|
||
(setq company-backends '(telega-company-emoji company-capf))
|
||
(general-define-key
|
||
:states '(normal visual)
|
||
:keymaps 'local
|
||
"Q" #'my/ement-room-compose-quit
|
||
"C-c C-k" (lambda () (interactive) (quit-window t))
|
||
"C-c C-c" #'ement-room-compose-send))
|
||
|
||
(add-hook 'ement-room-compose-hook #'my/ement-room-compose-setup)
|
||
#+end_src
|
||
|
||
Also a keymap for room mode:
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'ement
|
||
(general-define-key
|
||
:states '(normal visual)
|
||
:keymaps '(ement-room-mode-map)
|
||
"q" #'quit-window
|
||
"?" #'ement-room-transient
|
||
"C-u" #'ement-room-scroll-down-command
|
||
"C-d" #'ement-room-scroll-up-mark-read
|
||
"r" #'ement-room-write-reply
|
||
"a" #'ement-room-send-message
|
||
"i" #'ement-room-send-message
|
||
"e" #'ement-room-edit-message
|
||
"M-<RET>" #'ement-room-compose-message
|
||
"<RET>" #'ement-room-send-message
|
||
"K" #'ement-room-goto-prev
|
||
"J" #'ement-room-goto-next
|
||
"gr" #'ement-room-sync
|
||
"g?" #'ement-describe-room
|
||
"R?" #'ement-describe-room
|
||
"Rm" #'ement-list-members
|
||
"Rn" #'ement-room-set-notification-state
|
||
"Rt" #'ement-room-set-topic
|
||
"!" #'my/ement-room-send-reaction
|
||
"m?" #'ement-room-view-event
|
||
"Zf" #'ement-room-send-file
|
||
"ui" #'ement-invite-user)
|
||
(general-define-key
|
||
:states '(normal visual)
|
||
:keymaps '(ement-describe-room-mode-map)
|
||
"q" #'quit-window)
|
||
(general-define-key
|
||
:states '(motion)
|
||
:keymaps '(ement-room-mode-map)
|
||
"C-u" #'ement-room-scroll-down-command
|
||
"C-d" #'ement-room-scroll-up-mark-read))
|
||
#+end_src
|
||
|
||
**** Various functions
|
||
Scroll to the previous mention.
|
||
|
||
#+begin_quote
|
||
alphapapa 🐃> And, yes, that is a currently unsolved problem. As I said, in the future we can try using a different API endpoint to access those notifications similarly to Element. In the meantime, you can load old messages (e.g. "C-u 1000 M-v" to load 1000 old ones at a time), until you find it, maybe using "C-s sqrtm" to search for messages mentioning you.
|
||
|
||
Or you can load up Element for a moment to see what the mention was, if that's easier.
|
||
#+end_quote
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/ement-about-me-p (event)
|
||
(let ((me (ement-user-id (ement-session-user ement-session))))
|
||
(or
|
||
(equal (ement-user-id (ement-event-sender event)) me)
|
||
(when-let ((formatted-body
|
||
(alist-get
|
||
'formatted_body
|
||
(ement-event-content event))))
|
||
(string-match-p me formatted-body)))))
|
||
|
||
(defun my/ement-scroll-to-previous-about-me ()
|
||
(interactive)
|
||
(let ((scrolled 0))
|
||
(when (< (line-number-at-pos) 20)
|
||
(forward-line 20))
|
||
(if ement-room-retro-loading
|
||
(run-with-timer 0.5 nil #'my/ement-scroll-to-previous-about-me)
|
||
(while (let ((event (ewoc-data (ewoc-locate ement-ewoc))))
|
||
(and
|
||
(not ement-room-retro-loading)
|
||
(or
|
||
(not (ement-event-p event))
|
||
(not (my/ement-about-me-p event)))))
|
||
(condition-case _err
|
||
(scroll-down 1)
|
||
(beginning-of-buffer
|
||
(call-interactively #'ement-room-retro)
|
||
(run-with-timer 0.5 nil #'my/ement-scroll-to-previous-about-me)))
|
||
(cl-incf scrolled)
|
||
(message "Scrolled %s" scrolled)))))
|
||
#+end_src
|
||
*** Telega
|
||
:PROPERTIES:
|
||
:MODULE_NAME: telega
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-telega.el :comments links
|
||
:END:
|
||
[[https://github.com/zevlg/telega.el/][telega.el]] is a Telegam client for Emacs.
|
||
|
||
| Guix dependency |
|
||
|--------------------|
|
||
| emacs-telega-sever |
|
||
| font-gnu-unifont |
|
||
| font-gnu-freefont |
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package telega
|
||
;; :straight (:type built-in)
|
||
;; For now emacs-telega-server is compatible with the latest telega.el
|
||
:straight t
|
||
:if (not (or my/remote-server))
|
||
:commands (telega)
|
||
:init
|
||
(my-leader-def "a l" (my/command-in-persp "telega" "telega" 3 (telega)))
|
||
(my/use-colors
|
||
(telega-button-active :foreground (my/color-value 'base0)
|
||
:background (my/color-value 'cyan))
|
||
(telega-webpage-chat-link :foreground (my/color-value 'base0)
|
||
:background (my/color-value 'fg))
|
||
(telega-entity-type-spoiler :background (my/color-value 'base8)))
|
||
:config
|
||
(when (file-exists-p "~/.guix-extra-profiles/emacs/emacs/bin/telega-server")
|
||
(setq telega-server-command
|
||
(expand-file-name
|
||
"~/.guix-extra-profiles/emacs/emacs/bin/telega-server")))
|
||
(setq telega-emoji-use-images nil)
|
||
(setq telega-chat-fill-column 80)
|
||
(setq telega-completing-read-function #'completing-read)
|
||
(setq telega-sticker-size '(12 . 24))
|
||
(add-to-list 'savehist-additional-variables 'telega-msg-add-reaction)
|
||
(remove-hook 'telega-chat-mode-hook #'telega-chat-auto-fill-mode)
|
||
(general-define-key
|
||
:keymaps '(telega-root-mode-map telega-chat-mode-map)
|
||
:states '(normal)
|
||
"gp" telega-prefix-map)
|
||
(general-define-key
|
||
:keymaps '(telega-msg-button-map)
|
||
"<SPC>" nil)
|
||
(general-define-key
|
||
:keymaps '(telega-chat-mode-map)
|
||
"C-<return>" #'newline)
|
||
(my/persp-add-rule
|
||
telega-root-mode 3 "telega"
|
||
telega-chat-mode 3 "telega"
|
||
telega-image-mode 3 "telega"
|
||
telega-webpage-mode 3 "telega"))
|
||
#+end_src
|
||
|
||
Building =telega-server= can create problems. On Guix I can use the Guix package definition, on termux just build manually.
|
||
#+begin_src emacs-lisp
|
||
(defun my/telega-server-build ()
|
||
(interactive)
|
||
(setq telega-server-libs-prefix
|
||
(if (executable-find "guix")
|
||
(string-trim
|
||
(shell-command-to-string "guix build tdlib"))
|
||
(expand-file-name "~/bin/td/build/res/usr/local")))
|
||
(telega-server-build "CC=gcc"))
|
||
#+end_src
|
||
|
||
Setting up the modeline. The default mode string doesn't look great with my other modeline modules, so I override that.
|
||
#+begin_src emacs-lisp
|
||
(add-hook 'telega-load-hook #'telega-mode-line-mode)
|
||
(setq telega-mode-line-string-format
|
||
'("["
|
||
(:eval
|
||
(telega-mode-line-online-status))
|
||
(:eval
|
||
(when telega-use-tracking-for
|
||
(telega-mode-line-tracking)))
|
||
(:eval
|
||
(telega-mode-line-unread-unmuted))
|
||
(:eval
|
||
(telega-mode-line-mentions 'messages))
|
||
"]"))
|
||
#+end_src
|
||
|
||
Configuring company backends for the chat buffer, as recommended in the manual:
|
||
#+begin_src emacs-lisp
|
||
(defun my/telega-chat-setup ()
|
||
(interactive)
|
||
(set (make-local-variable 'company-backends)
|
||
(append (list 'telega-company-emoji
|
||
'telega-company-username
|
||
'telega-company-hashtag
|
||
'telega-company-markdown-precode)
|
||
(when (telega-chat-bot-p telega-chatbuf--chat)
|
||
'(telega-company-botcmd))))
|
||
(company-mode 1)
|
||
(setopt visual-fill-column-width
|
||
(+ telega-chat-fill-column
|
||
(if (display-graphic-p) 5 6)))
|
||
(setq-local split-width-threshold 1))
|
||
(add-hook 'telega-chat-mode-hook #'my/telega-chat-setup)
|
||
#+end_src
|
||
|
||
And custom online status. By default it marks you online when the Emacs frame is active, but I use EXWM, so I change that to when =telega.el= buffer is active. Otherwise, I'm online all the time.
|
||
#+begin_src emacs-lisp
|
||
(defun my/telega-online-status ()
|
||
(derived-mode-p 'telega-root-mode 'telega-chat-mode
|
||
'telega-image-mode 'telega-webpage-mode))
|
||
|
||
(setq telega-online-status-function #'my/telega-online-status)
|
||
#+end_src
|
||
|
||
Switch to topic in forum chats.
|
||
#+begin_src emacs-lisp
|
||
(defun my/telega-switch-to-topic ()
|
||
(interactive)
|
||
(let* ((topics-data (gethash
|
||
(plist-get telega-chatbuf--chat :id)
|
||
telega--chat-topics))
|
||
(topics-string
|
||
(mapcar
|
||
(lambda (topic)
|
||
(let* ((name (plist-get (plist-get topic :info) :name))
|
||
(unread-count (plist-get topic :unread_count))
|
||
(name-string (with-temp-buffer
|
||
(telega-ins--topic-title topic 'with-icon)
|
||
(buffer-string))))
|
||
(if (zerop unread-count)
|
||
name-string
|
||
(format "%-40s (%s)"
|
||
name-string
|
||
(propertize (format "%d" unread-count)
|
||
'face 'telega-unread-unmuted-modeline)))))
|
||
topics-data))
|
||
(topics-collection (cl-loop for datum in topics-data
|
||
for string in topics-string
|
||
collect (cons string datum)))
|
||
(topic (completing-read "Topic: " topics-collection nil t)))
|
||
(telega-chat--goto-thread
|
||
telega-chatbuf--chat
|
||
(plist-get
|
||
(plist-get
|
||
(alist-get topic topics-collection nil nil #'equal)
|
||
:info)
|
||
:message_thread_id))))
|
||
|
||
(with-eval-after-load 'telega
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'telega-chat-mode-map
|
||
"T" #'my/telega-switch-to-topic))
|
||
#+end_src
|
||
|
||
*** Google Translate
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-internet
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-internet.el :comments links
|
||
:END:
|
||
Emacs interface to Google Translate.
|
||
|
||
Can't make it load lazily for some strange reason.
|
||
|
||
References:
|
||
- [[https://github.com/atykhonov/google-translate][google-translate repo]]
|
||
- [[https://github.com/atykhonov/google-translate/issues/137#issuecomment-728278849][issue with ttk error fix]]
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package google-translate
|
||
:straight t
|
||
:if (not my/remote-server)
|
||
:functions (my-google-translate-at-point google-translate--search-tkk)
|
||
:commands (google-translate-at-point
|
||
google-translate-at-point-reverse
|
||
google-translate-query-translate
|
||
google-translate-query-translate-reverse
|
||
google-translate-smooth-translate)
|
||
:custom
|
||
(google-translate-backend-method 'curl)
|
||
:config
|
||
(require 'facemenu)
|
||
(defun google-translate--search-tkk ()
|
||
"Search TKK."
|
||
(list 430675 2721866130))
|
||
(defun my-google-translate-at-point()
|
||
"reverse translate if prefix"
|
||
(interactive)
|
||
(if current-prefix-arg
|
||
(google-translate-at-point)
|
||
(google-translate-at-point-reverse)))
|
||
(setq google-translate-translation-directions-alist
|
||
'(("en" . "ru")
|
||
("ru" . "en")
|
||
("de" . "en")
|
||
("en" . "de"))))
|
||
|
||
(my-leader-def
|
||
:infix "at"
|
||
"" '(:which-key "google translate")
|
||
"p" 'google-translate-at-point
|
||
"P" 'google-translate-at-point-reverse
|
||
"q" 'google-translate-query-translate
|
||
"Q" 'google-translate-query-translate-reverse
|
||
"t" 'google-translate-smooth-translate)
|
||
#+end_src
|
||
*** biome
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-internet
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-internet.el :comments links
|
||
:END:
|
||
[[https://github.com/SqrtMinusOne/biome][biome]] is my [[https://open-meteo.com/][open-meteo]] client.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package biome
|
||
:straight t
|
||
:commands (biome)
|
||
:init
|
||
(my-leader-def "ab" #'biome)
|
||
(when my/is-termux
|
||
(setq biome-query-tab-key "<TAB>")
|
||
(setq biome-api-try-parse-error-as-response t))
|
||
:config
|
||
(add-to-list 'biome-query-coords
|
||
'("Saint-Petersburg, Russia" 59.942651 30.229930))
|
||
(add-to-list 'biome-query-coords
|
||
'("Tyumen, Russia" 57.15222 65.52722)))
|
||
#+end_src
|
||
** Reading documentation
|
||
:PROPERTIES:
|
||
:MODULE_NAME: docs
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-docs.el :comments links
|
||
:END:
|
||
*** tldr
|
||
[[https://tldr.sh/][tldr]] is a collaborative project providing cheatsheets for various console commands. For some reason, the built-in download in the package is broken, so I use my own function.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package tldr
|
||
:straight t
|
||
:commands (tldr)
|
||
:config
|
||
(setq tldr-source-zip-url "https://github.com/tldr-pages/tldr/archive/refs/heads/main.zip")
|
||
|
||
(defun tldr-update-docs ()
|
||
(interactive)
|
||
(shell-command-to-string (format "curl -L %s --output %s" tldr-source-zip-url tldr-saved-zip-path))
|
||
(when (file-exists-p "/tmp/tldr")
|
||
(delete-directory "/tmp/tldr" t))
|
||
(shell-command-to-string (format "unzip -d /tmp/tldr/ %s" tldr-saved-zip-path))
|
||
(when (file-exists-p tldr-directory-path)
|
||
(delete-directory tldr-directory-path 'recursive 'no-trash))
|
||
(shell-command-to-string (format "mv %s %s" "/tmp/tldr/tldr-main" tldr-directory-path))))
|
||
|
||
(my-leader-def "hT" 'tldr)
|
||
#+end_src
|
||
*** man & info
|
||
Of course, Emacs can also display man and info pages.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq Man-width-max 180)
|
||
(my-leader-def "hM" 'woman)
|
||
(setq woman-fill-column 90)
|
||
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'Info-mode-map
|
||
(kbd "RET") #'Info-follow-nearest-node
|
||
"H" #'Info-history-back
|
||
"L" #'Info-history-forward
|
||
"n" #'Info-search-next
|
||
"b" #'Info-search-backward
|
||
"f" #'ace-link-info)
|
||
|
||
(defun my/man-fix-width (&rest _)
|
||
(setq-local Man-width (- (window-width) 4)))
|
||
|
||
(advice-add #'Man-update-manpage :before #'my/man-fix-width)
|
||
#+end_src
|
||
*** devdocs.io
|
||
There is a package called =devdocs= that does more or less the same, but I like =devdocs-browser= more because it uses =eww=.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package devdocs-browser
|
||
:straight t
|
||
:commands (devdocs-browser-open
|
||
devdocs-browser-open-in
|
||
devdocs-browser-install-doc
|
||
devdocs-browser-uninstall-doc
|
||
devdocs-browser-download-offline-data
|
||
devdocs-browser-remove-offline-data
|
||
devdocs-browser-upgrade-all-docs
|
||
devdocs-browser-update-docs)
|
||
:init
|
||
(my-leader-def
|
||
:infix "hd"
|
||
"" '(:wk "devdocs")
|
||
"d" #'devdocs-browser-open
|
||
"o" #'devdocs-browser-open-in
|
||
"i" #'devdocs-browser-install-doc
|
||
"n" #'devdocs-browser-uninstall-doc
|
||
"o" #'devdocs-browser-download-offline-data
|
||
"O" #'devdocs-browser-remove-offline-data
|
||
"u" #'devdocs-browser-upgrade-all-docs
|
||
"r" #'devdocs-browser-update-docs))
|
||
#+End_src
|
||
** Not-an-AI
|
||
:PROPERTIES:
|
||
:MODULE_NAME: ai
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-ai.el :comments links
|
||
:END:
|
||
Workflows, which are sometimes referred to as "AI", go in here.
|
||
|
||
I'm technically writing a PhD on a related topic, so I'm a bit more receptive towards the whole thing than most of the community. But I'm still not calling it AI.
|
||
|
||
*** LLMs
|
||
I don't have access to any proprietary APIs, but LLaMA 3.1 8b with [[https://ollama.com/][ollama]] works for some purposes.
|
||
|
||
**** gptel
|
||
[[https://github.com/karthink/gptel][gtpel]] is a package that provides an interface to chat with LLMs.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package gptel
|
||
:straight t
|
||
:if (not my/is-termux)
|
||
:init
|
||
(my-leader-def
|
||
:infix "ai"
|
||
"" '(:wk "AI")
|
||
"i" #'gptel)
|
||
:commands (gptel gptel-send gptel-menu)
|
||
:config
|
||
(setq gptel-mode "llama3:latest")
|
||
(setq gptel-track-media t)
|
||
(setq gptel-backend
|
||
(gptel-make-ollama "Ollama"
|
||
:host "localhost:11434"
|
||
:stream t
|
||
:models '("llama3.1:8b" "deepseek-r1:32b"
|
||
"qwen2.5:32b" "qwen2.5-coder:32b"
|
||
"qwen3:30b" "qwen3:32b"
|
||
"eva-qwen2.5-q4_k_l-32b:latest"
|
||
"gpt-oss"
|
||
(gemma3:27b
|
||
:capabilities (media)
|
||
:mime-types ("image/jpeg" "image/png")))))
|
||
(gptel-make-openai "OpenRouter"
|
||
:host "openrouter.ai/api"
|
||
:key (lambda () (my/password-store-get-field
|
||
"My_Online/Accounts/openrouter" "api-key"))
|
||
:stream t
|
||
:models '("anthropic/claude-sonnet-4"
|
||
"qwen/qwen3-coder"
|
||
"qwen/qwen3-coder:free"))
|
||
(setq gptel--known-backends
|
||
(seq-filter
|
||
(lambda (cell)
|
||
(not (equal (car cell) "ChatGPT")))
|
||
gptel--known-backends))
|
||
(setq gptel-response-prefix-alist
|
||
'((markdown-mode . "[Response] ")
|
||
(org-mode . "*** Response: ")
|
||
(text-mode . "[Response]")))
|
||
|
||
(general-define-key
|
||
:keymaps '(gptel-mode-map)
|
||
:states '(insert normal)
|
||
"C-<return>" 'gptel-send
|
||
"M-o" #'gptel-menu))
|
||
#+end_src
|
||
|
||
**** ellama
|
||
[[https://github.com/s-kostyaev/ellama][ellama]] provides commands that feed things from Emacs buffers into LLMs with various prompts.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package ellama
|
||
:straight t
|
||
:init
|
||
(setq ellama-language "English")
|
||
:defer t
|
||
:config
|
||
(require 'llm-ollama)
|
||
;; I've looked for this option for 1.5 hours
|
||
(setq ellama-long-lines-length 100000)
|
||
|
||
(setq ellama-provider (make-llm-ollama
|
||
:chat-model "qwen2.5:32b"
|
||
:embedding-model "qwen2.5:32b"))
|
||
(setq ellama-coding-provider (make-llm-ollama
|
||
:chat-model "qwen2.5-coder:32b"
|
||
:embedding-model "qwen2.5-coder:32b"))
|
||
(setq ellama-providers
|
||
`(("llama3.1:8b" . ,(make-llm-ollama
|
||
:chat-model "llama3.1:latest"
|
||
:embedding-model "llama3.1:latest"))
|
||
("phi4:latest" . ,(make-llm-ollama
|
||
:chat-model "phi4:latest"
|
||
:embedding-model "phi4:latest"))
|
||
("qwen2.5:32b" . ,(make-llm-ollama
|
||
:chat-model "qwen2.5:32b"
|
||
:embedding-model "qwen2.5:32b"))
|
||
("qwen2.5-coder:32b" . ,(make-llm-ollama
|
||
:chat-model "qwen2.5-coder:32b"
|
||
:embedding-model "qwen2.5-coder:32b")))))
|
||
#+end_src
|
||
|
||
The keybindings are a bit crazy to use even with =which-key=, so here goes transient.el.
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'ellama
|
||
(transient-define-prefix my/ellama-transient ()
|
||
"Ellama actions."
|
||
["General"
|
||
:class transient-row
|
||
("a" "Chat" ellama-chat)]
|
||
["Code"
|
||
:class transient-row
|
||
("ca" "Add" ellama-code-add)
|
||
("cc" "Complete" ellama-code-complete)
|
||
("ce" "Edit" ellama-code-edit)
|
||
("cr" "Review" ellama-code-review)
|
||
("ci" "Improve" ellama-code-improve)]
|
||
["Natural Language"
|
||
:class transient-row
|
||
("np" "Proof-read" my/ellama-proof-read)]
|
||
["Formatting"
|
||
:class transient-row
|
||
("ff" "Format" ellama-make-format)
|
||
("fm" "List" ellama-make-list)
|
||
("ft" "Table" ellama-make-table)]
|
||
["Explain & Summarize"
|
||
:class transient-row
|
||
("es" "Summarize" ellama-summarize)
|
||
("ea" "Ask about" ellama-ask-about)
|
||
("es" "Send to chat" ellama-ask-selection)
|
||
("ew" "Word definition" ellama-define-word)]
|
||
["Context"
|
||
:class transient-row
|
||
("xb" "Add buffer" ellama-context-add-buffer)
|
||
("xf" "Add file" ellama-context-add-file)
|
||
("xi" "Add info" ellama-context-add-info-node)
|
||
("xs" "Add selection" ellama-context-add-selection)]
|
||
["Settings & Sessions"
|
||
:class transient-row
|
||
("sp" "Provider" ellama-provider-select)
|
||
("ss" "Session" ellama-session-switch)
|
||
("sr" "Rename ression" ellama-session-rename)
|
||
("sd" "Delete session" ellama-session-remove)]))
|
||
|
||
(defun my/ellama ()
|
||
(interactive)
|
||
(require 'ellama)
|
||
(call-interactively #'my/ellama-transient))
|
||
|
||
(my-leader-def "aie" #'my/ellama)
|
||
#+end_src
|
||
|
||
**** Change natural-language text & diff against the results
|
||
One pattern I often want is to change the given text and compare it to the old version.
|
||
|
||
LLMs aren't perfectly good at saying what changes they have done, so the pattern here is to query the model and show the changed text together with the diff.
|
||
|
||
So first, I need to diff two strings.
|
||
#+begin_src emacs-lisp
|
||
(defun my/diff-strings (str1 str2)
|
||
(let ((file1 (make-temp-file "diff1"))
|
||
(file2 (make-temp-file "diff2")))
|
||
(unwind-protect
|
||
(progn
|
||
(with-temp-file file1
|
||
(insert str1))
|
||
(with-temp-file file2
|
||
(insert str2))
|
||
(with-temp-buffer
|
||
(diff-mode)
|
||
(diff-no-select file1 file2 (diff-switches) t (current-buffer))
|
||
(font-lock-fontify-buffer)
|
||
(buffer-string)))
|
||
(delete-file file1)
|
||
(delete-file file2))))
|
||
#+end_src
|
||
|
||
And the function to do the prompting iself. Llama tends to output in Markdown, so I use a function from Ellama to convert the output back to Org-mode, if necessary.
|
||
#+begin_src emacs-lisp
|
||
(defun my/ellama-proof-read--display (text is-org-mode prompt)
|
||
(llm-chat-async
|
||
ellama-provider
|
||
(llm-make-chat-prompt
|
||
(format prompt text))
|
||
(lambda (response)
|
||
(let* ((parts (split-string response "-FIXED TEXT ENDS-"))
|
||
(changed-text (nth 0 parts))
|
||
(comments (nth 1 parts))
|
||
(buffer (generate-new-buffer "*ellama-diff*")))
|
||
(when is-org-mode
|
||
(setq changed-text (ellama--translate-markdown-to-org-filter changed-text)))
|
||
(with-current-buffer buffer
|
||
(text-mode)
|
||
(insert
|
||
(propertize "Changed text:\n" 'face 'transient-heading)
|
||
(string-trim changed-text)
|
||
"\n\n"
|
||
(propertize "Comments:\n" 'face 'transient-heading)
|
||
(string-trim comments)
|
||
"\n\n"
|
||
(propertize "Diff:\n" 'face 'transient-heading)
|
||
(my/diff-strings text changed-text)))
|
||
(display-buffer buffer)))
|
||
(lambda (&rest err)
|
||
(message "Error: %s" err))))
|
||
#+end_src
|
||
|
||
As for prompts, I like the following prompt to proof-read text. It's pretty conservative, but good for fixing typos, missing commas, articles, etc.
|
||
#+begin_src emacs-lisp
|
||
(setq my/ellama-proof-read-prompt
|
||
"Proof-read the following text. Follow these rules:
|
||
- Fix all grammar errors
|
||
- Keep the original style and punctuation, including linebreaks.
|
||
- Use British spelling
|
||
- Do not replace ' with ’, and do not touch other such symbols
|
||
|
||
Output the following and nothing else:
|
||
- The fixed text
|
||
- The string -FIXED TEXT ENDS-
|
||
- List of found errors
|
||
- List of style suggestions
|
||
%s")
|
||
|
||
(defun my/ellama--text ()
|
||
(if (region-active-p)
|
||
(buffer-substring-no-properties (region-beginning) (region-end))
|
||
(buffer-substring-no-properties (point-min) (point-max))))
|
||
|
||
(defun my/ellama-proof-read (text is-org-mode)
|
||
(interactive (list (my/ellama--text) (derived-mode-p 'org-mode)))
|
||
(require 'ellama)
|
||
(my/ellama-proof-read--display text is-org-mode my/ellama-proof-read-prompt))
|
||
#+end_src
|
||
|
||
*** Podcast transcripts
|
||
In my experience, finding something in a podcast can be particularly troublesome. For instance, at times, I want to refer to a specific line in the podcast to make an [[https://github.com/org-roam/org-roam][org-roam]] node, and I need to check if I got that part right. And I have no reasonable way to get there because audio files, in themselves, don't allow for [[https://en.wikipedia.org/wiki/Random_access][random access]], i.e. there are no "landmarks" that point to a particular portion of the file. At least if nothing like a transcript is available.
|
||
|
||
For obvious reasons, podcasts rarely ship with transcripts. So in this +post+ section I'll be using a speech recognition engine to make up for that. The general idea is to obtain the podcast information from [[https://github.com/skeeto/elfeed][elfeed]], process it with [[https://github.com/openai/whisper][OpenAI Whisper]] and feed it to [[https://github.com/sachac/subed][subed]] to control the playback in [[https://mpv.io/][MPV]].
|
||
|
||
Edit <2022-10-08 Sat>: Changed [[https://github.com/alphacep/vosk-api][vosk-api]] to OpenAI Whisper.
|
||
|
||
Edit <2024-11-10 Sun>: Moved from elfeed to Not-an-AI, reworked to use [[https://github.com/Vaibhavs10/insanely-fast-whisper][insanely-fast-whisper]].
|
||
|
||
**** Whisper
|
||
[[https://github.com/openai/whisper][OpenAI Whisper]] is an amazing speech recognition toolkit.
|
||
|
||
I previously used [[https://github.com/ggerganov/whisper.cpp][whisper.cpp]] by Georgi Gerganov, but have switched to [[https://github.com/Vaibhavs10/insanely-fast-whisper][insanely-fast-whisper]] since it's easier to run on GPU, it doesn't require converting everything to WAV, and it includes speaker diarization capabilities.
|
||
|
||
One disadvantage is that it doesn't produce human-readable output by default, so I make my own.
|
||
|
||
| Guix dependency | Disabled |
|
||
|-----------------+----------|
|
||
| whisper-cpp | t |
|
||
|
||
**** Running it from Emacs
|
||
First, some functions to process the output. These take a JSON formed by =insanely-fast-whisper= and create a set of files:
|
||
- a TXT file with the full text;
|
||
- a VTT file;
|
||
- if speaker info is available:
|
||
- a TXT file with speaker tags;
|
||
- a VTT file with speaker tags.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/whisper--format-vtt-seconds (seconds)
|
||
(if (numberp seconds)
|
||
(let* ((hours (/ (floor seconds) (* 60 60)))
|
||
(minutes (/ (- (floor seconds) (* hours 60 60)) 60))
|
||
(sec (% (floor seconds) 60))
|
||
(ms (floor (* 1000 (- seconds (floor seconds))))))
|
||
(format "%.2d:%.2d:%.2d.%.3d" hours minutes sec ms))
|
||
""))
|
||
|
||
(defun my/whisper--save-chucks-vtt (path data)
|
||
(with-temp-file path
|
||
(insert "WEBVTT\n\n")
|
||
(cl-loop for chunk across (alist-get 'chunks data)
|
||
for start = (my/whisper--format-vtt-seconds
|
||
(aref (alist-get 'timestamp chunk) 0))
|
||
for end = (my/whisper--format-vtt-seconds
|
||
(aref (alist-get 'timestamp chunk) 1))
|
||
do (insert (format "%s --> %s" start end) "\n")
|
||
do (insert (string-trim (alist-get 'text chunk)) "\n\n"))))
|
||
|
||
(defun my/whisper--save-speakers-vtt (path data)
|
||
(with-temp-file path
|
||
(insert "WEBVTT\n\n")
|
||
(cl-loop for chunk across (alist-get 'speakers data)
|
||
for start = (my/whisper--format-vtt-seconds
|
||
(aref (alist-get 'timestamp chunk) 0))
|
||
for end = (my/whisper--format-vtt-seconds
|
||
(aref (alist-get 'timestamp chunk) 1))
|
||
do (insert (format "%s --> %s" start end) "\n")
|
||
do (insert
|
||
(format "<v %s>" (alist-get 'speaker chunk))
|
||
(string-trim (alist-get 'text chunk)) "\n\n"))))
|
||
|
||
(defun my/whisper--save-speakers-txt (path data)
|
||
(with-temp-file path
|
||
(cl-loop with prev-speaker
|
||
for chunk across (alist-get 'speakers data)
|
||
for speaker = (alist-get 'speaker chunk)
|
||
if (not (equal speaker prev-speaker))
|
||
do (progn
|
||
(when prev-speaker
|
||
(fill-region
|
||
(line-beginning-position)
|
||
(line-end-position))
|
||
(insert "\n\n"))
|
||
(insert (format "[%s]" speaker) "\n")
|
||
(setq prev-speaker speaker))
|
||
do (insert (string-trim (alist-get 'text chunk)) " "))
|
||
(fill-region
|
||
(line-beginning-position)
|
||
(line-end-position))))
|
||
|
||
(defun my/whisper--process-output (transcript-path)
|
||
(let ((data (json-read-file transcript-path)))
|
||
(when (alist-get 'text data)
|
||
(with-temp-file (concat
|
||
(file-name-sans-extension transcript-path)
|
||
".txt")
|
||
(insert (string-trim (alist-get 'text data)))
|
||
(do-auto-fill)))
|
||
(unless (seq-empty-p (alist-get 'speakers data))
|
||
(my/whisper--save-speakers-vtt
|
||
(concat (file-name-sans-extension transcript-path) "-spk.vtt")
|
||
data)
|
||
(my/whisper--save-speakers-txt
|
||
(concat (file-name-sans-extension transcript-path) "-spk.txt")
|
||
data))
|
||
(my/whisper--save-chucks-vtt
|
||
(concat (file-name-sans-extension transcript-path) ".vtt")
|
||
data)))
|
||
#+end_src
|
||
|
||
Then run the program itself with [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Asynchronous-Processes.html][asyncronous processes]].
|
||
|
||
#+begin_src emacs-lisp
|
||
(defvar my/whisper-path
|
||
"/home/pavel/micromamba/envs/insanely-fast-whisper/bin/insanely-fast-whisper")
|
||
|
||
(defun my/invoke-whisper (input output-dir &optional language num-speakers)
|
||
(interactive
|
||
(list
|
||
(read-file-name "Input file:" nil nil t)
|
||
(read-directory-name "Output-directory: ")
|
||
(let ((lang (read-string "Language (optional): ")))
|
||
(if (string-empty-p lang) nil lang))
|
||
(let ((num (read-number "Number of speakers (optional): " 0)))
|
||
(when (> num 0)
|
||
(number-to-string num)))))
|
||
(let* ((transcript-path (concat
|
||
(expand-file-name (file-name-as-directory output-dir))
|
||
(file-name-base input)
|
||
".json"))
|
||
(args
|
||
`("--file-name" ,(expand-file-name input)
|
||
"--transcript-path" ,transcript-path
|
||
"--hf-token" ,(my/password-store-get-field "My_Online/Accounts/huggingface.co" "token")
|
||
,@(when language
|
||
`("--language" ,language))
|
||
,@(when num-speakers
|
||
`("--num-speakers" ,num-speakers))))
|
||
(buffer (generate-new-buffer "*whisper*"))
|
||
(proc (apply #'start-process "whisper" buffer my/whisper-path args)))
|
||
(set-process-sentinel
|
||
proc
|
||
(lambda (process _msg)
|
||
(let ((status (process-status process))
|
||
(code (process-exit-status process)))
|
||
(cond ((and (eq status 'exit) (= code 0))
|
||
(my/whisper--process-output transcript-path)
|
||
(notifications-notify :body "Audio conversion completed"
|
||
:title "Whisper")
|
||
(kill-buffer (process-buffer process)))
|
||
((or (and (eq status 'exit) (> code 0))
|
||
(eq status 'signal))
|
||
(let ((err (with-current-buffer (process-buffer process)
|
||
(buffer-string))))
|
||
(user-error "Error in Whisper: %s" err)))))))))
|
||
#+end_src
|
||
|
||
If run interactively, the defined function prompts for paths to both files.
|
||
|
||
The process sentinel sends a [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Desktop-Notifications.html][desktop notification]] because it's a bit more noticeable than =message=, and the process is expected to take some time.
|
||
|
||
**** Integrating with elfeed
|
||
To actually run the function from the section above, we need to download the file in question.
|
||
|
||
The =whisper= executable, given the file =<file>.<extension>=, creates files named =<file>.vtt=, =<file>.srt=, =<file>.txt=. So first we need to save the file under the correct name.
|
||
|
||
I use a library called [[https://github.com/tkf/emacs-request][request.el]] to download files elsewhere, so I'll re-use it here. You can just as well invoke =curl= or =wget= via a asynchronous process.
|
||
|
||
This function downloads the file to a non-temporary folder, which is =~/.elfeed/podcast-files/= if you didn't move the elfeed database. That is so because a permanently downloaded file works better for the next section.
|
||
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'elfeed
|
||
(defvar my/elfeed-whisper-podcast-files-directory
|
||
(concat elfeed-db-directory "/podcast-files/")))
|
||
|
||
(defun my/elfeed-whisper-get-transcript-new (entry)
|
||
(interactive (list elfeed-show-entry))
|
||
(let* ((url (caar (elfeed-entry-enclosures entry)))
|
||
(file-name (concat
|
||
(elfeed-ref-id (elfeed-entry-content entry))
|
||
"."
|
||
(file-name-extension url)))
|
||
(file-path (expand-file-name
|
||
(concat
|
||
my/elfeed-whisper-podcast-files-directory
|
||
file-name))))
|
||
(message "Download started")
|
||
(unless (file-exists-p my/elfeed-whisper-podcast-files-directory)
|
||
(mkdir my/elfeed-whisper-podcast-files-directory))
|
||
(request url
|
||
:type "GET"
|
||
:encoding 'binary
|
||
:complete
|
||
(cl-function
|
||
(lambda (&key data &allow-other-keys)
|
||
(let ((coding-system-for-write 'binary)
|
||
(write-region-annotate-functions nil)
|
||
(write-region-post-annotation-function nil))
|
||
(write-region data nil file-path nil :silent))
|
||
(message "Conversion started")
|
||
(my/invoke-whisper file-path my/elfeed-srt-dir)))
|
||
:error
|
||
(cl-function
|
||
(lambda (&key error-thrown &allow-other-keys)
|
||
(message "Error!: %S" error-thrown))))))
|
||
#+end_src
|
||
|
||
I also experimented with a bunch of options to write binary data in Emacs, of which the way with =write-region= (as implemented in [[https://github.com/rejeep/f.el][f.el]]) seems to be the fastest. [[https://emacs.stackexchange.com/questions/59449/how-do-i-save-raw-bytes-into-a-file][This thread on StackExchange]] suggests that it may screw some bytes towards the end, but whether or not this is the case, mp3 files survive the procedure. The proposed solution with =seq-doseq= takes at least a few seconds.
|
||
|
||
As =my/invoke-whisper= creates multiple files, here's a function to select related files:
|
||
#+begin_src emacs-lisp
|
||
(defun my/elfeed-show-related-files (entry)
|
||
(interactive (list elfeed-show-entry))
|
||
(let* ((files
|
||
(mapcar
|
||
(lambda (file) (cons (file-name-extension file) file))
|
||
(seq-filter
|
||
(lambda (file)
|
||
(string-match-p
|
||
(rx bos (literal (elfeed-ref-id (elfeed-entry-content entry))) ".")
|
||
file))
|
||
(directory-files my/elfeed-srt-dir))))
|
||
(buffer
|
||
(find-file-other-window
|
||
(concat
|
||
my/elfeed-srt-dir
|
||
(alist-get
|
||
(completing-read "File: " files)
|
||
files nil nil #'equal)))))
|
||
(with-current-buffer buffer
|
||
(setq-local elfeed-show-entry entry))))
|
||
#+end_src
|
||
|
||
Finally, we need a function to show the transcript if it exists or invoke =my/elfeed-whisper-get-transcript-new= if it doesn't. And this is the function that we'll call from an =elfeed-entry= buffer.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/elfeed-whisper-get-transcript (entry)
|
||
"Retrieve transcript for the enclosure of the current elfeed ENTRY."
|
||
(interactive (list elfeed-show-entry))
|
||
(let ((enclosure (caar (elfeed-entry-enclosures entry))))
|
||
(unless enclosure
|
||
(user-error "No enclosure found!"))
|
||
(let ((srt-path (concat my/elfeed-srt-dir
|
||
(elfeed-ref-id (elfeed-entry-content entry))
|
||
".srt")))
|
||
(if (file-exists-p srt-path)
|
||
(let ((buffer (find-file-other-window srt-path)))
|
||
(with-current-buffer buffer
|
||
(setq-local elfeed-show-entry entry)))
|
||
(my/elfeed-whisper-get-transcript-new entry)))))
|
||
#+end_src
|
||
|
||
**** Integrating with subed
|
||
Now that we've produced a =.srt= file, we can use a package called [[https://github.com/sachac/subed][subed]] to control the playback, as I have done in the YouTube section.
|
||
|
||
By the way, this wasn't the most straightforward thing to figure out, because the MPV window doesn't show up for an audio file, and the player itself starts in the paused state. So I thought nothing was happening until I enabled the debug log.
|
||
|
||
With that in mind, here's a function to launch MPV from the buffer generated by =my/elfeed-whisper-get-transcript=:
|
||
#+begin_src emacs-lisp
|
||
(defun my/elfeed-whisper-subed (entry)
|
||
"Run MPV for the current Whisper-generated subtitles file.
|
||
|
||
ENTRY is an instance of `elfeed-entry'."
|
||
(interactive (list elfeed-show-entry))
|
||
(unless entry
|
||
(user-error "No entry!"))
|
||
(unless (derived-mode-p 'subed-mode)
|
||
(user-error "Not subed mode!"))
|
||
(setq-local subed-mpv-video-file
|
||
(expand-file-name
|
||
(concat my/elfeed-whisper-podcast-files-directory
|
||
(my/get-file-name-from-url
|
||
(caar (elfeed-entry-enclosures entry))))))
|
||
(subed-mpv--play subed-mpv-video-file))
|
||
#+end_src
|
||
|
||
After running =M-x my/elfeed-whisper-subed=, run =M-x subed-toggle-loop-over-current-subtitle= (=C-c C-l=), because somehow it's turned on by default, and =M-x subed-toggle-pause-while-typing= (=C-c C-p=), because sometimes this made my instance of MPV lag.
|
||
|
||
After that, =M-x subed-mpv-toggle-pause= should start the playback, which you can control by moving the cursor in the buffer.
|
||
|
||
You can also run =M-x subed-toggle-sync-point-to-player= (=C-c .=) to toggle syncing the point in the buffer to the currently played subtitle (this automatically gets disabled when you switch buffers).
|
||
|
||
Running =M-x subed-toggle-sync-player-to-point= (=C-c ,=) does the opposite, i.e. sets the player position to the subtitle under point. These two functions are useful since the MPV window controls aren't available.
|
||
|
||
**** Running it for Internet Files
|
||
And since lately I don't listen to podcasts via elfeed that much, I also want a function that runs whisper on random Internet files.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/whisper-url (url file-name output-dir &optional language num-speakers)
|
||
(interactive
|
||
(list (read-from-minibuffer "URL: ")
|
||
(read-from-minibuffer "File name: ")
|
||
(read-directory-name "Output directory: ")
|
||
(let ((lang (read-string "Language (optional): ")))
|
||
(if (string-empty-p lang) nil lang))
|
||
(let ((num (read-number "Number of speakers (optional): " 0)))
|
||
(when (> num 0)
|
||
(number-to-string num)))))
|
||
(let ((file-path
|
||
(concat output-dir file-name "." (file-name-extension url))))
|
||
(message "Download started")
|
||
(request url
|
||
:type "GET"
|
||
:encoding 'binary
|
||
:complete
|
||
(cl-function
|
||
(lambda (&key data &allow-other-keys)
|
||
(let ((coding-system-for-write 'binary)
|
||
(write-region-annotate-functions nil)
|
||
(write-region-post-annotation-function nil))
|
||
(write-region data nil file-path nil :silent))
|
||
(message "Conversion started")
|
||
(my/invoke-whisper file-path output-dir language num-speakers)))
|
||
:error
|
||
(cl-function
|
||
(lambda (&key error-thrown &allow-other-keys)
|
||
(message "Error!: %S" error-thrown))))))
|
||
#+end_src
|
||
**** Some observations
|
||
So, the functions above work for my purposes.
|
||
|
||
Vosk API works much faster than Whisper. The smallest Vosk model requires ~10 times less than the playback time, and even the =tiny.en= Whisper model on my PC requires maybe 1.2x playback time.
|
||
|
||
However, the quality of the output for Whisper is just so much better so I consider it to be worth the wait. Even with the =tiny= model, the transcript is almost perfect, provided that the audio is of reasonable quality.
|
||
** Declarative filesystem management
|
||
:PROPERTIES:
|
||
:MODULE_NAME: index
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-index.el :comments links
|
||
:END:
|
||
My filesystem is, shall we say, not the most orderly place.
|
||
|
||
#+begin_export html
|
||
<center>
|
||
<iframe src="https://emacs.ch/@sqrtminusone/110514686718545191/embed" class="mastodon-embed" style="max-width: 100%; border: 0" width="500" allowfullscreen="allowfullscreen"></iframe><script src="https://emacs.ch/embed.js" async="async"></script>
|
||
</center>
|
||
#+end_export
|
||
|
||
It's been somewhat messy, and messy in different ways across my three machines. For instance, my laptop had work projects in =~/Code/Job=, my work machine had just =~/Code=, and so forth.
|
||
|
||
Strangely, I couldn't find and existing solution to that problem. Surely, I can't be the only one facing that issue, can I?
|
||
|
||
Fortunately, I'm well-acquainted with (make-yourself-a) Swiss Army Knife of computing called [[https://www.gnu.org/software/emacs/][Emacs]], so... below is my attempt to make something of it. And another addition to the already substantial list of my Emacs uses.
|
||
|
||
*** Idea
|
||
So, I decided to try declarative filesystem management.
|
||
|
||
At the core is my work-in-progress adaptation of [[https://johnnydecimal.com/][Johnny.Decimal]]. Essentially, it suggests prefixing your folders with numbers like =12.34=, where:
|
||
- the first digit is the "[[https://johnnydecimal.com/10-19-concepts/11-core/11.02-areas-and-categories/][category]]";
|
||
- the second digit is the "[[https://johnnydecimal.com/10-19-concepts/11-core/11.02-areas-and-categories/][area]]";
|
||
- the last two digits are the [[https://johnnydecimal.com/10-19-concepts/11-core/11.03-ids/][ID]].
|
||
The point is to organize your folder structure, limiting its depth for quicker and more straightforward access. Check the website for a more thorough description.
|
||
|
||
So, what I want is to:
|
||
- define a Jonny.Decimal-esque file tree in a single [[https://orgmode.org/][Org]] file;
|
||
- have different nodes of that file tree active on different machines, e.g. I don't want [[https://github.com/SqrtMinusOne?tab=repositories&q=&type=&language=emacs+lisp&sort=][my Emacs stuff]] on my work machine;
|
||
- use different tools to sync different nodes (currently [[https://git-scm.com/][git]], [[https://mega.nz/][MEGA]], and "nothing").
|
||
**** Folder structure
|
||
As I said, I tried (and still trying) to adapt the proposed scheme to better suit my needs. Here's a subset of my current tree:
|
||
|
||
#+begin_example
|
||
10-19 Code
|
||
10 [REDACTED]
|
||
10.02 Digital Schedule ; project root
|
||
10.03 Digital Trajectories ; project root
|
||
12 My Emacs Packages
|
||
12.01 lyrics-fetcher.el ; managed by git
|
||
12.02 pomm.el ; managed by git
|
||
15 Other Projects
|
||
15.04 ZMU_2022 ; I'm done with this and don't need it on any machine
|
||
20-29 Education
|
||
24 Publications ; the entrire area is managed by MEGA
|
||
24.Y20.01 [bibtex code]
|
||
24.Y20.02 [bibtex code]
|
||
26 Students
|
||
26.Y22.01 [student name]
|
||
30-39 Life
|
||
32 org-mode
|
||
33 Library
|
||
#+end_example
|
||
|
||
The root of the tree is my =$HOME=. The entry at the third (or second) level can be either an entity itself (such as a git repository), or a "project root".
|
||
|
||
In several places, I use year references (=Y20=) instead of the plain =AC.ID=. This is mainly to group things by academic years, e.g. to find all my publications or students in a specific year, which I need for occasional reports. I also have semester references (=SEM10=) for my undergraduate studies.
|
||
|
||
The project structure is more or less standard. Johnny.Decimal [[https://johnnydecimal.com/10-19-concepts/13-multiple-projects/13.01-introduction/][proposes]] using =PRO.AC.ID= to manage multiple projects, but this doesn't seem to fit quite as well in my case. So I came up with the following:
|
||
|
||
#+begin_example
|
||
10.03 Digital Trajectories ; project root
|
||
10.03.A Artifacts ; managed by MEGA
|
||
10.03.A.04 library queries (Jan 23)
|
||
10.03.D Documents ; managed by MEGA
|
||
10.03.D.01 Initial design
|
||
10.03.R Repos
|
||
10.03.R.00 digital-trajectories-deploy ; managed by MEGA
|
||
10.03.R.01 digital-trajectories-backend ; managed by git
|
||
10.03.U Dumps ; managed by nothing, no need to sync this
|
||
#+end_example
|
||
|
||
I also use year references on the third level for courses I happen to teach across multiple academic years.
|
||
|
||
Perhaps this is too verbose (=10.03.R.01=), but it works for now.
|
||
|
||
**** Tools choice
|
||
As I mentioned earlier, my current options to manage a particular node are:
|
||
- [[https://git-scm.com/][git]];
|
||
- [[https://mega.nz/][MEGA]] - for files that don't fit into git, such as DOCX documents, photos, etc.;
|
||
- "nothing" - for something that I don't need to sync across machines, e.g. database dumps.
|
||
|
||
Another tool I considered was [[https://github.com/restic/restic][restic]]. It's an interesting backup & sync solution with built-in encryption, snapshots, etc.
|
||
|
||
However, a challenge I encountered is that its repositories are only accessible via restic. So, even if I use something like MEGA as a backend, I won't be able to use the MEGA file-sharing features, which I occasionally want for document or photo folders. Hence, for now, I'm more interested in synchronizing the file tree in MEGA with [[https://github.com/meganz/MEGAcmd][MEGAcmd]] (and also clean up the mess up there).
|
||
|
||
Another interesting tool is [[https://rclone.org/][rclone]], which provides a single interface for multiple services like Google Drive, Dropbox, S3, WebDAV. It also supports MEGA, but it requires turning off the two-factor authentication, which I don't want.
|
||
|
||
*** Implementation
|
||
**** Dependencies
|
||
We'll a package called [[https://github.com/daniel-ness/ini.el][ini.el]] to parse INI files.
|
||
#+begin_src emacs-lisp
|
||
(use-package ini
|
||
:mode "\\.ini\\'"
|
||
:straight (:host github :repo "daniel-ness/ini.el"))
|
||
#+end_src
|
||
|
||
The rest is built into Emacs.
|
||
|
||
*** Org tree
|
||
**** Tree definitions
|
||
The root is my =$HOME= directory.
|
||
#+begin_src emacs-lisp
|
||
(defvar my/index-root (concat (getenv "HOME") "/"))
|
||
#+end_src
|
||
|
||
The org tree is located in my =org-mode= folder in a file called =index.org=:
|
||
#+begin_src emacs-lisp
|
||
(with-eval-after-load 'org
|
||
(defvar my/index-file
|
||
(concat org-directory "/misc/index.org")))
|
||
#+end_src
|
||
|
||
Each "area" is an Org header with the =folder= tag; the Org hierarchy forms the file tree. A header can have the following properties:
|
||
- =machine= - a list of hostnames for which the node is active (or =nil=)
|
||
- =kind= - =mega=, =git=, or =dummy=
|
||
- =remote= - remote URL for =git=
|
||
- =symlink= - in case the folder has to be symlinked somewhere else
|
||
|
||
E.g. a part of the tree above:
|
||
#+begin_src org
|
||
,* 10-19 Code :folder:
|
||
,** 10 [REDACTED]
|
||
,*** 10.03 Digital Trajectories
|
||
:PROPERTIES:
|
||
:machine: indigo eminence
|
||
:project: t
|
||
:END:
|
||
,**** 10.03.A Artifacts
|
||
:PROPERTIES:
|
||
:kind: mega
|
||
:END:
|
||
,**** 10.03.D Documents
|
||
:PROPERTIES:
|
||
:kind: mega
|
||
:END:
|
||
,**** 10.03.R Repos
|
||
,***** 10.03.R.00 digital-trajectories-deploy
|
||
:PROPERTIES:
|
||
:kind: mega
|
||
:END:
|
||
,***** 10.03.R.01 digital-trajectories-backend
|
||
:PROPERTIES:
|
||
:kind: git
|
||
:remote: [REACTED]
|
||
:END:
|
||
|
||
,**** 10.03.U Dumps
|
||
:PROPERTIES:
|
||
:kind: dummy
|
||
:END:
|
||
#+end_src
|
||
|
||
**** Parse tree
|
||
So, let's parse the Org tree. This is done by recursively traversing the tree returned by =org-element-parse-buffer=.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--tree-get-recursive (heading &optional path)
|
||
"Read the index tree recursively from HEADING.
|
||
|
||
HEADING is an org-element of type `headline'.
|
||
|
||
If PATH is provided, it is the path to the current node. If not
|
||
provided, it is assumed to be the root of the index.
|
||
|
||
The return value is an alist; see `my/index--tree-get' for details."
|
||
(when (eq (org-element-type heading) 'headline)
|
||
(let (val
|
||
(new-path (concat
|
||
(or path my/index-root)
|
||
(org-element-property :raw-value heading)
|
||
"/")))
|
||
(when-let* ((children (thread-last
|
||
(org-element-contents heading)
|
||
(mapcar (lambda (e)
|
||
(my/index--tree-get-recursive
|
||
e new-path)))
|
||
(seq-filter #'identity))))
|
||
(setf (alist-get :children val) children))
|
||
(when-let ((machine (org-element-property :MACHINE heading)))
|
||
(setf (alist-get :machine val) (split-string machine)))
|
||
(when-let ((symlink (org-element-property :SYMLINK heading)))
|
||
(setf (alist-get :symlink val) symlink))
|
||
(when (org-element-property :PROJECT heading)
|
||
(setf (alist-get :project val) t))
|
||
(when-let* ((kind-str (org-element-property :KIND heading))
|
||
(kind (intern kind-str)))
|
||
(setf (alist-get :kind val) kind)
|
||
(when (equal kind 'git)
|
||
(let ((remote (org-element-property :REMOTE heading)))
|
||
(unless remote
|
||
(user-error "No remote for %s" (alist-get :name val)))
|
||
(setf (alist-get :remote val) remote))))
|
||
(setf (alist-get :name val) (org-element-property :raw-value heading)
|
||
(alist-get :path val) new-path)
|
||
val)))
|
||
|
||
(defun my/index--tree-get ()
|
||
"Read the index tree from the current org buffer.
|
||
|
||
The return value is a list of alists, each representing a
|
||
folder/node. Alists can have the following keys:
|
||
- `:name'
|
||
- `:path'
|
||
- `:children' - child nodes
|
||
- `:machine' - list of machines on which the node is active
|
||
- `:symlink' - a symlink to create
|
||
- `:kind' - one of \"git\", \"mega\", or \"dummy\"
|
||
- `:remote' - the remote to use for git nodes"
|
||
(let* ((tree
|
||
(thread-last
|
||
(org-element-map (org-element-parse-buffer) 'headline #'identity)
|
||
(seq-filter (lambda (el)
|
||
(and
|
||
(= (org-element-property :level el) 1)
|
||
(seq-contains-p
|
||
(mapcar #'substring-no-properties (org-element-property :tags el))
|
||
"folder"))))
|
||
(mapcar #'my/index--tree-get-recursive))))
|
||
tree))
|
||
#+end_src
|
||
|
||
**** Verify tree
|
||
I also want to make sure that I didn't mess up the numbers, i.e., didn't place =10.02= under =11=, and so on.
|
||
|
||
To do that, we first need to extract the number from the name:
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--extact-number (name)
|
||
"Extract the number from the index NAME.
|
||
|
||
NAME is a string. The number is the first sequence of digits, e.g.:
|
||
- 10-19
|
||
- 10.01
|
||
- 10.01.Y22.01"
|
||
(save-match-data
|
||
(string-match (rx bos (+ (| num alpha "." "-"))) name)
|
||
(match-string 0 name)))
|
||
#+end_src
|
||
|
||
Then, we can recursively verify the numbers:
|
||
#+begin_src emacs-lisp
|
||
(defun my/tree--verfify-recursive (elem &optional current)
|
||
"Verify that ELEM is a valid tree element.
|
||
|
||
CURRENT is the current number or name of the parent element."
|
||
(let* ((name (alist-get :name elem))
|
||
(number (my/index--extact-number name)))
|
||
(unless number
|
||
(user-error "Can't find number: %s" name))
|
||
(cond
|
||
((and (listp current) (not (null current)))
|
||
(unless (seq-some (lambda (cand) (string-prefix-p cand name)) current)
|
||
(user-error "Name: %s doesn't match: %s" name current)))
|
||
((stringp current)
|
||
(unless (string-prefix-p current name)
|
||
(user-error "Name: %s doesn't match: %s" name current))))
|
||
(let ((recur-value
|
||
(if (string-match-p (rx (+ num) "-" (+ num)) number)
|
||
(let* ((borders (split-string number "-"))
|
||
(start (string-to-number (nth 0 borders)))
|
||
(end (string-to-number (nth 1 borders))))
|
||
(cl-loop for i from start to (1- end) collect (number-to-string i)))
|
||
number)))
|
||
(mapcar (lambda (e) (my/tree--verfify-recursive e recur-value))
|
||
(alist-get :children elem))))
|
||
t)
|
||
|
||
(defun my/index--tree-verify (tree)
|
||
"Verify that TREE is a valid tree.
|
||
|
||
Return t if it is valid, otherwise raise an error.
|
||
|
||
See `my/index--tree-get' for the format of TREE."
|
||
(mapcar #'my/tree--verfify-recursive tree))
|
||
#+end_src
|
||
|
||
**** Narrow tree
|
||
Finally, we need to narrow the tree to only leave nodes that are active for the current machine.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--tree-narrow-recursive (elem machine)
|
||
"Remove all children of ELEM that are not active on MACHINE."
|
||
(unless (when-let ((elem-machines (alist-get :machine elem)))
|
||
(not (seq-some (lambda (elem-machine)
|
||
(string-equal elem-machine machine))
|
||
elem-machines)))
|
||
(setf (alist-get :children elem)
|
||
(seq-filter
|
||
#'identity
|
||
(mapcar (lambda (e)
|
||
(my/index--tree-narrow-recursive e machine))
|
||
(alist-get :children elem))))
|
||
elem))
|
||
|
||
(defun my/index--tree-narrow (tree)
|
||
"Remove all elements of TREE that are not active on machine."
|
||
(seq-filter
|
||
#'identity
|
||
(mapcar
|
||
(lambda (elem) (my/index--tree-narrow-recursive elem (my/system-name)))
|
||
(copy-tree tree))))
|
||
#+end_src
|
||
|
||
#+RESULTS:
|
||
: my/index--tree-narrow
|
||
*** Commands
|
||
Next, apply the tree to the filesystem.
|
||
|
||
I've decided to implement this by generating a bash script and executing it with =bash +x=. This way, I can check the required changes in advance and avert potential data loss if something unexpected happens.
|
||
|
||
One command for the script will be a list like:
|
||
- =(<command> <category> <priority>)=
|
||
|
||
**** Filesystem
|
||
First, we need to create non-existing folders and remove folders that aren't supposed to exist.
|
||
|
||
To do that, we need to find all such folders:
|
||
#+begin_src emacs-lisp
|
||
(defvar my/index-keep-files
|
||
'(".dtrash"))
|
||
|
||
(defun my/index--filesystem-tree-mapping (full-tree tree &optional active-paths)
|
||
"Return a \"sync state\" between the filesystem and the tree.
|
||
|
||
FULL-TREE and TREE are forms as defined by `my/index--tree-get'. TREE
|
||
is the narrowed FULL-TREE (returned by `my/index--tree-narrow').
|
||
|
||
ACTIVE-PATHS is a list of paths that are currently active. If not
|
||
provided, it is computed from TREE.
|
||
|
||
The return value is a list of alists with the following keys:
|
||
- path - the path of the folder
|
||
- exists - whether the folder exists on the filesystem
|
||
- has-to-exist - whether the folder exists in the tree
|
||
- extra - if the folder exists in the filesystem but not in the tree.
|
||
- children - a list of alists with the same keys for the children of
|
||
the folder."
|
||
(let ((active-paths (or active-paths (my/index--tree-get-paths tree))))
|
||
(cl-loop for elem in full-tree
|
||
for path = (alist-get :path elem)
|
||
for extra-folders = (when (and (alist-get :children elem)
|
||
(file-directory-p path))
|
||
(seq-difference
|
||
(mapcar (lambda (d) (if (file-directory-p d)
|
||
(concat d "/")
|
||
d))
|
||
(directory-files path t (rx (not ".") eos)))
|
||
(cl-loop for child in (alist-get :children elem)
|
||
collect (alist-get :path child))))
|
||
for folder-exists = (file-directory-p path)
|
||
for folder-has-to-exist = (seq-contains-p active-paths path)
|
||
collect `((path . ,path)
|
||
(exists . ,folder-exists)
|
||
(has-to-exist . ,folder-has-to-exist)
|
||
(children . ,(append
|
||
(cl-loop for f in extra-folders
|
||
collect
|
||
`((path . ,f)
|
||
(exists . t)
|
||
(has-to-exist
|
||
. ,(member
|
||
(file-name-nondirectory
|
||
(directory-file-name f))
|
||
my/index-keep-files))
|
||
(extra . t)))
|
||
(my/index--filesystem-tree-mapping
|
||
(alist-get :children elem) tree active-paths)))))))
|
||
#+end_src
|
||
|
||
And generate commands from the results of the above:
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--filesystem-commands (mapping)
|
||
"Get commands to sync filesystem with the tree.
|
||
|
||
MAPPING is a form generated by `my/index--filesystem-tree-mapping'
|
||
that describes the \"sync state\" between the filesystem and the
|
||
tree.
|
||
|
||
The return value is a list of commands as defined by
|
||
`my/index--commands-display'."
|
||
(cl-loop for elem in mapping
|
||
for path = (alist-get 'path elem)
|
||
for exists = (alist-get 'exists elem)
|
||
for has-to-exist = (alist-get 'has-to-exist elem)
|
||
for extra = (alist-get 'extra elem)
|
||
when (and (not exists) has-to-exist)
|
||
collect (list (format "mkdir \"%s\"" path) "Make directories" 1)
|
||
when (and exists (not has-to-exist))
|
||
collect (list (format "rm -rf \"%s\"" path)
|
||
(if extra "Remove extra files" "Remove directories")
|
||
(if extra 20 10))
|
||
append (my/index--filesystem-commands (alist-get 'children elem))))
|
||
#+end_src
|
||
|
||
**** MEGA
|
||
As I said above, MEGA provides [[https://github.com/meganz/MEGAcmd][MEGAcmd]], which is a convenient way to access MEGA via CLI.
|
||
|
||
To initialize the session, run
|
||
#+begin_src bash
|
||
mega-login <login> <password>
|
||
#+end_src
|
||
Then you'll be able to run the rest of =mega-*= commands.
|
||
|
||
The command I want to run, =mega-sync=, prints the results in a table-like way. So let's parse that.
|
||
#+begin_src emacs-lisp
|
||
(defun my/parse-table-str (string)
|
||
"Convert a table-like STRING into alist.
|
||
|
||
The input format is as follows:
|
||
HEADER1 HEADER2 HEADER3
|
||
value1 value2 3
|
||
value4 value5 6
|
||
|
||
Which creates the following output:
|
||
\(((HEADER1. \"value1\") (HEADER2 . \"value2\") (HEADER3 . \"3\"))
|
||
((HEADER1. \"value4\") (HEADER2 . \"value5\") (HEADER3 . \"6\")))
|
||
|
||
The functions also skips lines in [square brackets] and ones that
|
||
start with more than 3 spaces."
|
||
(when-let* ((lines (seq-filter
|
||
(lambda (s) (not (or (string-empty-p s)
|
||
(string-match-p (rx bos "[" (* nonl) "]") s)
|
||
(string-match-p (rx bos (>= 3 " ")) s))))
|
||
(split-string string "\n")))
|
||
(first-line (car lines))
|
||
(headers (split-string first-line))
|
||
(header-indices (mapcar
|
||
(lambda (header)
|
||
(cl-search header first-line))
|
||
headers)))
|
||
(cl-loop for line in (cdr lines)
|
||
collect (cl-loop for header in headers
|
||
for start in header-indices
|
||
for end in (append (cdr header-indices)
|
||
(list (length line)))
|
||
collect (cons
|
||
(intern header)
|
||
(string-trim
|
||
(substring line start end)))))))
|
||
#+end_src
|
||
|
||
Now we can invoke =mega-sync= to get the current sync status. =--path-display-size=10000= disables truncation of long paths.
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--mega-data-from-sync ()
|
||
"Get the current MEGA sync status.
|
||
|
||
The return value is a list of alists with the following keys:
|
||
- path - path to file or directory
|
||
- enabled - whether the file or directory is enabled for sync"
|
||
(let ((mega-result (my/parse-table-str
|
||
(shell-command-to-string "mega-sync --path-display-size=10000"))))
|
||
(cl-loop for value in mega-result
|
||
for localpath = (alist-get 'LOCALPATH value)
|
||
collect `((path . ,(if (file-directory-p localpath)
|
||
(concat localpath "/")
|
||
localpath))
|
||
(enabled . ,(seq-contains-p
|
||
'("Pending" "Loading" "Running")
|
||
(alist-get 'RUN_STATE value)))))))
|
||
#+end_src
|
||
|
||
And get the same data from the tree.
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--tree-get-paths (tree &optional kind)
|
||
"Get paths from TREE.
|
||
|
||
TREE is a form a defined by `my/index--tree-get'. KIND is either a
|
||
filter by the kind attribute or nil, in which case all paths are
|
||
returned.
|
||
|
||
The return value is a list of strings."
|
||
(cl-loop for elem in tree
|
||
when (or (null kind) (eq (alist-get :kind elem) kind))
|
||
collect (alist-get :path elem)
|
||
append (my/index--tree-get-paths
|
||
(alist-get :children elem) kind)))
|
||
#+end_src
|
||
|
||
With that information, we can generate commands to synchronize the required and actual sync paths.
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--mega-local-path (path)
|
||
"Get path in the MEGA cloud by the local path PATH."
|
||
(string-replace my/index-root "/" path))
|
||
|
||
(defun my/index--mega-commands (full-tree tree)
|
||
"Get commands to sync the mega-sync state with TREE.
|
||
|
||
FULL-TREE and TREE are forms as defined by `my/index--tree-get'. TREE
|
||
is the narrowed FULL-TREE (returned by `my/index--tree-narrow').
|
||
|
||
The return value is a list of commands as defined by
|
||
`my/index--commands-display'."
|
||
(let* ((paths-all (my/index--tree-get-paths full-tree))
|
||
(mega-paths-to-enable (my/index--tree-get-paths tree 'mega))
|
||
(mega-info (my/index--mega-data-from-sync))
|
||
(mega-paths-enabled (seq-map
|
||
(lambda (e) (alist-get 'path e))
|
||
(seq-filter (lambda (e) (alist-get 'enabled e))
|
||
mega-info)))
|
||
(mega-paths-disabled (seq-map
|
||
(lambda (e) (alist-get 'path e))
|
||
(seq-filter (lambda (e) (not (alist-get 'enabled e)))
|
||
mega-info))))
|
||
(append
|
||
(cl-loop for path in (seq-difference mega-paths-to-enable mega-paths-enabled)
|
||
if (seq-contains-p mega-paths-disabled path)
|
||
collect (list (format "mega-sync -r \"%s\"" path) "Mega enable sync" 5)
|
||
else append (list
|
||
(list (format "mega-mkdir -p \"%s\""
|
||
(my/index--mega-local-path path))
|
||
"Mega mkdirs" 4)
|
||
(list (format "mega-sync \"%s\" \"%s\""
|
||
path (my/index--mega-local-path path))
|
||
"Mega add sync" 5)))
|
||
(cl-loop for path in (seq-difference
|
||
(seq-intersection mega-paths-enabled paths-all)
|
||
mega-paths-to-enable)
|
||
collect (list
|
||
(format "mega-sync -d \"%s\""
|
||
(substring path 0 (1- (length path))))
|
||
"Mega remove sync" 4)))))
|
||
#+end_src
|
||
|
||
#+RESULTS:
|
||
: my/index--mega-commands
|
||
**** Git repos
|
||
To sync git, we just need to clone the required git repos. Removing the repos is handled by the folder sync commands.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--git-commands (tree)
|
||
"Get commands to clone the yet uncloned git repos in TREE.
|
||
|
||
TREE is a form a defined by `my/index--tree-get'. This is supposed to
|
||
be the tree narrowed to the current machine (`my/index--tree-narrow').
|
||
|
||
The return value is a list of commands as defined by
|
||
`my/index--commands-display'."
|
||
(cl-loop for elem in tree
|
||
for path = (alist-get :path elem)
|
||
when (and (eq (alist-get :kind elem) 'git)
|
||
(or (not (file-directory-p path))
|
||
(directory-empty-p path)))
|
||
collect (list (format "git clone \"%s\" \"%s\""
|
||
(alist-get :remote elem)
|
||
path)
|
||
"Init git repos" 2)
|
||
append (my/index--git-commands (alist-get :children elem))))
|
||
#+end_src
|
||
|
||
**** Wakatime
|
||
So, that's it for synchronization. A few other things are needed here.
|
||
|
||
I use [[https://wakatime.com/][WakaTime]] to track my coding activity, and I don't like the alphanumeric prefixes in my coding stats. Fortunately, =wakatime-cli= provides an option called [[https://github.com/wakatime/wakatime-cli/blob/develop/USAGE.md#project-map-section][projectmap]] to rename projects, so we just have to generate its contents.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--bare-project-name (name)
|
||
"Remove the alphanumeric prefix from NAME.
|
||
|
||
E.g. 10.03.R.01 Project Name -> Project Name."
|
||
(replace-regexp-in-string
|
||
(rx bos (+ (| num alpha "." "-")) space) "" name))
|
||
|
||
(defun my/index--wakatime-escape (string)
|
||
"Escape STRING for use in a WakaTime config file."
|
||
(thread-last
|
||
string
|
||
(replace-regexp-in-string (rx "'") "\\\\'")
|
||
(replace-regexp-in-string (rx "(") "\\\\(")
|
||
(replace-regexp-in-string (rx ")") "\\\\)")))
|
||
|
||
(defun my/index--wakatime-get-map-tree (tree)
|
||
"Get a list of (folder-name . bare-project-name) pairs from TREE.
|
||
|
||
TREE is a form as defined by `my/index--tree-get'.
|
||
\"bare-project-name\" is project name without the alphanumeric
|
||
prefix."
|
||
(cl-loop for elem in tree
|
||
for name = (alist-get :name elem)
|
||
if (eq (alist-get :kind elem) 'git)
|
||
collect (cons (my/index--wakatime-escape name)
|
||
(my/index--wakatime-escape
|
||
(my/index--bare-project-name name)))
|
||
if (and (eq (alist-get :kind elem) 'git)
|
||
(alist-get :symlink elem))
|
||
collect (cons (my/index--wakatime-escape
|
||
;; lmao
|
||
;; /a/b/c/ -> c
|
||
;; /a/b/c -> b
|
||
(file-name-nondirectory
|
||
(directory-file-name
|
||
(file-name-directory (alist-get :symlink elem)))))
|
||
(my/index--wakatime-escape
|
||
(my/index--bare-project-name name)))
|
||
append (my/index--wakatime-get-map-tree (alist-get :children elem))))
|
||
#+end_src
|
||
|
||
And insert that in =wakatime.cfg= if necessary.
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--wakatime-commands (tree)
|
||
"Get commands to update WakaTime config from TREE.
|
||
|
||
TREE is a form a defined by `my/index--tree-get'. The return value is
|
||
a list of commands as defined by `my/index--commands-display'."
|
||
(require 'ini)
|
||
(let* ((map-tree (my/index--wakatime-get-map-tree tree))
|
||
(map-tree-encoding (ini-encode `(("projectmap" . ,map-tree))))
|
||
(map-tree-saved (with-temp-buffer
|
||
(insert-file-contents (expand-file-name "~/.wakatime.cfg"))
|
||
(string-match-p (regexp-quote map-tree-encoding)
|
||
(buffer-string)))))
|
||
(unless map-tree-saved
|
||
(let ((insert-command (list (format "echo \"\n\n%s\" >> ~/.wakatime.cfg"
|
||
map-tree-encoding)
|
||
"Update WakaTime config" 9)))
|
||
(list (list (format "sed -i -z 's/\\[projectmap\\]\\n[^[]*//g' ~/.wakatime.cfg")
|
||
"Update WakaTime config" 9)
|
||
insert-command)))))
|
||
#+end_src
|
||
|
||
**** Symlinks
|
||
The last part here is creating symbolic links.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/index-get-symlink-commands (tree)
|
||
"Get commands to create symlinks from TREE.
|
||
|
||
TREE is a form a defined by `my/index--tree-get'. The return value is
|
||
a list of commands as defined by `my/index--commands-display'."
|
||
(cl-loop for elem in tree
|
||
for path = (alist-get :path elem)
|
||
for symlink = (alist-get :symlink elem)
|
||
when (and symlink (not (string-match-p (rx "/" eos) symlink)))
|
||
do (user-error "Wrong symlink: %s (should be a directory)" symlink)
|
||
when (and path symlink
|
||
(or (file-exists-p symlink)
|
||
(file-exists-p (substring symlink 0 -1)))
|
||
(not (file-symlink-p (substring symlink 0 -1))))
|
||
collect (list (format "rm -rf %s" (substring symlink 0 -1))
|
||
"Remove files to make symlinks" 6)
|
||
when (and path symlink
|
||
(not (file-symlink-p (substring symlink 0 -1))))
|
||
collect (list (format "ln -s '%s' '%s'" path
|
||
(substring symlink 0 -1))
|
||
"Make symlinks" 7)
|
||
append (my/index-get-symlink-commands (alist-get :children elem))))
|
||
#+end_src
|
||
|
||
#+RESULTS:
|
||
: my/index-get-symlink-commands
|
||
|
||
**** Run all commands
|
||
And put that all together.
|
||
|
||
First, as I want to check what's going to be executed, let's make a function to display commands in a separate buffer.
|
||
|
||
The mode definition is as follows:
|
||
#+begin_src emacs-lisp
|
||
(defvar my/index-commands-mode-map
|
||
(let ((keymap (make-sparse-keymap)))
|
||
(define-key keymap (kbd "C-c C-c") #'my/index-commands-exec)
|
||
(define-key keymap (kbd "q") #'my/quit-window-and-buffer)
|
||
(when (fboundp 'evil-define-key*)
|
||
(evil-define-key* 'normal keymap
|
||
"q" #'my/quit-window-and-buffer))
|
||
keymap)
|
||
"Keymap for `biome-api-error-mode'.")
|
||
|
||
(define-derived-mode my/index-commands-mode sh-mode "Index Commands"
|
||
"A mode to display index commands.")
|
||
#+end_src
|
||
|
||
#+begin_src emacs-lisp
|
||
(defvar-local my/index-commands nil
|
||
"Commands to be executed by `my/index-commands-exec'")
|
||
|
||
(defun my/index--commands-display (commands)
|
||
"Display COMMANDS in a buffer.
|
||
|
||
COMMANDS is a list of commands as defined by `my/index--commands-display'."
|
||
(unless commands
|
||
(user-error "No commands to display"))
|
||
(let ((buffer (get-buffer-create "*index commands*"))
|
||
(groups (seq-sort-by
|
||
(lambda (g) (nth 2 (nth 1 g)))
|
||
#'<
|
||
(seq-group-by (lambda (c) (nth 1 c))
|
||
commands))))
|
||
(with-current-buffer buffer
|
||
(my/index-commands-mode)
|
||
(let ((inhibit-read-only t)
|
||
commands-sequence)
|
||
(erase-buffer)
|
||
(setq-local my/index-commands nil)
|
||
(cl-loop for g in groups
|
||
for group-name = (car g)
|
||
for elems = (cdr g)
|
||
do (insert "# " group-name "\n")
|
||
do (cl-loop for elem in elems
|
||
do (push (nth 0 elem) my/index-commands)
|
||
do (insert (nth 0 elem) "\n")))
|
||
(setq-local buffer-read-only t)))
|
||
(switch-to-buffer buffer)))
|
||
#+end_src
|
||
|
||
In order to execute these commands, [[https://www.gnu.org/software/emacs/manual/html_node/emacs/Compilation.html][compile]] with =bash -x= on a temporary file is quite sufficient.
|
||
#+begin_src emacs-lisp
|
||
(defun my/index-commands-exec ()
|
||
(interactive)
|
||
(unless (eq major-mode 'my/index-commands-mode)
|
||
(user-error "Not shell mode"))
|
||
(let ((filename (make-temp-file "index-commands-")))
|
||
(write-region (point-min) (point-max) filename)
|
||
(compile (concat "bash -x " filename))))
|
||
#+end_src
|
||
|
||
I'll also try to save some time by caching the resulting index tree. =file-has-changed-p= is pretty helpful in that.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defvar my/index--tree nil
|
||
"The last version of the index tree.")
|
||
|
||
(defun my/index--tree-retrive ()
|
||
"Retrive the last version of the index tree.
|
||
|
||
This function returns the last saved version of the index tree if it
|
||
is still valid. Otherwise, it re-parses the index file."
|
||
(setq
|
||
my/index--tree
|
||
(cond ((string-equal (buffer-file-name) my/index-file)
|
||
(my/index--tree-get))
|
||
((or (null my/index--tree)
|
||
(file-has-changed-p my/index-file 'index))
|
||
(with-temp-buffer
|
||
(insert-file-contents my/index-file)
|
||
(let ((buffer-file-name my/index-file))
|
||
(my/index--tree-get))))
|
||
(t my/index--tree))))
|
||
#+end_src
|
||
|
||
With that, we can make the main entrypoint.
|
||
#+begin_src emacs-lisp
|
||
(defun my/index-commands-sync ()
|
||
"Sync the filesystem with the index."
|
||
(interactive)
|
||
(let* ((full-tree (my/index--tree-retrive)))
|
||
(my/index--tree-verify full-tree)
|
||
(let* ((tree (my/index--tree-narrow full-tree))
|
||
(mega-commands (my/index--mega-commands full-tree tree))
|
||
(mapping (my/index--filesystem-tree-mapping full-tree tree))
|
||
(folder-commands (my/index--filesystem-commands mapping))
|
||
(git-commands (my/index--git-commands tree))
|
||
(waka-commands (my/index--wakatime-commands tree))
|
||
(symlink-commands (my/index-get-symlink-commands tree)))
|
||
(my/index--commands-display (append mega-commands folder-commands git-commands
|
||
waka-commands symlink-commands)))))
|
||
#+end_src
|
||
*** Navigation
|
||
The last piece is the navigation interface.
|
||
|
||
Of course, plain dired does the job fine, thanks to the relatively low-depth filesystem structure. But I still want a navigation interface like =M-x projectile-switch-project=.
|
||
|
||
**** Navigation data
|
||
There are two slight problems with that.
|
||
|
||
First, the index tree does not always have the full info. For instance, I have the =10.03.A Artifacts= folder, which I sync with MEGA and which has child folders like =10.03.A.01 smth= and so on. Names of the latter are not stored anywhere because I don't see the point, which means we have to extract that from the filesystem.
|
||
|
||
Second, as it turns out, there have to be two levels for navigation, which are delimited by the =project= property. I'm not sure if that the optimal way to implement Jonny.Decimal, but it works for me.
|
||
|
||
So, a function to tackle the first problem:
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--nav-extend (name path)
|
||
"Find all index-related files in PATH.
|
||
|
||
NAME is the name of the root index entry, e.g. \"10.01
|
||
Something\". If PATH containts folders like \"10.01.01
|
||
Something\", \"10.01.02 ...\", they will be returned.
|
||
|
||
The return value is a form as defined by `my/index--nav-get'."
|
||
(when (file-directory-p path)
|
||
(let* ((number (my/index--extact-number name))
|
||
(files (mapcar
|
||
(lambda (f) (cons f (concat path f)))
|
||
(seq-filter (lambda (f) (not (string-prefix-p "." f)))
|
||
(directory-files path))))
|
||
(matching-files
|
||
(seq-filter
|
||
(lambda (f) (and (file-directory-p (cdr f))
|
||
(string-prefix-p number (car f))))
|
||
files)))
|
||
(when (and (length> matching-files 0)
|
||
(length< matching-files (length files)))
|
||
(user-error "Extraneuous files in %s" path))
|
||
(cl-loop for (name-1 . path-1) in matching-files
|
||
append (if-let ((child-files (my/index--nav-extend name-1 (concat path-1 "/"))))
|
||
(mapcar
|
||
(lambda (child-datum)
|
||
(push name-1 (alist-get :names child-datum))
|
||
child-datum)
|
||
child-files)
|
||
`(((:names . (,name-1))
|
||
(:path . ,(concat path-1 "/")))))))))
|
||
#+end_src
|
||
|
||
And one to get the navigation data structure.
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--nav-get (tree &optional names)
|
||
"Get the navigation structure from TREE.
|
||
|
||
TREE is a form as defined by `my/index--tree-get'. NAMES is a
|
||
list of names of the parent entries, e.g. (\"10.01 Something\"), used
|
||
for recursive calls.
|
||
|
||
The result is a list of alists with the following keys:
|
||
- `:names` - list of names, e.g.
|
||
(\"10.01 Something\" \"10.01.01 Something\")
|
||
- `:path` - path to the folder, e.g.
|
||
\"/path/10 stuff/10.01 Something/10.01.01 Something/\"
|
||
- `:child-navs` - list of child navigation structures (optional)"
|
||
(seq-sort-by
|
||
(lambda (item) (alist-get :path item))
|
||
#'string-lessp
|
||
(cl-reduce
|
||
(lambda (acc elem)
|
||
(let* ((name (alist-get :name elem))
|
||
(path (alist-get :path elem)))
|
||
(cond ((alist-get :project elem)
|
||
(let ((current-nav `((:names . (,@names ,name))
|
||
(:path . ,path))))
|
||
(when-let (child-navs
|
||
(and (alist-get :children elem)
|
||
(my/index--nav-get (alist-get :children elem))))
|
||
(setf (alist-get :child-navs current-nav) child-navs))
|
||
(push current-nav acc)))
|
||
((alist-get :children elem)
|
||
(when-let (child-navs (my/index--nav-get
|
||
(alist-get :children elem)
|
||
`(,@names ,name)))
|
||
(cl-loop for child-nav in child-navs
|
||
do (push child-nav acc))))
|
||
(t (if-let ((extended-nav (my/index--nav-extend name path)))
|
||
(cl-loop for child-nav in extended-nav
|
||
do (setf (alist-get :names child-nav)
|
||
(append names (list name)
|
||
(alist-get :names child-nav)))
|
||
do (push child-nav acc))
|
||
(push `((:names . (,@names ,name))
|
||
(:path . ,path))
|
||
acc))))
|
||
acc))
|
||
tree
|
||
:initial-value nil)))
|
||
#+end_src
|
||
|
||
It also makes sense to cache results of the above.
|
||
#+begin_src emacs-lisp
|
||
(defvar my/index--nav nil
|
||
"Navigation stucture for the index.")
|
||
|
||
(defun my/index--nav-retrive ()
|
||
"Retrive the navigation structure from the index file.
|
||
|
||
The return value is a form as defined by `my/index--nav-get'."
|
||
(if (or (null my/index--nav)
|
||
(file-has-changed-p my/index-file 'nav))
|
||
(let ((tree (my/index--tree-retrive)))
|
||
(setq my/index--nav (my/index--nav-get
|
||
(my/index--tree-narrow tree))))
|
||
my/index--nav))
|
||
#+end_src
|
||
**** Emacs interface
|
||
As for Emacs interface, =completing-read= is sufficient, except that I don't want [[https://github.com/radian-software/prescient.el][prescient.el]] to interfere with the default ordering of elements.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/index--nav-prompt (nav)
|
||
"Prompt the user for the navigation item to select.
|
||
|
||
NAV is a structure as defined by `my/index--nav-get'."
|
||
(let* ((collection
|
||
(mapcar (lambda (item)
|
||
(cons (car (last (alist-get :names item)))
|
||
(alist-get :path item)))
|
||
nav))
|
||
(vertico-sort-function nil))
|
||
(cdr
|
||
(assoc
|
||
(completing-read "Index: " collection nil t)
|
||
collection))))
|
||
|
||
(defun my/index--nav-find-path (nav path)
|
||
"Find the navigation item in NAV with the given PATH.
|
||
|
||
NAV is a structure as defined by `my/index--nav-get'."
|
||
(seq-find
|
||
(lambda (item)
|
||
(string-prefix-p (alist-get :path item) path))
|
||
nav))
|
||
|
||
(defun my/index-nav (arg &optional func)
|
||
"Navigate the filesystem index.
|
||
|
||
If ARG is nil, navigate all levels sequentially from the top one.
|
||
|
||
If ARG is '(4), select another directory from the same level.
|
||
|
||
FUNC is the function to call with the selected path. It defaults
|
||
to `dired' if used interactively."
|
||
(interactive (list current-prefix-arg #'dired))
|
||
(let* ((nav (my/index--nav-retrive))
|
||
(current-nav (my/index--nav-find-path
|
||
nav (expand-file-name default-directory)))
|
||
(current-child-navs (alist-get :child-navs current-nav)))
|
||
(cond ((null arg)
|
||
(let ((selected (my/index--nav-find-path
|
||
nav
|
||
(my/index--nav-prompt nav))))
|
||
(if-let (child-navs (alist-get :child-navs selected))
|
||
(funcall func (my/index--nav-prompt child-navs))
|
||
(funcall func (alist-get :path selected)))))
|
||
((and (equal arg '(4)) current-child-navs)
|
||
(funcall func (my/index--nav-prompt current-child-navs)))
|
||
((and (equal arg '(4)) (null current-child-navs))
|
||
(funcall func (my/index--nav-prompt nav))))))
|
||
#+end_src
|
||
|
||
Finally, something that I can bind to a key.
|
||
#+begin_src emacs-lisp
|
||
(defun my/index-nav-with-select-file (arg)
|
||
(interactive (list current-prefix-arg))
|
||
(my/index-nav
|
||
arg
|
||
(lambda (dir)
|
||
(let ((default-directory dir))
|
||
(projectile-find-file)))))
|
||
|
||
(defun my/index-open-file ()
|
||
(interactive)
|
||
(find-file my/index-file))
|
||
|
||
(my-leader-def
|
||
:infix "i"
|
||
"" '(:wk "index")
|
||
"i" #'my/index-nav
|
||
"s" #'my/index-commands-sync
|
||
"p" #'my/index-nav-with-select-file
|
||
"f" #'my/index-open-file)
|
||
#+end_src
|
||
*** Export tree
|
||
I also need the tree to use in my =sqrt-data=, so let's export this to JSON.
|
||
|
||
#+begin_src emacs-lisp
|
||
(defun my/index-export (file)
|
||
(interactive (list (read-file-name "File: " "~/logs-sync/data/index.json")))
|
||
(let ((full-tree (my/index--tree-retrive)))
|
||
(unless (file-exists-p (file-name-directory file))
|
||
(make-directory (file-name-directory file) t))
|
||
(with-temp-file file
|
||
(insert (json-encode full-tree))
|
||
(json-pretty-print-buffer))))
|
||
#+end_src
|
||
** Utilities
|
||
*** pass
|
||
:PROPERTIES:
|
||
:MODULE_NAME: pass
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-pass.el :comments links
|
||
:END:
|
||
I use [[https://www.passwordstore.org/][pass]] as my password manager. Expectedly, there is an Emacs frontend for it.
|
||
|
||
This package is pretty good to manage the password database. I use [[https://github.com/SqrtMinusOne/password-store-ivy][password-store-ivy]] (another package of mine) to actually type passwords. [[https://github.com/carnager/rofi-pass][rofi-pass]] is another good option.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package pass
|
||
:straight t
|
||
:commands (pass)
|
||
:init
|
||
(my-leader-def "ak" #'pass)
|
||
:config
|
||
(setq pass-show-keybindings nil))
|
||
#+end_src
|
||
|
||
Also I use =password-store-get= in a few places in my config, and by default it returns =nil= if I make an error in the password, which inconvinient if I want to run the command in =setq=. So:
|
||
#+begin_src emacs-lisp
|
||
(defun my/password-store-get (entry)
|
||
(if-let ((res (password-store-get entry)))
|
||
res
|
||
(my/password-store-get entry)))
|
||
|
||
(defun my/password-store-get-field (entry field)
|
||
(if-let (field (password-store-get-field entry field))
|
||
field
|
||
(my/password-store-get-field entry field)))
|
||
#+end_src
|
||
*** Docker
|
||
:PROPERTIES:
|
||
:MODULE_NAME: docker
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-docker.el :comments links
|
||
:END:
|
||
A package to manage docker containers from Emacs.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package docker
|
||
:straight t
|
||
:commands (docker)
|
||
:init
|
||
(my-leader-def "ao" 'docker))
|
||
#+end_src
|
||
|
||
*** proced
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-apps
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-apps.el :comments links
|
||
:END:
|
||
proced is an Emacs built-it process viewer, like top.
|
||
|
||
#+begin_src emacs-lisp
|
||
(my-leader-def "ah" 'proced)
|
||
(setq proced-auto-update-interval 1)
|
||
(add-hook 'proced-mode-hook (lambda ()
|
||
(visual-line-mode -1)
|
||
(setq-local truncate-lines t)
|
||
(proced-toggle-auto-update 1)))
|
||
#+end_src
|
||
*** Guix
|
||
:PROPERTIES:
|
||
:MODULE_NAME: guix
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-guix.el :comments links
|
||
:END:
|
||
An Emacs package to help managing GNU Guix.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package guix
|
||
:straight t
|
||
:if (executable-find "guix")
|
||
:commands (guix)
|
||
:init
|
||
(my-leader-def "ag" 'guix)
|
||
(defun geiser-company--setup (&rest args)
|
||
"A dummy function.")
|
||
(defvar geiser-repl-company-p nil
|
||
"A dummy variable."))
|
||
#+end_src
|
||
*** Pinentry
|
||
:PROPERTIES:
|
||
:MODULE_NAME: emacs-pinentry
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-emacs-pinentry.el :comments links
|
||
:END:
|
||
Emacs-based pinentry works great on Termux.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package pinentry
|
||
:straight t
|
||
:if my/is-termux
|
||
:config
|
||
(setenv "GPG_AGENT_INFO" nil) ;; use emacs pinentry
|
||
(setq auth-source-debug t)
|
||
|
||
(setq epg-gpg-program "gpg") ;; not necessary
|
||
(require 'epa-file)
|
||
(epa-file-enable)
|
||
(setq epa-pinentry-mode 'loopback)
|
||
(setq epg-pinentry-mode 'loopback)
|
||
(pinentry-start))
|
||
#+end_src
|
||
** Productivity
|
||
:PROPERTIES:
|
||
:MODULE_NAME: productivity-apps
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-productivity-apps.el :comments links
|
||
:END:
|
||
*** pomm
|
||
My package for doing Pomodoro timer.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package pomm
|
||
:straight t
|
||
;; :straight (:local-repo "~/Code/Emacs/pomm" :files (:defaults "resources"))
|
||
:commands (pomm pomm-third-time)
|
||
:init
|
||
(my-leader-def "ap" #'pomm)
|
||
;; (my-leader-def "ap" #'pomm-third-time)
|
||
(setq alert-default-style 'libnotify)
|
||
(setq pomm-audio-enabled t)
|
||
(setq pomm-audio-player-executable (executable-find "mpv"))
|
||
:config
|
||
(pomm-mode-line-mode)
|
||
(add-hook 'pomm-on-status-changed-hook #'pomm--sync-org-clock)
|
||
(add-hook 'pomm-third-time-on-status-changed-hook
|
||
#'pomm-third-time--sync-org-clock))
|
||
#+end_src
|
||
*** hledger
|
||
[[hledger.org/][hledger]] is a plain-text double-entry accounting software. I use it for managing my personal finances, and thus far it's great.
|
||
|
||
| Guix dependency |
|
||
|-----------------|
|
||
| hledger |
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package hledger-mode
|
||
:straight t
|
||
:mode (rx ".journal" eos)
|
||
:config
|
||
(setq hledger-jfile (concat org-directory "/ledger/ledger.journal"))
|
||
(add-hook 'hledger-mode-hook
|
||
(lambda ()
|
||
(make-local-variable 'company-backends)
|
||
(add-to-list 'company-backends 'hledger-company))))
|
||
|
||
(use-package flycheck-hledger
|
||
:straight t
|
||
:after (hledger-mode))
|
||
#+end_src
|
||
|
||
Here are some usage notes.
|
||
|
||
The fastest way to enter new entiries to the journal is by running =hledger add=
|
||
|
||
Then, run =hledger bs= to check whether the balance sheet matches the ground truth (e.g. the bank UI).
|
||
|
||
If it doesn't the simplest way to check for the differences is by running =hledger register <item>=.
|
||
|
||
Here are some interesting commands to run:
|
||
- =hledger incomestatement <query>=, where =<query>= is the account prefix. e.g. =expenses= or =revenues=.
|
||
- add =--pivot=payee= to get grouping by transaction descriptions
|
||
- add =-B= to cast currencies
|
||
|
||
*** Calendar
|
||
Emacs' built-in calendar. Can even calculate sunrise and sunset times.
|
||
|
||
#+begin_src emacs-lisp
|
||
(setq calendar-date-style 'iso) ;; YYYY/mm/dd
|
||
(setq calendar-week-start-day 1)
|
||
(setq calendar-time-display-form '(24-hours ":" minutes))
|
||
|
||
(setq calendar-latitude 59.9375)
|
||
(setq calendar-longitude 30.308611)
|
||
#+end_src
|
||
** Calc
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-apps
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-apps.el :comments links
|
||
:END:
|
||
[[https://github.com/kickingvegas/Casual][casual]] is a transient interface for the built-in calc.el
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package casual
|
||
:straight (:host github :repo "kickingvegas/Casual")
|
||
:after calc
|
||
:config
|
||
(general-define-key
|
||
:states '(normal)
|
||
:keymaps 'calc-mode-map
|
||
"M-o" #'casual-main-menu))
|
||
#+end_src
|
||
** Chess
|
||
:PROPERTIES:
|
||
:MODULE_NAME: chess
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-chess.el :comments links
|
||
:END:
|
||
*** chess.el
|
||
chess.el is a package by John Wiegley.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package chess
|
||
:commands (chess-pgn-mode)
|
||
:straight t)
|
||
#+end_src
|
||
|
||
*** Render PGN
|
||
A Python script to convert a PGN string to SVG:
|
||
#+begin_src python :tangle ~/bin/python-scripts/render_pgn.py
|
||
import chess
|
||
import chess.svg
|
||
import chess.pgn
|
||
import cairosvg
|
||
import io
|
||
import sys
|
||
|
||
line = sys.argv[1]
|
||
out_file = sys.argv[2]
|
||
is_fen = False
|
||
|
||
if len(sys.argv) > 3 and sys.argv[3] == 'true':
|
||
is_fen = True
|
||
|
||
def parse_pgn(line):
|
||
game = chess.pgn.read_game(io.StringIO(line))
|
||
game = game.end()
|
||
board = game.board()
|
||
return board
|
||
|
||
def parse_fen(line):
|
||
board = chess.Board(line)
|
||
return board
|
||
|
||
if is_fen:
|
||
board = parse_fen(line)
|
||
else:
|
||
board = parse_pgn(line)
|
||
|
||
svg = chess.svg.board(board=board)
|
||
png = cairosvg.svg2png(
|
||
bytestring=svg, write_to=out_file, output_width=400, output_height=400
|
||
)
|
||
#+end_src
|
||
|
||
=python-chess= is installed in the =dev= profile because =python3= is also there.
|
||
|
||
An =org-babel= block:
|
||
#+begin_src emacs-lisp
|
||
(setq my/chess-python "/home/pavel/.guix-extra-profiles/dev/dev/bin/python3")
|
||
|
||
(defun org-babel-execute:pgn (body params)
|
||
(let ((out-file (or (alist-get :file params)
|
||
(org-babel-temp-file "pgn-" ".png"))))
|
||
(org-babel-eval
|
||
(format "%s %s '%s' '%s'" my/chess-python
|
||
"~/bin/python-scripts/render_pgn.py"
|
||
body out-file)
|
||
"")
|
||
nil))
|
||
|
||
(defvar org-babel-default-header-args:pgn
|
||
'((:results . "file") (:exports . "results"))
|
||
"Default arguments for evaluating a pgn source block.")
|
||
|
||
(defun org-babel-execute:fen (body params)
|
||
(let ((out-file (or (alist-get :file params)
|
||
(org-babel-temp-file "fen-" ".png"))))
|
||
(org-babel-eval
|
||
(format "%s %s '%s' '%s' true" my/chess-python
|
||
"~/bin/python-scripts/render_pgn.py"
|
||
body out-file)
|
||
"")
|
||
nil))
|
||
|
||
(defvar org-babel-default-header-args:fen
|
||
'((:results . "file") (:exports . "results"))
|
||
"Default arguments for evaluating a pgn source block.")
|
||
#+end_src
|
||
|
||
** Fun
|
||
:PROPERTIES:
|
||
:MODULE_NAME: misc-apps
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-misc-apps.el :comments links
|
||
:END:
|
||
*** Discord integration
|
||
:PROPERTIES:
|
||
:MODULE_NAME: discord
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-discord.el :comments links
|
||
:END:
|
||
Integration with Discord. Shows which file is being edited in Emacs.
|
||
|
||
In order for this to work in Guix, a service is necessary - [[file:Desktop.org::*Discord rich presence][Discord rich presence]].
|
||
|
||
Some functions to override the displayed message:
|
||
#+begin_src emacs-lisp
|
||
(defun my/elcord-mask-buffer-name (name)
|
||
(cond
|
||
((string-match-p (rx bos (? "CAPTURE-") (= 14 num) "-" (* not-newline) ".org" eos) name)
|
||
"<ORG-ROAM>")
|
||
((string-match-p (rx "german" (* not-newline) ".org" eos) name)
|
||
"<LEARNING>")
|
||
((string-match-p (rx bos (+ num) "-" (+ num) "-" (+ num) ".org" eos) name)
|
||
"<ORG-JOURNAL>")
|
||
((string-match-p (rx bos "EXWM") name)
|
||
"<EXWM>")
|
||
((string-match-p (rx bos "*Org-Habit") name)
|
||
"<ORG>")
|
||
((with-current-buffer (get-buffer name)
|
||
(derived-mode-p 'telega-root-mode 'telega-chat-mode))
|
||
"<TELEGA>")
|
||
(t name)))
|
||
|
||
(defun my/elcord-buffer-details-format-functions ()
|
||
(format "Editing %s" (my/elcord-mask-buffer-name (buffer-name))))
|
||
|
||
(defun my/elcord-update-presence-mask-advice (r)
|
||
(list (my/elcord-mask-buffer-name (nth 0 r)) (nth 1 r)))
|
||
#+end_src
|
||
|
||
Create a symlink for flatpak:
|
||
#+begin_src emacs-lisp
|
||
(defun my/elcord-symlink ()
|
||
(shell-command-to-string "bash -c 'ln -sf {app/com.discordapp.Discord,$XDG_RUNTIME_DIR}/discord-ipc-0 &'"))
|
||
#+end_src
|
||
|
||
And the package configuration:
|
||
#+begin_src emacs-lisp
|
||
(use-package elcord
|
||
:straight t
|
||
:if (and (or
|
||
(string= (system-name) "violet")
|
||
(string= (system-name) "eminence")
|
||
(string= (system-name) "iris"))
|
||
(not my/remote-server)
|
||
(not my/nested-emacs))
|
||
:config
|
||
(setq elcord-buffer-details-format-function #'my/elcord-buffer-details-format-functions)
|
||
(advice-add 'elcord--try-update-presence :filter-args #'my/elcord-update-presence-mask-advice)
|
||
(add-to-list 'elcord-mode-text-alist '(telega-chat-mode . "Telega Chat"))
|
||
(add-to-list 'elcord-mode-text-alist '(telega-root-mode . "Telega Root"))
|
||
;; (elcord-mode)
|
||
(my/elcord-symlink))
|
||
#+end_src
|
||
*** Snow
|
||
#+begin_src emacs-lisp
|
||
(use-package snow
|
||
:straight (:repo "alphapapa/snow.el" :host github)
|
||
:commands (snow))
|
||
#+end_src
|
||
*** Redacted
|
||
#+begin_src emacs-lisp
|
||
(use-package redacted
|
||
:commands (redacted-mode)
|
||
:straight (:host github :repo "bkaestner/redacted.el"))
|
||
#+end_src
|
||
*** Zone
|
||
#+begin_src emacs-lisp
|
||
(use-package zone
|
||
:ensure nil
|
||
:commands (zone)
|
||
:config
|
||
(setq original-zone-programs (copy-sequence zone-programs)))
|
||
|
||
(defun my/zone-with-select ()
|
||
(interactive)
|
||
(let ((zone-programs
|
||
(vector
|
||
(intern
|
||
(completing-read
|
||
"Zone programs"
|
||
(cl-mapcar 'symbol-name original-zone-programs))))))
|
||
(zone)))
|
||
#+end_src
|
||
|
||
*** Gource
|
||
:PROPERTIES:
|
||
:MODULE_NAME: gource
|
||
:header-args:emacs-lisp: :tangle /home/pavel/.emacs.d/modules/sqrt-gource.el :comments links
|
||
:END:
|
||
[[https://gource.io/][Gource]] is a program that draws an animated graph of users changing the repository over time.
|
||
|
||
Although it can work without extra effort (just run =gource= in a [[https://git-scm.com/][git]] repo), there are some tweaks that can be done:
|
||
- Gource supports using custom pictures for users. [[https://en.gravatar.com/][Gravatar]] is an obvious place to get these.
|
||
- Occasionally, the same people have different names and/or emails in history.\\
|
||
It may happen when people use forges like [[https://gitlab.com/][GitLab]] or just have different settings on different machines. It would be nice to merge these names.
|
||
- Visualizing the history of multiple repositories (e.g. frontend and backend) requires combining multiple gource logs.
|
||
|
||
So, why not try doing that with Emacs?
|
||
|
||
**** Gravatars
|
||
Much to my surprise, Emacs turned out to have a built-in package called [[https://github.com/emacs-mirror/emacs/blob/master/lisp/image/gravatar.el][gravatar.el]].
|
||
|
||
So, let's make a function to retrieve a gravatar and save it:
|
||
#+begin_src emacs-lisp
|
||
(defun my/gravatar-retrieve-sync (email file-name)
|
||
"Get gravatar for EMAIL and save it to FILE-NAME."
|
||
(let ((gravatar-default-image "identicon")
|
||
(gravatar-size nil)
|
||
(coding-system-for-write 'binary)
|
||
(write-region-annotate-functions nil)
|
||
(write-region-post-annotation-function nil))
|
||
(write-region
|
||
(image-property (gravatar-retrieve-synchronously email) :data)
|
||
nil file-name nil :silent)))
|
||
#+end_src
|
||
|
||
To use these images, we need to save them to some folder and use usernames as file names. The folder:
|
||
#+begin_src emacs-lisp
|
||
(setq my/gravatar-folder "/home/pavel/.cache/gravatars/")
|
||
#+end_src
|
||
|
||
And the function that downloads a gravatar if necessary:
|
||
#+begin_src emacs-lisp
|
||
(defun my/gravatar-save (email author)
|
||
"Download gravatar for EMAIL.
|
||
|
||
AUTHOR is the username."
|
||
(let ((file-name (concat my/gravatar-folder author ".png")))
|
||
(mkdir my/gravatar-folder t)
|
||
(unless (file-exists-p file-name)
|
||
(message "Fetching gravatar for %s (%s)" author email)
|
||
(my/gravatar-retrieve-sync email file-name))))
|
||
#+end_src
|
||
|
||
**** Merging authors
|
||
Now to merging authors.
|
||
|
||
Gource itself uses only usernames (without emails), but we can use =git log= to get both. The required information can be extracted like that:
|
||
#+begin_src bash
|
||
git log --pretty=format:"%ae|%an" | sort | uniq -c | sed "s/^[ \t]*//;s/ /|/"
|
||
#+end_src
|
||
|
||
The output is a list of pipe-separated strings, where the values are:
|
||
- Number of occurrences for this combination of username and email
|
||
- Email
|
||
- Username
|
||
|
||
Of course, that part would have to be changed appropriately for other version control systems if you happen to use one.
|
||
|
||
So, below is one hell of a function that wraps this command and tries to merge emails and usernames belonging to one author:
|
||
#+begin_src emacs-lisp
|
||
(defun my/git-get-authors (repo &optional authors-init)
|
||
"Extract and merge all combinations of authors & emails from REPO.
|
||
|
||
REPO is the path to a git repository.
|
||
|
||
AUTHORS-INIT is the previous output of `my/git-get-authors'. It can
|
||
be used to extract that information from multiple repositories.
|
||
|
||
The output is a list of alists with following keys:
|
||
- emails: list of (<email> . <count>)
|
||
- authors: list of (<username> . <count>)
|
||
- email: the most popular email
|
||
- author: the most popular username
|
||
I.e. one alist is all emails and usernames of one author."
|
||
(let* ((default-directory repo)
|
||
(data (shell-command-to-string
|
||
"git log --pretty=format:\"%ae|%an\" | sort | uniq -c | sed \"s/^[ \t]*//;s/ /|/\""))
|
||
(authors
|
||
(cl-loop for string in (split-string data "\n")
|
||
if (= (length (split-string string "|")) 3)
|
||
collect (let ((datum (split-string string "|")))
|
||
`((count . ,(string-to-number (nth 0 datum)))
|
||
(email . ,(downcase (nth 1 datum)))
|
||
(author . ,(nth 2 datum)))))))
|
||
(mapcar
|
||
(lambda (datum)
|
||
(setf (alist-get 'author datum)
|
||
(car (cl-reduce
|
||
(lambda (acc author)
|
||
(if (> (cdr author) (cdr acc))
|
||
author
|
||
acc))
|
||
(alist-get 'authors datum)
|
||
:initial-value '(nil . -1))))
|
||
(setf (alist-get 'email datum)
|
||
(car (cl-reduce
|
||
(lambda (acc email)
|
||
(if (> (cdr email) (cdr acc))
|
||
email
|
||
acc))
|
||
(alist-get 'emails datum)
|
||
:initial-value '(nil . -1))))
|
||
datum)
|
||
(cl-reduce
|
||
(lambda (acc val)
|
||
(let* ((author (alist-get 'author val))
|
||
(email (alist-get 'email val))
|
||
(count (alist-get 'count val))
|
||
(saved-value
|
||
(seq-find
|
||
(lambda (cand)
|
||
(or (alist-get email (alist-get 'emails cand)
|
||
nil nil #'string-equal)
|
||
(alist-get author (alist-get 'authors cand)
|
||
nil nil #'string-equal)
|
||
(alist-get email (alist-get 'authors cand)
|
||
nil nil #'string-equal)
|
||
(alist-get author (alist-get 'emails cand)
|
||
nil nil #'string-equal)))
|
||
acc)))
|
||
(if saved-value
|
||
(progn
|
||
(if (alist-get email (alist-get 'emails saved-value)
|
||
nil nil #'string-equal)
|
||
(cl-incf (alist-get email (alist-get 'emails saved-value)
|
||
nil nil #'string-equal)
|
||
count)
|
||
(push (cons email count) (alist-get 'emails saved-value)))
|
||
(if (alist-get author (alist-get 'authors saved-value)
|
||
nil nil #'string-equal)
|
||
(cl-incf (alist-get author (alist-get 'authors saved-value)
|
||
nil nil #'string-equal)
|
||
count)
|
||
(push (cons author count) (alist-get 'authors saved-value))))
|
||
(setq saved-value
|
||
(push `((emails . ((,email . ,count)))
|
||
(authors . ((,author . ,count))))
|
||
acc)))
|
||
acc))
|
||
authors
|
||
:initial-value authors-init))))
|
||
#+end_src
|
||
|
||
Despite the probable we-enjoy-typing-ness of the implementation, it's actually pretty simple:
|
||
- The output of =git log= is parsed into a list of alists with =count=, =email= and =author= as keys.
|
||
- This list is reduced by =cl-reduce= into a list of alists with =emails= and =authors= as keys and the respective counts as values, e.g. =((<email-1> . 1) (<email-2> . 3))=.\\
|
||
I've seen a couple of cases where people would swap their username and email (lol), so =seq-find= also looks for an email in the list of authors and vice versa.
|
||
- The =mapcar= call determines the most popular email and username for each authors.
|
||
|
||
The output is another list of alists, now with the following keys:
|
||
- =emails= - list of elements like =(<email> . <count>)=
|
||
- =authors= - list of elements like =(<author-name> . <count>)=
|
||
- =email= - the most popular email
|
||
- =author= - the most popular username.
|
||
|
||
**** Running for multiple repos
|
||
This section was mostly informed by [[https://github.com/acaudwell/Gource/wiki/Visualizing-Multiple-Repositories][this page]] in the [[https://github.com/acaudwell/Gource/wiki][gource wiki]].
|
||
|
||
As I said above, by default =gource= just creates a visualization for the current repo. To change something in it, we need to invoke the program like that: =gource --output-custom-log PATH=, where =PATH= is either the path to the log file or =-= for stdout.
|
||
|
||
The log consists of lines of pipe-separated strings, e.g.:
|
||
#+begin_example
|
||
1600769568|dsofronov|A|/studentor/.dockerignore
|
||
1600769568|dsofronov|A|/studentor/.editorconfig
|
||
1600769568|dsofronov|A|/studentor/.flake8
|
||
1600769568|dsofronov|A|/studentor/.gitignore
|
||
#+end_example
|
||
|
||
where the values of one line are:
|
||
- UNIX timestamp
|
||
- Author name
|
||
- =A= for add, =M= for modify, and =D= for delete
|
||
- Path to file
|
||
|
||
The file has to be sorted by the timestamp in ascending order.
|
||
|
||
So, the function that prepares the log for one repository:
|
||
#+begin_src emacs-lisp
|
||
(defun my/gource-prepare-log (repo authors)
|
||
"Create gource log string for REPO.
|
||
|
||
AUTHORS is the output of `my/git-get-authors'."
|
||
(let ((log (shell-command-to-string
|
||
(concat
|
||
"gource --output-custom-log - "
|
||
repo)))
|
||
(authors-mapping (make-hash-table :test #'equal))
|
||
(prefix (file-name-base repo)))
|
||
(cl-loop for author-datum in authors
|
||
for author = (alist-get 'author author-datum)
|
||
do (my/gravatar-save (alist-get 'email author-datum) author)
|
||
do (cl-loop for other-author in (alist-get 'authors author-datum)
|
||
unless (string-equal (car other-author) author)
|
||
do (puthash (car other-author) author
|
||
authors-mapping)))
|
||
(cl-loop for line in (split-string log "\n")
|
||
concat (let ((fragments (split-string line "|")))
|
||
(when (> (length fragments) 3)
|
||
(when-let (mapped-author (gethash (nth 1 fragments)
|
||
authors-mapping))
|
||
(setf (nth 1 fragments) mapped-author))
|
||
(setf (nth 3 fragments)
|
||
(concat "/" prefix (nth 3 fragments))))
|
||
(string-join fragments "|"))
|
||
concat "\n")))
|
||
#+end_src
|
||
|
||
This function:
|
||
- Downloads a gravatar for each author
|
||
- Replaces all usernames of one author with the most frequent one
|
||
- Prepends the file path with the repository name.
|
||
|
||
The output is a string in the gource log format as described above.
|
||
|
||
Finally, as we need to invoke all of this for multiple repositories, why not do that with [[https://www.gnu.org/software/emacs/manual/html_node/emacs/Dired.html][dired]]:
|
||
#+begin_src emacs-lisp
|
||
(defun my/gource-dired-create-logs (repos log-name)
|
||
"Create combined gource log for REPOS.
|
||
|
||
REPOS is a list of strings, where a string is a path to a git repo.
|
||
LOG-NAME is the path to the resulting log file.
|
||
|
||
This function is meant to be invoked from `dired', where the required
|
||
repositories are marked."
|
||
(interactive (list (or (dired-get-marked-files nil nil #'file-directory-p)
|
||
(user-error "Select at least one directory"))
|
||
(read-file-name "Log file name: " nil "combined.log")))
|
||
(let ((authors
|
||
(cl-reduce
|
||
(lambda (acc repo)
|
||
(my/git-get-authors repo acc))
|
||
repos
|
||
:initial-value nil)))
|
||
(with-temp-file log-name
|
||
(insert
|
||
(string-join
|
||
(seq-filter
|
||
(lambda (line)
|
||
(not (string-empty-p line)))
|
||
(seq-sort-by
|
||
(lambda (line)
|
||
(if-let (time (car (split-string line "|")))
|
||
(string-to-number time)
|
||
0))
|
||
#'<
|
||
(split-string
|
||
(mapconcat
|
||
(lambda (repo)
|
||
(my/gource-prepare-log repo authors))
|
||
repos "\n")
|
||
"\n")))
|
||
"\n")))))
|
||
#+end_src
|
||
|
||
This function extracts authors from each repository and merges the logs as required by gource, that is sorting the result by time in ascending order.
|
||
|
||
**** Using the function
|
||
To use the function above, mark the required repos in a dired buffer and run =M-x my/gource-dired-create-logs=. This also works nicely with [[https://github.com/Fuco1/dired-hacks][dired-subtree]], in case your repos are located in different folders.
|
||
|
||
The function will create a combined log file (by default =combined.log=). To visualize the log, run:
|
||
#+begin_src bash
|
||
gource <log-file> --user-image-dir <path-to-gravatars>
|
||
#+end_src
|
||
|
||
Check the [[https://github.com/acaudwell/Gource][README]] for possible parameters, such as the speed of visualization, different elements, etc. That's it!
|
||
|
||
I thought about making something like a [[https://github.com/magit/transient][transient.el]] wrapper around the =gource= command but figured it wasn't worth the effort for something that I run just a handful of times in a year.
|
||
|
||
*** Memes
|
||
Generate memes from Emacs.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package imgur
|
||
:straight (:host github :repo "larsmagne/imgur.el")
|
||
:defer t)
|
||
|
||
(use-package meme
|
||
:straight (:host github :repo "larsmagne/meme" :files (:defaults "images"))
|
||
:commands (meme))
|
||
#+end_src
|
||
|
||
*** Ed
|
||
My text editor isn't old enough.
|
||
|
||
#+begin_src emacs-lisp
|
||
(use-package ed-mode
|
||
:straight (:host github :repo "ryanprior/ed-mode")
|
||
:commands (ed))
|
||
#+end_src
|
||
* Guix settings
|
||
| Guix dependency | Description |
|
||
|---------------------+-------------------------------|
|
||
| emacs-vterm | The vterm package |
|
||
| ripgrep | A recursive search tool |
|
||
| the-silver-searcher | Another recursive search tool |
|
||
| texinfo | |
|
||
|
||
#+NAME: packages
|
||
#+begin_src emacs-lisp :tangle no
|
||
(when (fboundp #'my/format-guix-dependencies)
|
||
(my/format-guix-dependencies))
|
||
#+end_src
|
||
|
||
#+begin_src scheme :tangle .config/guix/manifests/emacs.scm :noweb yes
|
||
(specifications->manifest
|
||
'("emacs"
|
||
<<packages()>>))
|
||
#+end_src
|