From 1c4f950e3fe51d63f1d5a40bdb3b4ca02f046223 Mon Sep 17 00:00:00 2001 From: Jason Hilder Date: Mon, 26 Oct 2026 08:06:00 +0200 Subject: [PATCH] Emacs config wip, split to better fit modules. --- config/emacs/init.el | 66 +++---- config/emacs/modules/build.el | 41 ++++ config/emacs/modules/dev.el | 99 ++++++++++ config/emacs/modules/development.el | 48 ----- config/emacs/modules/org.el | 58 ++++++ config/emacs/modules/rss.el | 278 ++++++++++++++++++++++++++++ 6 files changed, 503 insertions(+), 87 deletions(-) create mode 100644 config/emacs/modules/build.el create mode 100644 config/emacs/modules/dev.el delete mode 100644 config/emacs/modules/development.el create mode 100644 config/emacs/modules/org.el create mode 100644 config/emacs/modules/rss.el diff --git a/config/emacs/init.el b/config/emacs/init.el index a07c262..1c760d8 100644 --- a/config/emacs/init.el +++ b/config/emacs/init.el @@ -4,9 +4,9 @@ ;; C-h works too, except in normal/motion state where it moves ;; to the window on the left. ;;; Code: - ;;;; 1. Emac setup, files, packages and paths -(let ((bin (expand-file-name "~/.local/bin"))) +(dolist (bin (list (expand-file-name "~/.local/bin") + (expand-file-name "~/.go/bin"))) (add-to-list 'exec-path bin) (setenv "PATH" (concat bin path-separator (getenv "PATH")))) @@ -63,16 +63,25 @@ (which-key-mode 1) (add-to-list 'warning-suppress-types '(files)) -(use-package jbeans-theme - :config - (load-theme 'jbeans t)) +(use-package doom-themes :ensure t) +(load-theme 'doom-one t) +(doom-themes-set-faces 'user + '(default :background "#131313") + '(fringe :background "#131313") + '(line-number :background "#131313") + '(line-number-current-line :background "#131313") + '(mode-line :background "#1a1a1a") + '(mode-line-inactive :background "#131313")) -(set-face-attribute 'default nil :family "JetBrainsMono Nerd Font" :height 120) -(setq-default line-spacing '(4 . 4)) +(use-package doom-modeline + :ensure t + :init (doom-modeline-mode 1)) ;; set mode line at the top of each window (setq-default header-line-format mode-line-format mode-line-format nil) -(set-face-attribute 'header-line nil :inherit 'mode-line :background "#131313") + +(set-face-attribute 'default nil :family "JetBrainsMono Nerd Font" :height 120) +(setq-default line-spacing '(4 . 4)) ;; Command launcher. (keymap-global-set "C-S-p" #'execute-extended-command) @@ -234,41 +243,20 @@ "." #'dired-omit-mode (kbd "RET") #'dired-find-file)) -;;;; 8. Build -(setq compile-command "./build.sh" - compilation-read-command nil - compilation-ask-about-save nil - compilation-scroll-output t) +;;;; 8. Modules - additional functionality outside of the core init.el +;; Setup for orgmode +(load (locate-user-emacs-file "modules/org.el") nil 'nomessage) -(add-hook 'compilation-filter-hook #'ansi-color-compilation-filter) +;; Setup for rss feed +(load (locate-user-emacs-file "modules/rss.el") nil 'nomessage) -;; Compilation buffer always at the bottom, full frame width. -(add-to-list 'display-buffer-alist - '((derived-mode . compilation-mode) - (display-buffer-reuse-window display-buffer-at-bottom) - (window-height . 0.45))) - -;; C-c C-c builds from the project root. -;; C-c b asks first: the prompt is pre-filled with ./build.sh, so type the args after it. -(defun my/project-compile-prompt () - "Run `project-compile', prompting to edit the command." - (interactive) - (let ((compilation-read-command t)) - (call-interactively #'project-compile))) - -(keymap-global-set "C-c C-c" #'project-compile) -(keymap-global-set "C-c b" #'my/project-compile-prompt) - -;; Focus the compilation window when a build starts. -(add-hook 'compilation-start-hook - (lambda (proc) - (when-let* ((win (get-buffer-window (process-buffer proc)))) - (select-window win)))) - -;;;; 9. Modules - additional functionality outside of the core init.el ;; Magit and the :git command live in modules/git.el. (load (locate-user-emacs-file "modules/git.el") nil 'nomessage) + +;; Setup for personal build commands and system. +(load (locate-user-emacs-file "modules/build.el") nil 'nomessage) + ;; Setup for dev languages, modes, lsp and lsp keybinds. -(load (locate-user-emacs-file "modules/development.el") nil 'nomessage) +(load (locate-user-emacs-file "modules/dev.el") nil 'nomessage) ;;; init.el ends here diff --git a/config/emacs/modules/build.el b/config/emacs/modules/build.el new file mode 100644 index 0000000..617eb9f --- /dev/null +++ b/config/emacs/modules/build.el @@ -0,0 +1,41 @@ +;;; build.el --- Project build, set up for Evil -*- lexical-binding: t; -*- +;;; Commentary: +;; Loaded from init.el after Evil. +;; +;; :build Run ./build.sh from the project root. +;; :build ARGS Run ./build.sh ARGS, e.g. :build release -v +;; +;; The compilation buffer opens at the bottom (45% height, full width) and +;; takes focus when the build starts. ANSI colours are rendered. +;; Kill a running build with M-x kill-compilation. +;;; Code: + +(setq compile-command "./build.sh" + compilation-read-command nil ; run without prompting + compilation-ask-about-save nil + compilation-scroll-output t) + +(add-hook 'compilation-filter-hook #'ansi-color-compilation-filter) + +;; Compilation buffer at the bottom, full frame width. +(add-to-list 'display-buffer-alist + '((derived-mode . compilation-mode) + (display-buffer-reuse-window display-buffer-at-bottom) + (window-height . 0.45))) + +;; Focus the compilation window when a build starts. +(add-hook 'compilation-start-hook + (lambda (proc) + (when-let* ((win (get-buffer-window (process-buffer proc)))) + (select-window win)))) + +;; :build runs ./build.sh; :build ARGS appends ARGS. +(evil-define-command my/ex-build (args) + "Run `./build.sh ARGS' from the project root." + (interactive "") + (let ((compile-command (concat "./build.sh " args))) ; nil args -> "" + (project-compile))) + +(evil-ex-define-cmd "build" #'my/ex-build) + +;;; build.el ends here diff --git a/config/emacs/modules/dev.el b/config/emacs/modules/dev.el new file mode 100644 index 0000000..fef8fa9 --- /dev/null +++ b/config/emacs/modules/dev.el @@ -0,0 +1,99 @@ +;;; development.el --- Languages, Eglot and code navigation -*- lexical-binding: t; -*- +;;; Commentary: +;; Loaded from init.el. Requires Evil. +;; +;; Languages: +;; Markdown markdown-mode. +;; PHP php-mode, PSR-2 style, 4 spaces. +;; Odin odin-mode from git (needs Emacs 30+ for :vc), Eglot + OLS. +;; Go go-mode, Eglot + gopls; organize imports and format on save. +;; +;; Normal/motion state: +;; gd definitions gD references K eldoc +;; C-t jump back ESC closes any open eldoc window. +;;; Code: + +;;;; Markdown +(use-package markdown-mode) + +;;;; PHP +(use-package php-mode + :mode "\\.php\\'" + :hook + (php-mode . (lambda () + (setq-local indent-tabs-mode nil) + (setq-local tab-width 4) + (setq-local c-basic-offset 4))) + :custom + (php-mode-coding-style 'psr2)) + +;;;; Odin +(use-package odin-mode + :vc (:url "https://github.com/mattt-b/odin-mode" + :rev :newest) + :mode "\\.odin\\'" + :hook (odin-mode . eglot-ensure)) + +;;;; Go +;; Emacs started from the desktop doesn't inherit fish env. +(dolist (d '("/usr/local/go/bin" "/home/jason/.go/bin")) + (add-to-list 'exec-path d) + (setenv "PATH" (concat d ":" (getenv "PATH")))) +(setenv "GOPATH" "/home/jason/.go") +(setenv "GOCACHE" "/home/jason/.go/cache") + +(defun my/go-before-save () + "Organize imports and format the buffer through Eglot." + (when (eglot-managed-p) + (eglot-code-actions nil nil "source.organizeImports" t) + (eglot-format-buffer))) + +(use-package go-mode + :mode "\\.go\\'" + :hook ((go-mode . eglot-ensure) + (go-mode . (lambda () + (setq-local tab-width 4) + (add-hook 'before-save-hook #'my/go-before-save nil t))))) + +;;;; Eglot +(use-package eglot + :ensure nil + :config + (setq eglot-autoshutdown t) + (add-to-list 'eglot-ignored-server-capabilities :signatureHelpProvider) + + ;; Explicit OLS configuration. + (add-to-list + 'eglot-server-programs + '(odin-mode + . ("/home/jason/.local/bin/ols" + :initializationOptions + (:odin_command "/home/jason/.odin/odin" + :enable_document_symbols t + :enable_format t + :collections + [(:name "core" + :path "/home/jason/.odin/core") + (:name "vendor" + :path "/home/jason/.odin/vendor")]))))) + +;; OLS built-in definitions. +(setenv "OLS_BUILTIN_FOLDER" "/home/jason/ThirdParty/ols/builtin") + +;;;; Code navigation +;; Vim-style code navigation. +(evil-define-key '(normal motion) 'global + "gd" #'xref-find-definitions + "gD" #'xref-find-references + "K" #'eldoc + (kbd "C-t") #'xref-go-back) + +;; ESC closes any open eldoc window. +(defun my/eldoc-close (&rest _) + "Quit every window showing an *eldoc* buffer." + (dolist (w (window-list)) + (when (string-prefix-p "*eldoc" (buffer-name (window-buffer w))) + (quit-window nil w)))) +(advice-add 'evil-force-normal-state :after #'my/eldoc-close) + +;;; development.el ends here diff --git a/config/emacs/modules/development.el b/config/emacs/modules/development.el deleted file mode 100644 index 901284c..0000000 --- a/config/emacs/modules/development.el +++ /dev/null @@ -1,48 +0,0 @@ -;;;; Markdown -(use-package markdown-mode) - -;;;; Odin -(use-package odin-mode - :vc (:url "https://github.com/mattt-b/odin-mode" - :rev :newest) - :mode "\\.odin\\'" - :hook (odin-mode . eglot-ensure)) - -(use-package eglot - :ensure nil - :config - (setq eglot-autoshutdown t) - (add-to-list 'eglot-ignored-server-capabilities :signatureHelpProvider) - - ;; Explicit OLS configuration. - (add-to-list - 'eglot-server-programs - '(odin-mode - . ("/home/jason/.local/bin/ols" - :initializationOptions - (:odin_command "/home/jason/.odin/odin" - :enable_document_symbols t - :enable_format t - :collections - [(:name "core" - :path "/home/jason/.odin/core") - (:name "vendor" - :path "/home/jason/.odin/vendor")]))))) - -;; OLS built-in definitions. -(setenv "OLS_BUILTIN_FOLDER" "/home/jason/ThirdParty/ols/builtin") - -;;;; Code navigation -;; Vim-style code navigation. -(evil-define-key '(normal motion) 'global - "gd" #'xref-find-definitions - "gD" #'xref-find-references - "K" #'eldoc - (kbd "C-t") #'xref-go-back) - -;; ESC closes any open eldoc window. -(defun my/eldoc-close (&rest _) - (dolist (w (window-list)) - (when (string-prefix-p "*eldoc" (buffer-name (window-buffer w))) - (quit-window nil w)))) -(advice-add 'evil-force-normal-state :after #'my/eldoc-close) diff --git a/config/emacs/modules/org.el b/config/emacs/modules/org.el new file mode 100644 index 0000000..04a0309 --- /dev/null +++ b/config/emacs/modules/org.el @@ -0,0 +1,58 @@ +;;; org.el --- Org appearance -*- lexical-binding: t; -*- +;;; Code: + +;; Styled stars, bullets, tags, tables and blocks. +(use-package org-modern + :hook (org-mode . org-modern-mode)) + +(setq org-directory "~/Projects/notebook") +(setq org-startup-indented t ; visual indent per level, no real spaces + org-hide-emphasis-markers t ; *bold* shows bold, markers hidden + org-pretty-entities t ; \rarr, \alpha etc. as symbols + org-ellipsis " ▾" ; folded-heading marker + org-startup-folded 'show2levels ; open with headings only + org-fontify-whole-heading-line t + org-fontify-done-headline t + org-fontify-quote-and-verse-blocks t + org-src-fontify-natively t ; highlight src blocks with their own mode + org-src-tab-acts-natively t + org-edit-src-content-indentation 0 + org-return-follows-link t) + +;; org-modern's recommended settings. +(setq org-auto-align-tags nil + org-tags-column 0 + org-catch-invisible-edits 'show-and-error + org-special-ctrl-a/e t + org-insert-heading-respect-content t) + +(add-hook 'org-mode-hook #'visual-line-mode) ; wrap at window edge +(add-hook 'org-mode-hook (lambda () (display-line-numbers-mode -1))) ; prose reads better without + +;; doom-themes' own Org fontification fixes (blocks, headings). +(doom-themes-org-config) + +;; Heading sizes; 'user survives theme reloads, same as in init.el. +(doom-themes-set-faces 'user + '(org-document-title :height 1.3 :weight 'bold) + '(org-level-1 :height 1.2 :weight 'bold) + '(org-level-2 :height 1.15 :weight 'bold) + '(org-level-3 :height 1.1 :weight 'bold) + '(org-level-4 :height 1.0 :weight 'bold)) + +(defun my/notes-index () + "Open the notes index." + (interactive) + (find-file (expand-file-name "index.org" org-directory))) + +(keymap-global-set "C-c n" #'my/notes-index) + +;; Evil's TAB (= C-i, jump-forward) shadows Org's. Rebind for Org buffers only. +(with-eval-after-load 'org + (evil-define-key 'normal org-mode-map + (kbd "TAB") #'org-cycle ; fold/unfold the heading at point + (kbd "") #'org-cycle ; GUI emits , so bind both + (kbd "RET") #'org-open-at-point + (kbd "") #'org-shifttab)) ; S-TAB: cycle all headings + +;;; org.el ends here diff --git a/config/emacs/modules/rss.el b/config/emacs/modules/rss.el new file mode 100644 index 0000000..28f6ecc --- /dev/null +++ b/config/emacs/modules/rss.el @@ -0,0 +1,278 @@ +;;; rss.el --- Elfeed for YouTube feeds, played in mpv -*- lexical-binding: t; -*- +;;; Commentary: +;; Elfeed with a little glue for Evil. Needs the `elfeed' package (MELPA). +;; +;; :rss / :rss get open elfeed / also fetch all feeds now +;; +;; Search buffer RET show the entry +;; o play in mpv and mark read +;; r/u mark read / unread +;; A mark everything listed as read +;; c pick a channel/feed by name and show only it +;; s change the filter; TAB completes +tags +;; (type "+seb" TAB -> "+Sebastian_Lague") +;; gr fetch all feeds +;; q quit +;; Entry buffer o play in mpv +;; q close +;; +;; Every feed is tagged with its category (+youtube, +news) and with its +;; own channel tag: its name with spaces replaced by "_". So +;; "gingerBill" is +gingerBill and "Sebastian Lague" is +Sebastian_Lague. +;; Tags match exactly, but in the `s' prompt TAB completes them, ignoring +;; case. Give a feed a short tag of your own with a third argument: +;; (my/rss-yt "Jonathan Blow highlights" "UC..." 'blow). +;; +;; Everything else is your normal Evil keys. Nothing is fetched +;; automatically; fetching is always manual (:rss get / gr). Fetch errors +;; are logged in the *elfeed-log* buffer. +;; +;; Feeds live in `my/rss-categories' below, grouped by tag. Edit the list +;; and restart Emacs. +;;; Code: + +(use-package elfeed + :ensure t + :defer t + :custom + (elfeed-search-filter "@1-month-ago +unread") + ;; Be gentle with YouTube: few simultaneous connections. + (elfeed-curl-max-connections 4) + :config + ;; Not in :custom: `my/rss-feeds' is defined below, and :custom runs + ;; immediately, while :config waits until elfeed loads. + (setq elfeed-feeds (my/rss-feeds)) + (my/rss-apply-titles) + (my/rss-retag)) + +(defun my/rss-yt (name channel-id &optional tag) + "Feed entry for a YouTube channel, uploads only (no Shorts). +TAG, a symbol, overrides the channel tag made from NAME." + (let ((url (concat "https://www.youtube.com/feeds/videos.xml?playlist_id=UULF" + (substring channel-id 2)))) + (if tag (list name url tag) (list name url)))) + +(defvar my/rss-categories + (list + (list 'youtube + (my/rss-yt "Awesome" "UCXzw-OdotBUcNA9yhuYQBwA") + (my/rss-yt "Coding with Sphere" "UCpwl7jNE9PJc-lBTShNs5TQ") + (my/rss-yt "Computerphile" "UC9-y-6csu5WGm29I7JiwpnA") + (my/rss-yt "gingerBill" "UCUSck1dOH7VKmG4lRW7tZXg") + (my/rss-yt "Jonathan Blow highlights" "UCavhLwXoB9tkqd-KsXpovOQ") + (my/rss-yt "Karl Zylinski" "UC5YnADU2rChK7598A9gKygQ") + (my/rss-yt "Nic Barker" "UCz-yrxeZYIYdpEZgHGvIydA") + (my/rss-yt "Nir Lichtman" "UCAMu6Dso0ENoNm3sKpQsy0g") + (my/rss-yt "Ryan Fleury" "UCCsdwE2z_kRL3vfVsd5OGyA") + (my/rss-yt "Tariq10x" "UCXyisNI9ezbv7fvGMoSaFKg") + (my/rss-yt "The Coding Gopher" "UC-mTIBh__DzAqW495JHXy5A") + (my/rss-yt "Tsoding Daily" "UCrqM0Ym_NbK1fqeQG2VIohg") + (my/rss-yt "zyapguy" "UCc4IizbM0q_JySK3XUY0Drw") + (my/rss-yt "Dylan Falconer" "UCZzs3Umh6sRiYS_IQIQDThw") + (my/rss-yt "Gamefromscratch" "UCr-5TdGkKszdbboXXsFZJTQ") + (my/rss-yt "Jonas Tyroller" "UC_p_9arduPuxM8DHTGIuSOg") + (my/rss-yt "Miziziziz" "UCaoqVlqPTH78_xjTjTOMcmQ") + (my/rss-yt "Penusbmic" "UCjU-rielI7RsX1hqsCQdfhw") + (my/rss-yt "randy" "UCUmLRMERmJrmUtgnbFfknAg") + (my/rss-yt "randy uncut" "UCgXdyySqz7qH-lDiVG-7KwA") + (my/rss-yt "raylib" "UC8WIBkhYb5sBNqXO1mZ7WSQ") + (my/rss-yt "Sebastian Lague" "UCmtyQOKKmrMVaKuRXz02jbQ") + (my/rss-yt "Tantan" "UChl_NKOs1qqh_x7yJfaDpDw") + (my/rss-yt "ThinMatrix" "UCUkRj4qoT1bsWpE_C8lZYoQ") + (my/rss-yt "Brodie Robertson" "UCld68syR8Wi-GY_n4CaoJGA") + (my/rss-yt "ETA PRIME" "UC_0CVCfC_3iuHqmyClu59Uw") + (my/rss-yt "Benny Surge" "UCkih_7PNYICxAZi1omr2_Og") + (my/rss-yt "bizlychannel" "UCMPGiQ8gwDXFYpwQhX6kK9A") + (my/rss-yt "Daz Games" "UCDBCCMlFD-hArPbWv38FINA") + (my/rss-yt "Deep Dive Dev" "UC1yGsLABIDAqLVtPnoHbpEA") + (my/rss-yt "Flo Woelki" "UC18qytfIhR9cNEjUcgGLl3A") + (my/rss-yt "Gohjoe" "UCac9YlOpTLy_wToRb5Bo-DQ") + (my/rss-yt "Herbcode" "UC79nyXj9gujAj5CySiy2FAQ") + (my/rss-yt "hoff._world" "UCaezB99bVg9xlMS6Jn_ka0A") + (my/rss-yt "Inbound Shovel" "UCdYwjLVP-98bptdlQFO_5zQ") + (my/rss-yt "Jimmy Oakes" "UChp08hcL-qAJD4LK51ZZZvw") + (my/rss-yt "Juby Philosophy" "UCLrpO7U1lnmbRtVxji843-Q") + (my/rss-yt "Lance Hedrick" "UCR2_8uHyWcTSV6iIw6BN4rg") + (my/rss-yt "Mental Moshpit" "UC5a4hb7i2UAT6DxmGz72hdg") + (my/rss-yt "NIGHTRIDE" "UCuYLDxZWv-aal88czKkFvKA") + (my/rss-yt "Rockers Games" "UCeq6focWSVKJy5DR1JxDGyw") + (my/rss-yt "Slax" "UCc0V8-3xYIT6aNOAreAjRcw") + (my/rss-yt "StayAtHomeDev" "UCDshKIMtFjYreCzApleuV8w") + (my/rss-yt "Turkey Tom" "UCDKJdFer1phQI95UinPZehw") + (my/rss-yt "Zei" "UCywwEsnSmADUyhtSaq5dKNg") + (my/rss-yt "Zip Tie Tech" "UCUpt_5O1vYpWAGg77iNMQXA")) + (list 'news + '("Hacker News" "https://hnrss.org/frontpage?points=100"))) + "Feeds by tag: each entry is (TAG FEED...), a feed is (NAME URL [CHANNEL-TAG]). +The tag is what you filter on in elfeed, e.g. \"+youtube\". Each feed +also gets its own channel tag, see `my/rss-channel-tag'.") + +(defun my/rss-slug (name) + "Tag symbol made from NAME, with spaces replaced by \"_\"." + (intern (replace-regexp-in-string " +" "_" (string-trim name) t t))) + +(defun my/rss-channel-tag (feed) + "Channel tag for FEED, (NAME URL [TAG]): TAG if given, else NAME slugged." + (or (nth 2 feed) (my/rss-slug (car feed)))) + +(defun my/rss-feeds () + "Elfeed feed list, (URL CATEGORY CHANNEL-TAG), from `my/rss-categories'." + (mapcan (lambda (cat) + (mapcar (lambda (feed) + (list (cadr feed) (car cat) (my/rss-channel-tag feed))) + (cdr cat))) + my/rss-categories)) + +(defun my/rss-all-feeds () + "Every (NAME URL) in `my/rss-categories', across all tags." + (mapcan (lambda (cat) (copy-sequence (cdr cat))) my/rss-categories)) + +(defun my/rss-apply-titles () + "Give each feed the name from `my/rss-categories' as its display title. +YouTube playlist feeds all call themselves \"Videos\"; this overrides +that, so the list shows the channel and \"=Name\" filters work. The +title is stored as feed metadata, which fetching never overwrites." + (dolist (feed (my/rss-all-feeds)) + (setf (elfeed-meta (elfeed-db-get-feed (cadr feed)) :title) + (car feed)))) + +(defun my/rss-retag () + "Give already-fetched entries their feed's tags from `elfeed-feeds'. +Elfeed tags entries only as they arrive, so this catches up entries +fetched before a feed's tags changed. Safe to run any time." + (interactive) + (elfeed-db-ensure) + (let ((tags-by-url (make-hash-table :test #'equal)) + (n 0)) + (dolist (f elfeed-feeds) + (puthash (car f) (cdr f) tags-by-url)) + (maphash + (lambda (_id entry) + (let* ((tags (gethash (elfeed-entry-feed-id entry) tags-by-url)) + (missing (cl-set-difference tags (elfeed-entry-tags entry)))) + (when missing + (apply #'elfeed-tag entry missing) + (cl-incf n)))) + elfeed-db-entries) + (when (or (> n 0) (called-interactively-p 'any)) + (message "rss: retagged %d entries" n)))) + +;;;; Playing videos + +(defun my/rss-play (url &rest _) + "Play URL in mpv. Its output goes to the *mpv* buffer." + (message "mpv: starting %s (can take a few seconds)..." url) + (set-process-sentinel + (start-process "mpv" "*mpv*" "mpv" url) + (lambda (proc _event) + (when (and (eq (process-status proc) 'exit) + (/= (process-exit-status proc) 0)) + (message "mpv failed (exit %d), see the *mpv* buffer" + (process-exit-status proc)) + (display-buffer "*mpv*"))))) + +;; YouTube links go to mpv, everywhere in Emacs. +(with-eval-after-load 'browse-url + (add-to-list 'browse-url-handlers + '("\\`https?://\\(?:www\\.\\|m\\.\\)?\\(?:youtube\\.com\\|youtu\\.be\\)/" + . my/rss-play))) + +;;;; Elfeed + +(defvar my/rss-stagger 4 + "Seconds between feed fetches. YouTube answers with 404/500 when hit fast.") + +(defun my/rss-fetch () + "Fetch every feed, one at a time, `my/rss-stagger' seconds apart. +Run it again to fill in feeds that failed; entries are not duplicated." + (interactive) + (seq-do-indexed + (lambda (feed i) + (run-with-timer (* my/rss-stagger i) nil #'elfeed-update-feed (car feed))) + elfeed-feeds) + (message "rss: fetching %d feeds, about %ds" + (length elfeed-feeds) (* my/rss-stagger (length elfeed-feeds)))) + +(defun my/rss-all-tags () + "Every tag name: the ones in `elfeed-feeds' plus any in the database." + (delete-dups + (mapcar #'symbol-name + (append (mapcan (lambda (f) (copy-sequence (cdr f))) elfeed-feeds) + (elfeed-db-get-all-tags))))) + +(defun my/rss-filter-capf () + "Complete the +tag or -tag at point in the filter prompt." + (when (looking-back "\\(?:^\\| \\)[+-]\\([^ ]*\\)" (line-beginning-position)) + (list (match-beginning 1) (match-end 1) + (completion-table-case-fold (my/rss-all-tags)) + :exclusive 'no))) + +(defvar my/rss-filter-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "TAB") #'completion-at-point) + map) + "Extra keys for the filter prompt.") + +(defun my/rss-filter () + "Elfeed's live filter, with TAB completing +tags and -tags." + (interactive) + (minibuffer-with-setup-hook + (lambda () + (add-hook 'completion-at-point-functions #'my/rss-filter-capf nil t) + (use-local-map (make-composed-keymap my/rss-filter-map + (current-local-map)))) + (elfeed-search-live-filter))) + +(defun my/rss-mark-all-read () + "Mark every entry the current filter lists as read. +Works on elfeed's list directly, so it doesn't depend on the region or +on Evil's visual state. Clear the filter first to mark everything." + (interactive) + (let ((n (length elfeed-search-entries))) + (elfeed-untag elfeed-search-entries 'unread) + (elfeed-search-update :force) + (message "rss: marked %d entries read" n))) + +(defun my/rss-channel () + "Pick a feed by name and show only its entries (read and unread). +Filters on the feed URL, so it works whatever the feed calls itself. +Press `s' and clear the filter to go back." + (interactive) + (let* ((feeds (my/rss-all-feeds)) + (name (completing-read "Channel: " feeds nil t)) + (url (cadr (assoc name feeds)))) + (elfeed-search-set-filter + (concat "@6-months-ago =" (regexp-quote url))))) + +;; Elfeed's buffers start in motion state: Evil movement, no editing keys. +(evil-set-initial-state 'elfeed-search-mode 'motion) +(evil-set-initial-state 'elfeed-show-mode 'motion) + +(with-eval-after-load 'elfeed-search + (evil-define-key 'motion elfeed-search-mode-map + (kbd "RET") #'elfeed-search-show-entry + "o" #'elfeed-search-browse-url ; plays via `browse-url-handlers', marks read + "r" #'elfeed-search-untag-all-unread + "u" #'elfeed-search-tag-all-unread + "A" #'my/rss-mark-all-read + "c" #'my/rss-channel + "s" #'my/rss-filter + "gr" #'my/rss-fetch + "q" #'elfeed-search-quit-window)) + +(with-eval-after-load 'elfeed-show + (evil-define-key 'motion elfeed-show-mode-map + "o" #'elfeed-show-visit + "q" #'elfeed-kill-buffer)) + +;; :rss opens elfeed; :rss get also fetches every feed now. +(evil-define-command my/rss (args) + "Open elfeed, or fetch all feeds too with `:rss get'." + (interactive "") + (elfeed) + (when (equal (string-trim (or args "")) "get") + (my/rss-fetch))) + +(evil-ex-define-cmd "rss" #'my/rss) + +;;; rss.el ends here