Files
emacs-doom/config.el
Daneel f8c0c74531 fix(org-caldav): use url-digest-auth-storage with HA1 hash for Baikal Digest auth
url-digest-auth-user-pass does not exist. Correct var is url-digest-auth-storage
which stores HA1 = MD5(user:realm:pass), not plaintext password.
2026-02-24 19:47:14 +01:00

1927 lines
75 KiB
EmacsLisp
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
;;; $DOOMDIR/config.el -*- lexical-binding: t; -*-
;;; ============================================================
;;; USER IDENTITY
;;; ============================================================
(setq user-full-name "Martin Sukany"
user-mail-address "martin@sukany.cz")
;;; ============================================================
;;; THEME & FONT
;;; ============================================================
(setq doom-theme 'modus-vivendi-deuteranopia
doom-variable-pitch-font nil)
;; Font: JetBrains Mono preferred; fallback to Menlo (always on macOS)
;; Install: brew install --cask font-jetbrains-mono
(setq doom-font (if (find-font (font-spec :name "JetBrains Mono"))
(font-spec :family "JetBrains Mono" :size 14)
(font-spec :family "Menlo" :size 14)))
(setq display-line-numbers-type t)
;; Start Emacs maximized on every launch (macOS: fills screen, keeps menu bar)
(add-to-list 'initial-frame-alist '(fullscreen . maximized))
;;(add-to-list 'default-frame-alist '(fullscreen . maximized))
;;; ============================================================
;;; UI & DISPLAY
;;; ============================================================
(setq doom-modeline-refresh-rate 1.0)
(setq which-key-idle-delay 0.8
which-key-idle-secondary-delay 0.05)
;; Centered cursor mode — safe with macOS Zoom set to "Follow mouse cursor"
;; (NOT "Follow keyboard focus" — that causes viewport jumping)
(use-package! centered-cursor-mode
:config
(setq ccm-vpos-init 0.5 ; cursor centered on screen
ccm-step-size 2 ; smoother scrolling
ccm-recenter-at-end-of-file t)
;; Disable in terminal and special modes
(define-globalized-minor-mode my/global-ccm
centered-cursor-mode
(lambda ()
(unless (memq major-mode '(vterm-mode eshell-mode term-mode
treemacs-mode pdf-view-mode))
(centered-cursor-mode 1))))
(my/global-ccm +1))
;;; ============================================================
;;; MACOS / PLATFORM
;;; ============================================================
(setq mouse-autoselect-window nil ; don't switch window on mouse move
focus-follows-mouse nil ; don't change focus on mouse move
select-enable-clipboard t
select-enable-primary t
inhibit-splash-screen t)
;; PATH: ensure GUI Emacs sees the same paths as the terminal.
;; macOS GUI apps do not inherit the shell PATH, so we add critical
;; directories explicitly. Order matches the shell's $PATH priority.
(let ((extra-paths '("/opt/local/bin" ; MacPorts
"/opt/local/sbin" ; MacPorts sbin
"/opt/homebrew/bin" ; Homebrew (mu, msmtp, …)
"/opt/homebrew/sbin" ; Homebrew sbin
"/usr/local/bin" ; local wrappers (emacs script, …)
"/Library/TeX/texbin"))) ; MacTeX
(dolist (p (reverse extra-paths))
(setenv "PATH" (concat p ":" (getenv "PATH")))
(add-to-list 'exec-path p)))
;; macOS clipboard integration via pbcopy/pbpaste (works in terminal Emacs too)
(defun my/pbcopy (text &optional _push)
"Send TEXT to the macOS clipboard using pbcopy."
(let ((process-connection-type nil))
(let ((proc (start-process "pbcopy" "*pbcopy*" "pbcopy")))
(process-send-string proc text)
(process-send-eof proc))))
(defun my/pbpaste ()
"Return text from the macOS clipboard using pbpaste."
(when (executable-find "pbpaste")
(string-trim-right (shell-command-to-string "pbpaste"))))
(setq interprogram-cut-function #'my/pbcopy
interprogram-paste-function #'my/pbpaste)
;; Let Evil use the system clipboard (y/d/c go to system)
(after! evil
(setq evil-want-clipboard t))
;;; ============================================================
;;; CLIPBOARD IMAGE PASTE (macOS — cmd+v)
;;; ============================================================
;; Requires: brew install pngpaste
;;
;; cmd+v behaviour:
;; clipboard has image → saves to attachments/ → inserts link
;; clipboard has text → normal paste (yank)
;;
;; Org: [[./attachments/image-TIMESTAMP.png]]
;; Markdown: ![image-TIMESTAMP.png](./attachments/image-TIMESTAMP.png)
;;
;; attachments/ is always relative to the current file's directory.
(defun my/clipboard-has-image-p ()
"Return non-nil if macOS clipboard contains an image (requires pngpaste)."
(and (executable-find "pngpaste")
(let ((tmp (make-temp-file "emacs-imgcheck" nil ".png")))
(prog1 (= 0 (call-process "pngpaste" nil nil nil tmp))
(ignore-errors (delete-file tmp))))))
(defun my/paste-image-from-clipboard ()
"Save clipboard image to attachments/ subdir and insert link at point.
File: image-YYYYMMDD-HHMMSS.png. Supports org-mode and markdown-mode."
(interactive)
(let* ((base-dir (if buffer-file-name
(file-name-directory (buffer-file-name))
default-directory))
(attach-dir (expand-file-name "attachments" base-dir))
(filename (format-time-string "image-%Y%m%d-%H%M%S.png"))
(filepath (expand-file-name filename attach-dir))
(relpath (concat "attachments/" filename)))
(make-directory attach-dir t)
(if (= 0 (call-process "pngpaste" nil nil nil filepath))
(progn
(insert (pcase major-mode
('org-mode (format "[[./%s]]" relpath))
('markdown-mode (format "![%s](./%s)" filename relpath))
(_ relpath)))
(message "✓ Image saved: %s" relpath))
(error "pngpaste failed — no image in clipboard?"))))
(defun my/smart-paste ()
"Paste image if clipboard has one, else normal yank (text paste).
Bound to cmd+v in org-mode and markdown-mode."
(interactive)
(if (my/clipboard-has-image-p)
(my/paste-image-from-clipboard)
(yank)))
;; Bind cmd+v to smart paste in org and markdown
(map! :after org
:map org-mode-map
"s-v" #'my/smart-paste)
(map! :after markdown-mode
:map markdown-mode-map
"s-v" #'my/smart-paste)
;; macOS Zoom accessibility — cancel persp-mode's 2.5s cache timer after startup
;; (reduces unnecessary redraws that cause Zoom to jump)
(run-with-timer 3 nil
(lambda ()
(when (and (boundp 'persp-frame-buffer-predicate-buffer-list-cache--timer)
(timerp persp-frame-buffer-predicate-buffer-list-cache--timer))
(cancel-timer persp-frame-buffer-predicate-buffer-list-cache--timer)
(setq persp-frame-buffer-predicate-buffer-list-cache--timer nil)
(message "persp-mode 2.5s cache timer cancelled for Zoom accessibility"))))
;;; ============================================================
;;; MACOS GUI — FIXES
;;; ============================================================
;; Fix A: Ensure dashboard buffer starts in normal state (required for SPC leader)
(after! evil
(evil-set-initial-state '+doom-dashboard-mode 'normal))
;; Fix B: Standard macOS modifier keys for GUI Emacs
(when (display-graphic-p)
(setq mac-command-modifier 'super
mac-option-modifier 'meta
mac-right-option-modifier 'none))
;; Fix C: Disable mouse clicks in GUI Emacs (prevent accidental cursor movement)
;; Scroll wheel events ([wheel-up/down]) are NOT disabled — scrolling works normally.
;; Mouse movement does not switch windows (mouse-autoselect-window nil above).
(when (display-graphic-p)
;; Disable clicks only — NOT wheel events
(dolist (key '([mouse-1] [mouse-2] [mouse-3]
[double-mouse-1] [double-mouse-2] [double-mouse-3]
[triple-mouse-1] [triple-mouse-2] [triple-mouse-3]
[drag-mouse-1] [drag-mouse-2] [drag-mouse-3]
[down-mouse-1] [down-mouse-2] [down-mouse-3]))
(global-set-key key #'ignore))
(setq mouse-highlight nil))
;; macOS scroll wheel best practice (NS/Cocoa build):
;; - pixel-scroll-precision-mode is NOT used: it targets X11/Haiku and breaks
;; NS/Cocoa scroll event delivery (rebinds [wheel-up/down] to non-working handlers)
;; - Standard mwheel.el with conservative settings is reliable on macOS
;; - NS backend converts trackpad + physical wheel to [wheel-up]/[wheel-down] events
(when (display-graphic-p)
(require 'mwheel)
;; 3 lines per scroll tick; shift = 1 line; no progressive acceleration
(setq mouse-wheel-scroll-amount '(3 ((shift) . 1) ((meta) . 0) ((control) . text-scale))
mouse-wheel-progressive-speed nil ; constant speed, no acceleration
mouse-wheel-follow-mouse t ; scroll window under cursor
mouse-wheel-tilt-scroll t ; horizontal scroll with tilt/two-finger
mouse-wheel-flip-direction nil) ; standard direction (not natural)
;; Ensure mwheel-scroll is bound (Doom may remap these)
(global-set-key [wheel-up] #'mwheel-scroll)
(global-set-key [wheel-down] #'mwheel-scroll))
;;; ============================================================
;;; PERFORMANCE & GC
;;; ============================================================
(setq gc-cons-threshold (* 100 1024 1024) ; 100 MB
gc-cons-percentage 0.6)
;; GCMH — Doom's GC manager; increase idle delay to reduce redraws
(after! gcmh
(setq gcmh-idle-delay 'auto
gcmh-auto-idle-delay-factor 20
gcmh-high-cons-threshold (* 200 1024 1024))) ; 200 MB
(add-hook 'focus-out-hook #'garbage-collect)
;; Auto-save all buffers on idle (replaces noisy #file# autosave)
(setq auto-save-default nil)
(defun my/save-all-buffers () (save-some-buffers t))
(run-with-idle-timer 10 t #'my/save-all-buffers)
;; !!! WARNING: TLS verification disabled globally !!!
;; Required for self-signed certs on local services (ai.apps.sukany.cz etc.)
(setq gnutls-verify-error nil
gnutls-algorithm-priority "NORMAL:-VERS-TLS1.3")
;;; ============================================================
;;; ORG MODE — CORE
;;; ============================================================
(after! org
(require 'ox-hugo)
(setq org-directory "~/org/")
(setq org-default-notes-file (expand-file-name "inbox.org" org-directory))
;; Helper: return absolute path to a file inside org-directory
(defun ms/org-file (name)
"Return absolute path to NAME inside `org-directory`."
(expand-file-name name org-directory))
(setq org-todo-keywords
'((sequence "TODO(t)" "NEXT(n)" "WAIT(w@/!)" "|" "DONE(d!)" "CANCELLED(c@)")))
(setq org-log-done 'time)
(setq org-refile-targets '((org-agenda-files :maxlevel . 5))
org-outline-path-complete-in-steps nil
org-refile-use-outline-path 'file)
;; Return path to project.org in current Projectile project, if it exists
(defun my/project-org-file ()
"Return path to ./project.org in current Projectile project, if it exists."
(when-let ((root (projectile-project-root)))
(let ((f (expand-file-name "project.org" root)))
(when (file-exists-p f) f))))
;; Update all dynamic blocks before export
(add-hook 'org-export-before-processing-hook
(lambda (_backend) (org-update-all-dblocks)))
;; Visual: hide markup, pretty entities, compact tags
(setq org-startup-indented nil ; disable org-indent-mode -- conflicts with org-modern star display
org-hide-emphasis-markers t
org-pretty-entities t
org-ellipsis ""
org-auto-align-tags nil
org-tags-column 0)
;; Restore window layout after capture quit
(setq org-capture-restore-window-after-quit t))
;;; ============================================================
;;; ORG MODE — CAPTURE
;;; ============================================================
(after! org
(setq org-capture-templates
`(("i" "Inbox task" entry
(file ,(ms/org-file "inbox.org"))
"* TODO %?\n%U\n%a\n")
("n" "Note" entry
(file+headline ,(ms/org-file "inbox.org") "Notes")
"* %?\n%U\n%a\n")
("p" "Project task" entry
(file ,(ms/org-file "inbox.org"))
"* TODO %? :project:\n%U\n%a\n")
("s" "Clocked subtask" entry (clock)
"* TODO %?\n%U\n%a\n%i"
:empty-lines 1)
("j" "Journal" entry
(file+olp+datetree ,(ms/org-file "journal.org"))
"\n* %<%I:%M %p> - Journal :journal:\n\n%?\n\n"
:clock-in :clock-resume
:empty-lines 1)
("m" "Meeting" entry
(file+olp+datetree ,(ms/org-file "journal.org"))
"* %<%I:%M %p> - %^{Meeting title} :meetings:\nContext: %a\n\n%?\n\n"
:clock-in :clock-resume
:empty-lines 1)
("e" "Checking Email" entry
(file+olp+datetree ,(ms/org-file "journal.org"))
"* Checking Email :email:\n\n%?"
:clock-in :clock-resume
:empty-lines 1)
("w" "Weight" table-line
(file+headline ,(ms/org-file "metrics.org") "Weight")
"| %U | %^{Weight} | %^{Notes} |"
:kill-buffer t))))
;;; ============================================================
;;; ORG MODE — AGENDA
;;; ============================================================
(after! org
(setq org-agenda-files (list org-directory
(expand-file-name "projects" org-directory)
(expand-file-name "roam" org-directory)
(expand-file-name "notes" org-directory))))
;;; ============================================================
;;; ORG MODE — LATEX EXPORT
;;; ============================================================
;; LaTeX table export: tabular → tabularx{\linewidth} with lYYY column spec.
;; Uses with-temp-buffer + replace-match (literal t t) — avoids backslash issues
;; in replace-regexp-in-string lambda replacements on Emacs 31.
;; Uses Y column type (defined in document.org template: RaggedRight + auto-width X).
(defun my/org-latex-col-to-lyyy (spec)
"Convert tabular column SPEC to lYYY: first col l, rest Y (auto-width)."
(let ((ncols (max 1 (length (replace-regexp-in-string "[^lrcLRCpP]" "" spec)))))
(if (= ncols 1) "Y"
(concat "l" (make-string (1- ncols) ?Y)))))
(defun my/org-latex-fix-tabularx (table _backend _info)
"Convert tabular/tabularx → tabularx{\\linewidth}{lYYY} in LaTeX output."
(when (stringp table)
(with-temp-buffer
(insert table)
(goto-char (point-min))
;; \begin{tabular}{spec} or \begin{tabularx}{spec} → \begin{tabularx}{\linewidth}{lYYY}
(while (re-search-forward "\\\\begin{tabular[x]?}{\\([^}]*\\)}" nil t)
(let* ((spec (match-string 1))
(new-spec (my/org-latex-col-to-lyyy spec))
(repl (concat "\\begin{tabularx}{\\linewidth}{" new-spec "}")))
(replace-match repl t t)))
;; \end{tabular} → \end{tabularx} (skip if already tabularx)
(goto-char (point-min))
(while (re-search-forward "\\\\end{tabular}" nil t)
(replace-match "\\end{tabularx}" t t))
(buffer-string))))
;; Register filter on ox-latex load AND ensure it via a pre-processing hook
;; (belt+suspenders: whichever fires first wins, both are idempotent).
(with-eval-after-load 'ox-latex
(add-to-list 'org-export-filter-table-functions #'my/org-latex-fix-tabularx)
(add-to-list 'org-latex-packages-alist '("" "tabularx")))
(defun my/org-ensure-tabularx-filter (backend)
"Force ox-latex load and register tabularx filter before LaTeX export."
(when (org-export-derived-backend-p backend 'latex)
(require 'ox-latex)
(add-to-list 'org-export-filter-table-functions #'my/org-latex-fix-tabularx)
(add-to-list 'org-latex-packages-alist '("" "tabularx"))))
(add-hook 'org-export-before-processing-hook #'my/org-ensure-tabularx-filter)
;; Optional: enable booktabs style (horizontal rules in tables)
;; (setq org-latex-tables-booktabs t)
;; Ensure latexmk doesn't hang on errors (nonstopmode + force-continue)
;; Doom latex module usually sets this, but belt+suspenders for macOS.
(after! org
(setq org-latex-pdf-process
'("latexmk -f -pdf -%latex -interaction=nonstopmode -output-directory=%o %f")))
;;; LaTeX aux file cleanup after export
;; org-latex-remove-logfiles t is the default; extend the list to cover all
;; latexmk output files (bbl, fdb_latexmk, fls, synctex.gz, run.xml, etc.)
(with-eval-after-load 'ox-latex
(setq org-latex-remove-logfiles t
org-latex-logfiles-extensions
'("aux" "bbl" "bcf" "blg" "fdb_latexmk" "fls" "idx" "log"
"nav" "out" "ptc" "run.xml" "snm" "synctex.gz" "toc" "vrb" "xdv")))
;;; Export directory routing
;; All exports go to ~/exports/<type>/ (created automatically if absent).
;; .tex is intermediate for PDF export -- both routed to exports/pdf/.
(defun my/org-export-directory (extension)
"Return ~/exports/<type>/ directory for file EXTENSION, creating it if needed."
(let* ((ext (downcase (string-trim-left extension "\\.")))
(subdir (if (string= ext "tex") "pdf" ext))
(dir (expand-file-name (concat "exports/" subdir "/") "~")))
(make-directory dir t)
dir))
(defun my/org-export-output-file-name (orig extension &optional subtreep pub-dir)
"Route org exports to ~/exports/<type>/ unless pub-dir is already set."
(funcall orig extension subtreep (or pub-dir (my/org-export-directory extension))))
(advice-add 'org-export-output-file-name :around #'my/org-export-output-file-name)
;;; ============================================================
;;; ORG MODE — CUSTOM BEHAVIOR
;;; ============================================================
;; Org agenda: position cursor at task name (after any todo keyword and priority)
;; Works with n/p (or j/k in evil mode) — skips any keyword from org-todo-keywords
;; (TODO, NEXT, WAIT, DONE, CANCELLED, …) and optional [#A] priority.
(defun my/org-agenda-all-keywords ()
"Return list of all org todo keyword strings (without shortcut suffixes)."
(let (result)
(dolist (seq org-todo-keywords result)
(dolist (kw (cdr seq))
(unless (equal kw "|")
(push (replace-regexp-in-string "(.*" "" kw) result))))))
(defun my/org-agenda-goto-task-name (&rest _)
"Move cursor to the task name on the current org-agenda line.
Skips past any org todo keyword (TODO, NEXT, WAIT, DONE, CANCELLED, etc.)
and optional priority indicator [#A]."
(when (get-text-property (line-beginning-position) 'org-hd-marker)
(beginning-of-line)
(let* ((eol (line-end-position))
(kw-re (regexp-opt (my/org-agenda-all-keywords) 'words)))
(when (re-search-forward kw-re eol t)
(skip-chars-forward " \t")
(when (looking-at "\\[#.\\][ \t]+")
(goto-char (match-end 0)))))))
(advice-add 'org-agenda-next-line :after #'my/org-agenda-goto-task-name)
(advice-add 'org-agenda-previous-line :after #'my/org-agenda-goto-task-name)
;; Org buffer: snap cursor past TODO keyword/priority on headings in normal state.
;; Fires via post-command-hook (buffer-local) — only when cursor is before task name.
(defun my/org-heading-snap-past-keyword ()
"In Evil normal state, snap cursor past TODO keyword and priority on org headings."
(when (and (derived-mode-p 'org-mode)
(evil-normal-state-p)
(org-at-heading-p))
(let* ((kw-re (regexp-opt (my/org-agenda-all-keywords) 'words))
(task-start
(save-excursion
(beginning-of-line)
(skip-chars-forward "* ")
(when (looking-at kw-re)
(goto-char (match-end 0))
(skip-chars-forward " \t")
(when (looking-at "\\[#.\\][ \t]+")
(goto-char (match-end 0)))
(point)))))
(when (and task-start (< (point) task-start))
(goto-char task-start)))))
(add-hook 'org-mode-hook
(lambda ()
(add-hook 'post-command-hook
#'my/org-heading-snap-past-keyword
nil t)))
;;; ============================================================
;;; GPTEL — AI INTEGRATION (OpenWebUI / OpenRouter)
;;; ============================================================
(use-package! gptel
:config
;; API key from environment variable (no secrets in config)
(defun my/openwebui-key ()
(or (getenv "OPENWEBUI_API_KEY")
(user-error "Missing OPENWEBUI_API_KEY env var")))
;; Fetch available models from OpenWebUI /api/models
(defun my/openwebui-fetch-model-ids ()
"Return list of model ids from OpenWebUI /api/models."
(require 'url)
(require 'json)
(let* ((url-request-method "GET")
(url-request-extra-headers
`(("Authorization" . ,(concat "Bearer " (funcall #'my/openwebui-key))))))
(with-current-buffer (url-retrieve-synchronously
"https://ai.apps.sukany.cz/api/models" t t 15)
(goto-char (point-min))
(re-search-forward "\n\n" nil 'move)
(let* ((json-object-type 'alist)
(json-array-type 'list)
(json-key-type 'symbol)
(obj (json-read))
(data (alist-get 'data obj))
(ids (delq nil (mapcar (lambda (it) (alist-get 'id it)) data))))
(kill-buffer (current-buffer))
ids))))
(defvar my/openwebui-models-cache nil)
(defun my/openwebui-models ()
"Return cached list of model ids; falls back to a minimal list on failure."
(or my/openwebui-models-cache
(setq my/openwebui-models-cache
(condition-case err
(my/openwebui-fetch-model-ids)
(error
(message "OpenWebUI models fetch failed: %s" err)
'("openai/gpt-4o-mini" "openai/gpt-4.1-mini"))))))
(defun my/openwebui-refresh-models ()
"Clear model cache and refetch from OpenWebUI."
(interactive)
(setq my/openwebui-models-cache nil)
(message "OpenWebUI models refreshed: %d" (length (my/openwebui-models))))
;; Register OpenWebUI as an OpenAI-compatible backend
(setq gptel-backend
(gptel-make-openai "OpenWebUI"
:host "ai.apps.sukany.cz"
:protocol "https"
:key #'my/openwebui-key
:endpoint "/api/chat/completions"
:stream t
:curl-args '("--http1.1")
:models (my/openwebui-models)))
;; Default model: prefer gpt-5-mini, fall back to first available
(let* ((models (my/openwebui-models))
(preferred "openai/gpt-5-mini"))
(setq gptel-model (if (member preferred models) preferred (car models))))
;; Presets for quick task-specific model switching
(gptel-make-preset 'fast
:description "Default (fast/cheap) — everyday work"
:backend "OpenWebUI"
:model "openai/gpt-4o-mini"
:system "Reply in Czech. Be specific and step-by-step. No fluff."
:temperature 0.2)
(gptel-make-preset 'coding
:description "Code / refactor / review"
:backend "OpenWebUI"
:model "openai/gpt-4.1-mini"
:system "You are a strict code reviewer. Propose concrete changes and flag risks."
:temperature 0.1)
(gptel-make-preset 'deep
:description "Complex analysis / architecture"
:backend "OpenWebUI"
:model "openai/gpt-4.1"
:system "Work systematically. Provide alternatives, tradeoffs, and a recommendation."
:temperature 0.2))
;; CLI helper: call gptel from emacs --batch
(defun my/gptel-cli (prompt &optional model system)
"Send PROMPT via gptel and print response to stdout."
(require 'gptel)
(let* ((done nil)
(result nil)
(gptel-model (or model gptel-model))
(gptel--system-message (or system gptel--system-message)))
(gptel-request prompt
:callback (lambda (response _info)
(setq result response)
(setq done t)))
(while (not done) (accept-process-output nil 0.05))
(princ result)))
;; GPTel keybindings under SPC o g
(after! gptel
(map! :leader
(:prefix ("o g" . "GPTel")
:desc "Send (region or buffer)" "s" #'gptel-send
:desc "Menu (model/scope/preset)" "m" #'gptel-menu
:desc "Chat buffer" "c" #'gptel
:desc "Abort request" "x" #'gptel-abort
:desc "Refresh OpenWebUI models" "R" #'my/openwebui-refresh-models)))
;;; ============================================================
;;; COMPLETION — CORFU + CAPE
;;; ============================================================
(after! corfu
(setq corfu-auto t
corfu-auto-delay 1.0
corfu-auto-prefix 2
corfu-cycle t
corfu-preselect 'prompt
corfu-quit-no-match 'separator
corfu-preview-current nil)
(global-corfu-mode))
;; Cape: additional completion-at-point sources
(use-package! cape
:after corfu
:config
(defun martin/cape-capf-setup-text ()
"Cape sources for text modes (org, markdown, etc.) — no Elisp symbols."
(add-to-list 'completion-at-point-functions #'cape-dabbrev 0)
(add-to-list 'completion-at-point-functions #'cape-file 0))
(defun martin/cape-capf-setup-prog ()
"Cape sources for prog modes — includes Elisp symbols only in Elisp."
(add-to-list 'completion-at-point-functions #'cape-dabbrev 0)
(add-to-list 'completion-at-point-functions #'cape-file 0)
(add-to-list 'completion-at-point-functions #'cape-keyword 0)
(when (derived-mode-p 'emacs-lisp-mode)
(add-to-list 'completion-at-point-functions #'cape-elisp-symbol 0)))
(add-hook 'text-mode-hook #'martin/cape-capf-setup-text)
(add-hook 'prog-mode-hook #'martin/cape-capf-setup-prog))
;; Ensure yasnippet-capf is FIRST in completion-at-point-functions
;; so corfu shows snippets and TAB expands them (not inserts plain text).
;; Depth 0 + local=t puts it before cape backends. (GitHub issue #8183)
(after! (yasnippet corfu)
(dolist (hook '(org-mode-hook markdown-mode-hook text-mode-hook prog-mode-hook))
(add-hook hook
(lambda ()
(add-hook 'completion-at-point-functions #'yasnippet-capf 0 t)))))
;; Corfu popup in terminal — only needed for Emacs < 31 without child-frame support.
;; Emacs 31 handles corfu natively even in terminal; loading corfu-terminal
;; there causes popup positioning issues ("jumping").
(use-package! corfu-terminal
:when (and (not (display-graphic-p))
(< emacs-major-version 31))
:after corfu
:config
(corfu-terminal-mode +1))
;;; ============================================================
;;; EMAIL — MU4E
;;; ============================================================
(add-to-list 'load-path
(expand-file-name "/opt/homebrew/opt/mu/share/emacs/site-lisp/mu/mu4e"))
(after! mu4e
(setq mu4e-maildir "~/.mail"
mu4e-get-mail-command "mbsync personal"
mu4e-update-interval 300
mu4e-change-filenames-when-moving t
mu4e-view-show-images t
mu4e-sent-folder "/personal/Sent"
mu4e-drafts-folder "/personal/Drafts"
mu4e-trash-folder "/personal/Trash"
mu4e-refile-folder "/personal/Archive"
mu4e-headers-show-threads t
mu4e-headers-include-related t
mu4e-use-fancy-chars t
mu4e-headers-mark-for-thread t
mu4e-headers-fields '((:human-date . 12)
(:flags . 6)
(:from-or-to . 25)
(:subject))
;; Sort: newest first
mu4e-headers-sort-field :date
mu4e-headers-sort-direction 'descending
;; Thread prefixes — fancy Unicode
mu4e-headers-thread-single-orphan-prefix '("─>" . "─▶ ")
mu4e-headers-thread-orphan-prefix '("┬>" . "┬▶ ")
mu4e-headers-thread-connection-prefix '("" . "")
mu4e-headers-thread-first-child-prefix '("├>" . "├▶ ")
mu4e-headers-thread-last-child-prefix '("└>" . "└▶ ")
mu4e-headers-thread-duplicate-prefix '("=" . ""))
;; Bookmarks — unread excludes Trash/Archive/Sent/Drafts/Spam
(setq mu4e-bookmarks
'((:name "Unread"
:query "flag:unread AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent AND NOT maildir:/personal/Drafts AND NOT maildir:/personal/Spam"
:key ?u)
(:name "Inbox" :query "maildir:/personal/INBOX" :key ?i)
(:name "Today" :query "date:today AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?t)
(:name "Week" :query "date:7d..now AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?w)))
;; Do not cite sender's signature in replies
(setq message-cite-function #'message-cite-original-without-signature)
;; Signature from file — create ~/.mail/signature with your sig text
(setq message-signature-file (expand-file-name "~/.mail/signature")
message-signature t)
;; Move cursor past headers to message body when opening a message
;; Modern mu4e (1.8+) uses gnus-article-mode, not mu4e-view-mode.
;; We use mu4e-view-rendered-hook which fires after the message is displayed,
;; with a small idle timer to ensure the buffer is fully populated.
(defun my/mu4e-view-goto-body ()
"Position cursor at the start of the message body, skipping headers."
(run-with-idle-timer
0.05 nil
(lambda ()
(when-let ((buf (get-buffer "*mu4e-article*")))
(with-current-buffer buf
(goto-char (point-min))
;; Skip header block: lines matching "Key: value" or continuation whitespace
(while (and (not (eobp))
(looking-at "^\\([A-Za-z-]+:\\|[ \t]\\)"))
(forward-line 1))
;; Skip blank separator line(s)
(while (and (not (eobp)) (looking-at "^\\s-*$"))
(forward-line 1)))))))
;; Hook into gnus-article-prepare-hook (fires in gnus-based mu4e view)
(add-hook 'gnus-article-prepare-hook #'my/mu4e-view-goto-body)
;; Also keep mu4e-view-mode-hook as fallback for older mu4e / non-gnus view
(add-hook 'mu4e-view-mode-hook #'my/mu4e-view-goto-body)
;; Maildir shortcuts
(setq mu4e-maildir-shortcuts
'(("/personal/INBOX" . ?i)
("/personal/Sent" . ?s)
("/personal/Trash" . ?t)
("/personal/Archive" . ?a)))
;; Cursor on subject column after j/k navigation
(defun my/mu4e-goto-subject (&rest _)
"Move cursor to the start of the subject text in a mu4e headers line.
Uses the actual subject string from the message rather than column arithmetic,
which is unreliable with unicode flags and thread-prefix strings."
(when (derived-mode-p 'mu4e-headers-mode)
(let* ((msg (mu4e-message-at-point t))
(subject (when msg (mu4e-message-field msg :subject))))
(when (and subject (> (length subject) 0))
(beginning-of-line)
;; Search for up to the first 10 chars of the subject on this line.
;; Avoids false matches while tolerating mu4e truncation.
(let ((needle (substring subject 0 (min 10 (length subject)))))
(when (search-forward needle (line-end-position) t)
(goto-char (match-beginning 0))))))))
(advice-add 'mu4e-headers-next :after #'my/mu4e-goto-subject)
(advice-add 'mu4e-headers-prev :after #'my/mu4e-goto-subject)
;; zT = toggle thread view on/off (T alone marks thread for bulk action)
(evil-define-key 'normal mu4e-headers-mode-map
(kbd "zT") #'mu4e-headers-toggle-threading))
(after! mu4e
(setq sendmail-program "msmtp"
message-send-mail-function #'message-send-mail-with-sendmail
mail-specify-envelope-from t
message-sendmail-envelope-from 'header))
;;; ============================================================
;;; RSS — ELFEED
;;; ============================================================
(map! :leader :desc "Elfeed" "o r" #'elfeed)
(after! org
(setq rmh-elfeed-org-files
(list (expand-file-name "elfeed.org" org-directory))))
(after! elfeed
(require 'elfeed-org)
(elfeed-org))
;;; ============================================================
;;; PLANTUML
;;; ============================================================
(add-to-list 'auto-mode-alist '("\\.puml\\'" . plantuml-mode))
(add-to-list 'auto-mode-alist '("\\.plantuml\\'" . plantuml-mode))
(after! plantuml-mode
(setq plantuml-default-exec-mode 'server
plantuml-server-url "https://www.plantuml.com/plantuml"
plantuml-output-type "svg"
plantuml-verbose t))
(defun my/plantuml-encode-hex (text)
"Encode TEXT using PlantUML HEX encoding (~h + hex(UTF-8 bytes))."
(let* ((utf8 (encode-coding-string text 'utf-8 t)))
(concat "~h"
(apply #'concat
(mapcar (lambda (b) (format "%02x" b))
(append utf8 nil))))))
(defun my/plantuml-fix-png-header (file)
"Strip any bytes before the PNG signature in FILE."
(let ((sig (unibyte-string #x89 ?P ?N ?G ?\r ?\n #x1a ?\n)))
(with-temp-buffer
(set-buffer-multibyte nil)
(insert-file-contents-literally file)
(goto-char (point-min))
(unless (looking-at (regexp-quote sig))
(let ((pos (search-forward sig nil t)))
(unless pos (user-error "PNG signature not found"))
(delete-region (point-min) (- pos (length sig)))
(let ((coding-system-for-write 'binary))
(write-region (point-min) (point-max) file nil 'silent)))))))
(defun my/plantuml-render-server (type)
"Render current .puml buffer via PlantUML server to TYPE (png or svg)."
(interactive (list (completing-read "Type: " '("png" "svg") nil t "png")))
(unless buffer-file-name (user-error "Open .puml as a file first"))
(let* ((text (buffer-substring-no-properties (point-min) (point-max)))
(encoded (my/plantuml-encode-hex text))
(server (string-remove-suffix "/" plantuml-server-url))
(url (format "%s/%s/%s" server type encoded))
(out (concat (file-name-sans-extension buffer-file-name) "." type)))
(url-copy-file url out t)
(when (string-equal type "png")
(my/plantuml-fix-png-header out))
(message "PlantUML saved: %s" out)
out))
(after! plantuml-mode
(define-key plantuml-mode-map (kbd "C-c C-p")
(lambda () (interactive) (my/plantuml-render-server "png")))
(define-key plantuml-mode-map (kbd "C-c C-s")
(lambda () (interactive) (my/plantuml-render-server "svg"))))
;;; ============================================================
;;; TRAMP & REMOTE
;;; ============================================================
(after! tramp
(setq projectile-git-command "git ls-files -zco --exclude-standard"))
;; Disable VC and Projectile over TRAMP — main cause of hangs
(setq vc-ignore-dir-regexp
(format "%s\\|%s" vc-ignore-dir-regexp tramp-file-name-regexp))
(defadvice projectile-project-root (around ignore-remote first activate)
(unless (file-remote-p default-directory) ad-do-it))
(setq remote-file-name-inhibit-cache nil
tramp-verbose 1)
;;; ============================================================
;;; DIRED
;;; ============================================================
(after! dired
(put 'dired-find-alternate-file 'disabled nil)
(map! :map dired-mode-map
"RET" #'dired-find-alternate-file
"^" #'dired-up-directory))
;;; ============================================================
;;; PROJECTILE
;;; ============================================================
(after! projectile
(setq projectile-enable-caching nil
projectile-indexing-method 'alien)
(when (executable-find "fd")
(setq projectile-generic-command
"fd . -0 --type f --hidden --follow --exclude .git --color=never")))
;;; ============================================================
;;; PYTHON
;;; ============================================================
(setq python-shell-interpreter "python3")
(after! org
(setq org-babel-python-command "python3")
(require 'ob-python))
;;; ============================================================
;;; ACCESSIBILITY — EMACSPEAK
;;; ============================================================
;;; Default: OFF. Toggle with SPC t s (on) / SPC t S (off).
(defconst my/emacspeak-dir (expand-file-name "~/.emacspeak"))
(defconst my/emacspeak-wrapper (expand-file-name "~/.local/bin/emacspeak-mac"))
(setq dtk-program my/emacspeak-wrapper)
(defvar my/emacspeak-loaded nil)
(defvar my/emacspeak-enabled nil)
;; Hard inhibit: when non-nil, Emacspeak server will not start/restart
(defvar my/emacspeak-inhibit-server t)
(defun my/emacspeak--ensure-loaded ()
"Load Emacspeak once, safely, without breaking Doom startup."
(unless my/emacspeak-loaded
(setq my/emacspeak-loaded t)
(setq emacspeak-directory my/emacspeak-dir)
(load-file (expand-file-name "lisp/emacspeak-setup.el" emacspeak-directory))
(with-eval-after-load 'dtk-speak
(dolist (fn '(dtk-initialize dtk-start-process dtk-speak))
(when (fboundp fn)
(advice-add
fn :around
(lambda (orig &rest args)
(if my/emacspeak-inhibit-server
nil ; OFF: do nothing, don't restart
(apply orig args)))))))))
(defun my/emacspeak-on ()
"Enable speech and allow TTS server to start."
(interactive)
(setq my/emacspeak-inhibit-server nil)
(my/emacspeak--ensure-loaded)
(setq my/emacspeak-enabled t)
(when (fboundp 'dtk-restart) (ignore-errors (dtk-restart)))
(when (fboundp 'dtk-speak) (ignore-errors (dtk-speak "Emacspeak on.")))
(message "Emacspeak ON"))
(defun my/emacspeak-off ()
"Disable speech and prevent auto-restart."
(interactive)
(setq my/emacspeak-enabled nil
my/emacspeak-inhibit-server t)
(when (fboundp 'dtk-stop) (ignore-errors (dtk-stop)))
(when (boundp 'dtk-speaker-process)
(let ((p dtk-speaker-process))
(when (processp p)
(ignore-errors (set-process-sentinel p nil))
(ignore-errors (delete-process p))))
(setq dtk-speaker-process nil))
(message "Emacspeak OFF (server restart inhibited)"))
(map! :leader
(:prefix ("t" . "toggle")
:desc "Speech ON" "s" #'my/emacspeak-on
:desc "Speech OFF" "S" #'my/emacspeak-off))
(with-eval-after-load 'dtk-speak
(setq dtk-speech-rate-base 300)
(setq-default dtk-punctuation-mode 'none))
(with-eval-after-load 'emacspeak
(setq-default emacspeak-character-echo nil
emacspeak-word-echo t
emacspeak-line-echo t))
;; Apply global default speech rate after TTS init/restart
(setq dtk-default-speech-rate 400)
(with-eval-after-load 'dtk-speak
(defun my/dtk-apply-global-default-rate (&rest _)
"Apply global default speech rate after TTS init/restart."
(when (fboundp 'dtk-set-rate)
(ignore-errors (dtk-set-rate dtk-default-speech-rate t))))
(advice-add 'dtk-initialize :after #'my/dtk-apply-global-default-rate))
;;; ============================================================
;;; ACCESSIBILITY — GLOBAL TEXT SCALING (SPC z)
;;; ============================================================
;; Screen magnifier: scales the global `default' face — all buffers,
;; help windows, doom menus, org-agenda, magit, which-key included.
;;
;; Modeline + header-line + tabs are PINNED to base size (always visible).
;; which-key shows at full zoom in 90 % of frame height — more keys fit.
;; No face-remap hooks (avoid accumulation bugs with timers).
;;
;; Step: ×1.5 per step (multiplicative). From 14pt base:
;; +1 ≈ 21pt +2 ≈ 32pt +3 ≈ 47pt
;; +4 ≈ 71pt +5 ≈ 106pt +6 ≈ 159pt
;;
;; SPC z + / = zoom in
;; SPC z - zoom out
;; SPC z 0 reset to default (saves level for restore)
;; SPC z z restore zoom before last reset
;; In which-key popup: C-h pages to next group of bindings
;; --------------- state ---------------
(defvar my/zoom-base-height 140
"Default face height before any zoom, in 1/10 pt. Captured at Doom init.")
(defvar my/zoom-steps 0
"Current zoom step count. 0 = default, positive = bigger.")
(defvar my/zoom-saved-steps nil
"Step count saved before last `my/zoom-reset', for `my/zoom-restore'.")
;; --------------- pinned faces (always at base) ---------------
(defvar my/zoom-pinned-faces
'(mode-line mode-line-inactive mode-line-active
header-line tab-bar tab-bar-tab tab-bar-tab-inactive)
"Faces kept at `my/zoom-base-height' regardless of zoom.
Keeps the status bar and tab bar fully visible at any zoom level.")
(defun my/zoom-pin-ui ()
"Set all pinned UI faces to base height."
(dolist (face my/zoom-pinned-faces)
(when (facep face)
(set-face-attribute face nil :height my/zoom-base-height))))
;; --------------- which-key: max side-window height ---------------
;; which-key scales with global zoom (same as all other buffers).
;; Give it 90 % of frame height so more bindings are visible at once.
;; Press C-h while which-key is open to page through remaining bindings.
(after! which-key
(setq which-key-side-window-max-height 0.90
which-key-max-display-columns nil))
;; --------------- core zoom engine ---------------
(defun my/zoom--apply (steps)
"Scale global default face to base × 1.5^STEPS and re-pin UI faces."
(let ((new-h (max 80 (round (* my/zoom-base-height (expt 1.5 steps))))))
(set-face-attribute 'default nil :height new-h)
(my/zoom-pin-ui)
(when (fboundp 'corfu--popup-hide)
(ignore-errors (corfu--popup-hide)))
(message "Zoom %+d ×%.2f ≈%dpt"
steps (expt 1.5 steps) (/ new-h 10))
;; Ensure cursor stays visible after zoom change.
(when (and (not (minibufferp)) (window-live-p (selected-window)))
(scroll-right (window-hscroll)) ; reset horizontal scroll
(recenter nil))))
;; Capture base height once Doom finishes font setup.
(add-hook 'doom-after-init-hook
(lambda ()
(let ((h (face-attribute 'default :height nil t)))
(when (and (integerp h) (> h 0))
(setq my/zoom-base-height h)))))
;; Re-pin UI faces after theme reloads (Doom resets faces on theme change).
(add-hook 'doom-load-theme-hook #'my/zoom-pin-ui)
;; --------------- interactive commands ---------------
(defun my/zoom-in ()
"Zoom in one step (×1.5) — all buffers, help, menus, which-key."
(interactive)
(cl-incf my/zoom-steps)
(my/zoom--apply my/zoom-steps))
(defun my/zoom-out ()
"Zoom out one step (÷1.5) — all buffers."
(interactive)
(cl-decf my/zoom-steps)
(my/zoom--apply my/zoom-steps))
(defun my/zoom-reset ()
"Reset to default font size. Saves current level for restore."
(interactive)
(if (= my/zoom-steps 0)
(message "Zoom: already at default")
(setq my/zoom-saved-steps my/zoom-steps)
(my/zoom--apply 0)
(setq my/zoom-steps 0)
(message "Zoom reset (SPC z z to restore %+d)" my/zoom-saved-steps)))
(defun my/zoom-restore ()
"Restore zoom level saved before last reset."
(interactive)
(if (null my/zoom-saved-steps)
(message "Zoom: nothing to restore")
(my/zoom--apply my/zoom-saved-steps)
(setq my/zoom-steps my/zoom-saved-steps
my/zoom-saved-steps nil)))
;; Keep cursor visible while scrolling at any zoom level.
(setq hscroll-margin 3
hscroll-step 1
scroll-conservatively 101
scroll-margin 2)
;;; ============================================================
;;; KEYBINDINGS
;;; ============================================================
(map! :leader
(:prefix ("h" . "help")
:desc "Describe bindings (buffer-local)" "B" #'describe-bindings))
(map! :leader
(:prefix ("z" . "zoom")
:desc "Zoom in (×1.5)" "+" #'my/zoom-in
:desc "Zoom in (×1.5)" "=" #'my/zoom-in
:desc "Zoom out (÷1.5)" "-" #'my/zoom-out
:desc "Reset" "0" #'my/zoom-reset
:desc "Restore previous magnification" "z" #'my/zoom-restore))
;;; ============================================================
;;; MATRIX — EMENT.EL
;;; ============================================================
;; Matrix client. Package declared in packages.el.
;; Keybindings: SPC o M (open → matrix)
;; Note: SPC o m is taken by Doom's mu4e module (#'mu4e), hence uppercase M.
;;
;; Quick reference:
;; SPC o M o — open Matrix panel (connect + room list, no credentials)
;; SPC o M c — connect / re-connect manually
;; SPC o M C — disconnect
;; SPC o M l — list rooms
;; SPC o M r — open room
;; SPC o M d — direct message
;; Set BEFORE ement loads — ensures kill-emacs-hook saves sessions on exit.
;; Also pin sessions to a known path (no-littering may redirect otherwise).
(setq ement-save-sessions t
ement-sessions-file (expand-file-name "ement-sessions.el" doom-private-dir))
(after! ement
;; Background auto-sync (internal — do NOT call ement-sync manually, causes issues)
(setq ement-auto-sync t)
;; Show timestamp on every message
(setq ement-room-timestamp-format "%H:%M"
ement-room-show-avatars nil) ; avatars slow things down, disabled
;; Colored usernames for readability
(setq ement-room-username-display-property '(raise 0))
;; Notify on mentions (@martin)
(setq ement-notify-mentions-p t
ement-notify-dingalings-p nil) ; no sound
) ; end after! ement
;; Defined outside after! so Doom registers them as proper interactive commands.
(defun my/ement-open-after-sync (&rest _)
"Open room list after ement finishes initial sync. Self-removing."
(remove-hook 'ement-after-initial-sync-hook #'my/ement-open-after-sync)
(when (fboundp 'ement-list-rooms)
(ement-list-rooms)))
(defun my/ement-maybe-restore ()
"Restore saved ement session silently on startup. No prompts, no buffer.
Loads sessions from file and calls ement--reconnect directly to bypass
any interactive prompts in ement-connect (homeserver discovery, password)."
(require 'ement)
(condition-case err
(let ((sf (expand-file-name ement-sessions-file)))
(when (file-readable-p sf)
;; Pre-load sessions from file so ement-connect won't prompt
(unless ement-sessions
(when (fboundp 'ement--load-sessions)
(setq ement-sessions (ement--load-sessions))))
;; Reconnect directly — bypasses interactive ement-connect entirely
(if (fboundp 'ement--reconnect)
(dolist (entry ement-sessions)
(ement--reconnect (cdr entry)))
;; Fallback: explicit homeserver avoids discovery/password prompt
(when ement-sessions
(ement-connect :user-id (caar ement-sessions)
:homeserver "https://matrix.apps.sukany.cz")))))
(error (message "ement startup restore: %s" (error-message-string err)))))
(defun my/ement-open ()
"Open Matrix panel: show room list (connect/restore if needed).
If already connected: opens room list immediately.
If sessions file exists: auto-restores without credentials, opens rooms after sync.
Otherwise: runs interactive ement-connect, then opens rooms after sync."
(interactive)
(require 'ement)
(cond
;; Already connected — open rooms immediately
((and (boundp 'ement-sessions) ement-sessions)
(ement-list-rooms))
;; Saved session exists — restore without credentials, open rooms after sync
((file-readable-p (expand-file-name ement-sessions-file))
(add-hook 'ement-after-initial-sync-hook #'my/ement-open-after-sync)
(my/ement-maybe-restore))
;; No saved session — interactive connect, open rooms after sync
(t
(add-hook 'ement-after-initial-sync-hook #'my/ement-open-after-sync)
(call-interactively #'ement-connect))))
;; Auto-connect on Emacs startup (outside after! — ement may be deferred)
(add-hook 'doom-after-init-hook #'my/ement-maybe-restore)
;; Keybindings under SPC o M (uppercase M — o m is taken by mu4e)
(map! :leader
(:prefix ("o M" . "Matrix")
:desc "Open panel" "o" #'my/ement-open
:desc "Connect" "c" #'ement-connect
:desc "Disconnect" "C" #'ement-disconnect
:desc "List rooms" "l" #'ement-list-rooms
:desc "Open room" "r" #'ement-view-room
:desc "Direct message" "d" #'ement-send-direct-message
:desc "Join room" "j" #'ement-join-room
:desc "Notifications" "n" #'ement-notifications
:desc "Mentions" "m" #'ement-mentions))
;;; ============================================================
;;; PDF TOOLS
;;; ============================================================
;; pdf-tools: install server binary on first load
(after! pdf-tools
(pdf-tools-install :no-query))
;; pdf-view-mode settings
(after! pdf-view
;; Fit page to window width by default
(setq-default pdf-view-display-size 'fit-page)
;; High-res rendering on Retina displays
(setq pdf-view-use-scaling t
pdf-view-use-imagemagick nil)
;; Midnight mode (dark background) — toggle with M-m or keybind
(setq pdf-view-midnight-colors '("#d4d4d4" . "#1c1c1c"))
;; Continuous scrolling across pages
(setq pdf-view-continuous t)
;; No line-number gutter
(add-hook 'pdf-view-mode-hook #'(lambda ()
(display-line-numbers-mode -1)))
;; Auto-revert when PDF is regenerated (e.g. after org LaTeX export)
(add-hook 'pdf-view-mode-hook #'auto-revert-mode))
;; Evil-friendly keybinds in pdf-view
(after! pdf-view
(evil-define-key 'normal pdf-view-mode-map
"j" #'pdf-view-next-line-or-next-page
"k" #'pdf-view-previous-line-or-previous-page
"J" #'pdf-view-next-page
"K" #'pdf-view-previous-page
"gg" #'pdf-view-first-page
"G" #'pdf-view-last-page
"+" #'pdf-view-enlarge
"-" #'pdf-view-shrink
"=" #'pdf-view-fit-page-to-window
"W" #'pdf-view-fit-width-to-window
"/" #'isearch-forward
"?" #'isearch-backward
"m" #'pdf-view-midnight-minor-mode
"a" #'pdf-annot-add-highlight-markup-annotation
"q" #'quit-window))
;; Re-activate Evil normal state when switching to a pdf-view window.
;; Without this, j/k do not respond after SPC w w until the user clicks.
;; Uses targeted advice on window-switch commands to avoid interfering
;; with org-agenda which triggers window-selection-change-functions internally.
(defun my/pdf-view-ensure-normal-state (&rest _)
"Activate evil-normal-state when landing on a pdf-view-mode buffer."
(when (and (derived-mode-p 'pdf-view-mode)
(called-interactively-p 'any))
(evil-normal-state)))
(advice-add 'evil-window-next :after #'my/pdf-view-ensure-normal-state)
(advice-add 'evil-window-prev :after #'my/pdf-view-ensure-normal-state)
(advice-add 'other-window :after #'my/pdf-view-ensure-normal-state)
;; Open PDFs from org export in Emacs (pdf-view-mode) instead of Preview
(when (eq system-type 'darwin)
(setq org-file-apps
'((auto-mode . emacs)
(directory . emacs)
("\\.mm\\'" . default)
("\\.x?html?\\'" . default)
("\\.pdf\\'" . emacs)))) ; find-file → pdf-view-mode via auto-mode-alist
;;; ============================================================
;;; YASNIPPET — snippets from ~/org/snippets
;;; ============================================================
;; Load snippets from the emacs-org repo so they are version-controlled.
;; Directory structure: ~/org/snippets/<mode-name>/<snippet-name>
;; e.g. ~/org/snippets/org-mode/adr, ~/org/snippets/markdown-mode/adr
(after! yasnippet
;; Add ~/org/snippets to yas-snippet-dirs (prepend = higher priority).
;; Must be in yas-snippet-dirs so it survives yas-reload-all calls.
(push (expand-file-name "snippets/" org-directory) yas-snippet-dirs)
(yas-reload-all)
;; TAB in Evil insert: with yasnippet-capf first in CAPF list,
;; corfu shows snippets as expandable items — no manual TAB override needed.
;; Standard indent-for-tab-command handles org-cycle / indent fallback.
;; Evil + yasnippet: when entering a field, select default text so typing replaces it.
;; Without this, default text is not visually selected in Evil insert state.
(add-hook 'yas-before-expand-snippet-hook
(lambda () (when (evil-normal-state-p) (evil-insert-state))))
;; Delete field content with a single key: C-d clears current field content
(add-hook 'yas-keymap-disable-hook
(lambda () (not (yas--snippets-at-point))))
(define-key yas-keymap (kbd "C-d")
(lambda ()
(interactive)
(delete-region (yas-field-start (yas-current-field))
(yas-field-end (yas-current-field)))))
)
;;; ============================================================
;;; NAVIGATION — link-hint, avy
;;; ============================================================
(use-package! link-hint
:defer t
:commands (link-hint-open-link link-hint-copy-link))
(map! :leader
(:prefix ("j" . "jump")
:desc "Open link (link-hint)" "k" #'link-hint-open-link
:desc "Copy link URL" "K" #'link-hint-copy-link
:desc "Avy goto char-2" "j" #'avy-goto-char-2
:desc "Avy goto line" "l" #'avy-goto-line))
;;; ============================================================
;;; WRITING — olivetti-mode
;;; ============================================================
;; Guard: buffer-file-name ensures olivetti doesn't run in export temp buffers.
;; Corfu popup: if it renders off-screen, disable olivetti (SPC t o).
(use-package! olivetti
:defer t
:config
(setq olivetti-body-width 90))
(add-hook 'org-mode-hook (lambda () (when buffer-file-name (olivetti-mode 1))))
(add-hook 'markdown-mode-hook (lambda () (when buffer-file-name (olivetti-mode 1))))
(add-hook 'text-mode-hook (lambda () (when buffer-file-name (olivetti-mode 1))))
(map! :leader
(:prefix ("t" . "toggle")
:desc "Olivetti mode" "o" #'olivetti-mode))
;;; ============================================================
;;; ORG-MODERN
;;; ============================================================
;;; Org-modern -- modern visual style for org-mode
;; Uses vector star format ["◉"] which works reliably across font/version variations.
;; org-modern-hide-stars: replace non-final stars with · (avoids multi-star clutter).
;; org-modern-table nil: avoids LaTeX export conflicts.
(use-package! org-modern
:hook (org-mode . org-modern-mode)
:hook (org-agenda-finalize . org-modern-agenda)
:config
(setq org-modern-star [""]
org-modern-hide-stars "·"
org-modern-table nil))
;; org-modern-checkbox: default alist value is correct (☑ ☒ ☐), no override needed
;;; ============================================================
;;; ORG-FRAGTOG — auto-render LaTeX fragments
;;; ============================================================
;; Guard: only in file-backed buffers, not in export copies.
(use-package! org-fragtog
:after org
:hook (org-mode . my/org-fragtog-maybe))
(defun my/org-fragtog-maybe ()
"Enable org-fragtog-mode only in file-backed buffers."
(when buffer-file-name
(org-fragtog-mode 1)))
;;; ============================================================
;;; ORG-SUPER-AGENDA — groups in agenda view
;;; ============================================================
;; Note: :deadline (before DATE) requires absolute date or eval — unreliable in
;; plain quoted list. "Soon" group removed. Mode enabled via after! (safer).
(use-package! org-super-agenda
:after org-agenda
:config
(setq org-super-agenda-groups
'((:name "Kyndryl — dnes"
:and (:tag ("kyndryl" "work") :scheduled today))
(:name "Kyndryl — deadline"
:and (:tag ("kyndryl" "work") :deadline t))
(:name "Kyndryl"
:tag ("kyndryl" "work"))
(:name "ZTJ — dnes"
:and (:tag "ztj" :scheduled today))
(:name "ZTJ"
:tag "ztj")
(:name "Dnes"
:scheduled today
:deadline today)
(:name "Cekam"
:todo "WAIT")
(:name "Ostatni"
:anything t))))
(after! org-super-agenda
(org-super-agenda-mode 1))
;;; ============================================================
;;; ORG-NOTER — PDF annotations
;;; ============================================================
;; Emacs 31 may not autoload dired-read-dir-and-switches early enough,
;; causing "Symbol's function definition is void" when org-noter starts.
(require 'dired)
(use-package! org-noter
:after (:any org pdf-view)
:config
(setq org-noter-notes-window-location 'horizontal-split
;; Directories to search for and create notes files
org-noter-notes-search-path (list (expand-file-name "annotations/" org-directory))
;; Default note file name candidates
org-noter-default-notes-file-names '("notes.org")
;; Remember last position in PDF across sessions
org-noter-auto-save-last-location t
;; Insert note at precise location, not just page level
org-noter-insert-note-no-questions nil
org-noter-use-indirect-buffer nil
;; Split in the current frame (PDF + notes side by side), no new frame
org-noter-always-create-frame nil))
;; Smart org-noter launcher: repairs broken notes files and starts from the PDF window.
;; Starting from the PDF window (not the notes file) ensures org-noter splits the
;; current frame — PDF on one side, notes on the other — instead of creating a
;; hidden new frame.
(defun my/org-noter-start ()
"Start org-noter for the PDF visible in the current frame.
Repairs any wrong NOTER_DOCUMENT property in the notes file, then
starts the session from the PDF window so the split appears in the
current frame."
(interactive)
(require 'org-noter)
(let* ((pdf-win (if (derived-mode-p 'pdf-view-mode)
(selected-window)
(cl-find-if (lambda (w)
(with-current-buffer (window-buffer w)
(derived-mode-p 'pdf-view-mode)))
(window-list (selected-frame)))))
(pdf-path (when pdf-win
(with-current-buffer (window-buffer pdf-win)
(buffer-file-name)))))
(unless pdf-path
(user-error "No PDF buffer visible — open a PDF first"))
(let* ((base (file-name-base pdf-path))
(notes-name (concat base ".org"))
(notes-dir (car org-noter-notes-search-path))
(target (expand-file-name notes-name notes-dir)))
(make-directory notes-dir t)
;; Repair or create notes file with correct NOTER_DOCUMENT (absolute PDF path).
;; Absolute paths avoid symlink ambiguity (~/org/ may be an iCloud symlink).
(with-current-buffer (find-file-noselect target)
(let ((modified nil))
(save-excursion
(goto-char (point-min))
(if (re-search-forward
(concat "^[ \t]*:" org-noter-property-doc-file ":[ \t]*\\(.*\\)$")
nil t)
;; Property exists — fix if it doesn't point to pdf-path
(let* ((stored (string-trim (match-string 1)))
(expanded (if (file-name-absolute-p stored)
stored
(expand-file-name stored (file-name-directory target)))))
(unless (and (file-exists-p expanded)
(file-equal-p expanded pdf-path))
(replace-match
(concat ":" org-noter-property-doc-file ": " pdf-path) t t)
(setq modified t)))
;; No property — insert a new org-noter heading
(goto-char (point-min))
(insert (format "* Notes: %s :noexport:\n:PROPERTIES:\n:%s: %s\n:END:\n\n"
base org-noter-property-doc-file pdf-path))
(setq modified t)))
(when modified (save-buffer))))
;; Start from the PDF window. cl-letf auto-answers org-noter's prompts for
;; edge cases where it still asks (e.g. multiple candidate files).
(cl-letf* ((orig-cr (symbol-function 'completing-read))
((symbol-function 'completing-read)
(lambda (prompt collection &rest args)
(cond
((string-match-p "name" prompt) notes-name)
((string-match-p "save\\|where\\|Which" prompt) target)
(t (apply orig-cr prompt collection args))))))
(with-selected-window pdf-win
(org-noter))))))
(map! :leader
(:prefix ("o" . "open")
:desc "org-noter" "n" #'my/org-noter-start
:desc "org-noter insert note" "N" #'org-noter-insert-note))
;;; ============================================================
;;; GPTEL — region rewrite & org heading prompt
;;; ============================================================
(after! gptel
(defun my/gptel-rewrite-region (beg end)
"Send selected region to GPTel with 'improve text' instruction and replace with response."
(interactive "r")
(let ((text (buffer-substring-no-properties beg end)))
(gptel-request
(concat "Vylepši následující text. Vrať POUZE vylepšený text, nic jiného:\n\n" text)
:callback (lambda (response info)
(if response
(save-excursion
(delete-region beg end)
(goto-char beg)
(insert response)
(message "GPTel: text improved"))
(message "GPTel rewrite failed: %s" (plist-get info :status)))))))
(defun my/gptel-org-heading-prompt ()
"Send current org heading + content as context to GPTel chat."
(interactive)
(unless (derived-mode-p 'org-mode)
(user-error "Only available in org-mode"))
(let* ((heading (org-get-heading t t t t))
(content (save-excursion
(org-back-to-heading t)
(let ((beg (point)))
(org-end-of-subtree t t)
(buffer-substring-no-properties beg (point))))))
(gptel content)
(message "GPTel: heading '%s' sent as context" heading)))
(map! :leader
(:prefix ("o g" . "GPTel")
:desc "Rewrite region" "r" #'my/gptel-rewrite-region
:desc "Org heading → GPTel" "p" #'my/gptel-org-heading-prompt)))
;;; ============================================================
;;; GIT — git-link
;;; ============================================================
(use-package! git-link
:defer t
:config
(setq git-link-default-branch "master")
;; Add support for Gitea at git.apps.sukany.cz
(add-to-list 'git-link-remote-alist
'("git\\.apps\\.sukany\\.cz" git-link-gitea))
(add-to-list 'git-link-commit-remote-alist
'("git\\.apps\\.sukany\\.cz" git-link-commit-gitea)))
(map! :leader
(:prefix ("g" . "git")
:desc "Copy git link" "y" #'git-link
:desc "Copy git link commit" "Y" #'git-link-commit))
;;; ============================================================
;;; EVIL — ORG TABLE CELL TEXT OBJECTS (di| ci| vi|)
;;; ============================================================
;; org-table text objects: "|" = cell (di|, ci|, vi|)
(after! evil-org
;; Activate all key themes including textobjects
(evil-org-set-key-theme '(navigation insert textobjects additional calendar))
;; Define inner table cell text object
(evil-define-text-object evil-org-inner-table-cell (count &optional beg end type)
"Inner org table cell (content between pipes)."
(when (org-at-table-p)
(let ((b (save-excursion
(search-backward "|")
(forward-char 1)
(skip-chars-forward " ")
(point)))
(e (save-excursion
(search-forward "|")
(backward-char 1)
(skip-chars-backward " ")
(point))))
(list b e))))
(define-key evil-inner-text-objects-map "|" #'evil-org-inner-table-cell)
(define-key evil-outer-text-objects-map "|" #'evil-org-inner-table-cell))
;;; ============================================================
;;; FORGE — Gitea integration
;;; ============================================================
;; Requires Gitea API token in ~/.authinfo:
;; machine git.apps.sukany.cz login daneel^forge password <TOKEN>
(after! forge
(add-to-list 'forge-alist
'("git.apps.sukany.cz" "git.apps.sukany.cz/api/v1" "git.apps.sukany.cz" forge-gitea-repository)))
;;; ============================================================
;;; DEVELOPER WORKFLOW
;;; ============================================================
;; Language-specific configs for Perl, Python, Go, Ansible, Terraform, Podman.
;; Each language gets SPC m f (format), SPC m r (run), and additional bindings.
;;; --- Perl (cperl-mode) ---
;; Prefer cperl-mode over perl-mode for all Perl files
(add-to-list 'auto-mode-alist '("\\.pl\\'" . cperl-mode))
(add-to-list 'auto-mode-alist '("\\.pm\\'" . cperl-mode))
(add-to-list 'auto-mode-alist '("\\.t\\'" . cperl-mode))
(after! cperl-mode
(setq cperl-indent-level 4
cperl-close-paren-offset -4
cperl-continued-statement-offset 4
cperl-indent-parens-as-block t
cperl-tab-always-indent t))
;; Perltidy formatter via reformatter
(use-package! reformatter
:config
(reformatter-define perl-tidy
:program "perltidy"
:args '("-st" "-se" "--quiet")))
(after! cperl-mode
(map! :map cperl-mode-map
:localleader
:desc "Format (perltidy)" "f" #'perl-tidy-buffer
:desc "Run file" "r" (cmd! (compile (concat "perl " (buffer-file-name))))
:desc "Debug (perldb)" "d" #'perldb)
;; Flycheck: use perl checker
(add-hook 'cperl-mode-hook
(lambda ()
(when (fboundp 'flycheck-select-checker)
(flycheck-select-checker 'perl)))))
;;; --- Python ---
(after! python
(setq python-shell-interpreter "python3")
(map! :map python-mode-map
:localleader
:desc "Format (ruff)" "f" (cmd! (compile (concat "ruff format " (buffer-file-name))
(revert-buffer t t t)))
:desc "Run file" "r" (cmd! (compile (concat "python3 " (buffer-file-name))))))
;;; --- Go ---
(after! go-mode
(setq gofmt-command "goimports")
;; Auto-format on save
(add-hook 'go-mode-hook
(lambda ()
(add-hook 'before-save-hook #'gofmt-before-save nil t)))
(map! :map go-mode-map
:localleader
:desc "Format (goimports)" "f" #'gofmt
:desc "Run file" "r" (cmd! (compile (concat "go run " (buffer-file-name))))
:desc "Test" "t" (cmd! (compile "go test ./..."))
:desc "Build" "b" (cmd! (compile "go build ./..."))))
;;; --- Ansible / YAML ---
(after! yaml-mode
(map! :map yaml-mode-map
:localleader
:desc "Run playbook" "r" (cmd! (compile (concat "ansible-playbook " (buffer-file-name))))))
;; Ansible-lint via flycheck for YAML files in ansible-ish directories
(after! flycheck
(flycheck-define-checker yaml-ansible-lint
"Ansible linter."
:command ("ansible-lint" "--parseable" source)
:error-patterns
((warning line-start (file-name) ":" line ": " (message) line-end))
:modes (yaml-mode))
(add-to-list 'flycheck-checkers 'yaml-ansible-lint t))
;;; --- Terraform ---
(after! terraform-mode
(map! :map terraform-mode-map
:localleader
:desc "Format (terraform fmt)" "f" (cmd! (compile "terraform fmt" t)
(revert-buffer t t t))
:desc "Init" "i" (cmd! (compile "terraform init"))
:desc "Plan" "p" (cmd! (compile "terraform plan"))))
;;; --- Dockerfile / Podman ---
(after! dockerfile-mode
(map! :map dockerfile-mode-map
:localleader
:desc "Build (podman)" "b" (cmd! (let ((tag (read-string "Image tag: " "myapp:latest")))
(compile (format "podman build -t %s -f %s ."
tag (buffer-file-name)))))
:desc "Run (podman)" "r" (cmd! (let ((img (read-string "Image name: " "myapp:latest")))
(compile (format "podman run --rm %s" img))))))
;; Hadolint via flycheck for Dockerfiles
(after! flycheck
(when (executable-find "hadolint")
(flycheck-define-checker dockerfile-hadolint
"Dockerfile linter using hadolint."
:command ("hadolint" "--no-color" "-")
:standard-input t
:error-patterns
((info line-start (one-or-more not-newline) ":" line " " (id (one-or-more not-newline)) " " (message) line-end)
(warning line-start (one-or-more not-newline) ":" line " " (id (one-or-more not-newline)) " " (message) line-end)
(error line-start (one-or-more not-newline) ":" line " " (id (one-or-more not-newline)) " " (message) line-end))
:modes (dockerfile-mode))
(add-to-list 'flycheck-checkers 'dockerfile-hadolint t)))
;;; --- Common project settings ---
(after! projectile
(dolist (dir '("node_modules" "__pycache__" ".terraform" "vendor"))
(add-to-list 'projectile-globally-ignored-directories dir)))
;;; ============================================================
;;; EXTENSIONS — Tier 1-3 + BibTeX
;;; ============================================================
;;; --- Tier 1: High impact ---
;; org-caldav — CalDAV sync for org (Baikal server, Digest auth)
;; Credentials: add to ~/.authinfo:
;; machine cal.apps.sukany.cz login martin password YOUR_PASSWORD
(use-package! org-caldav
:commands (org-caldav-sync my/org-caldav-sync)
:config
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
org-caldav-calendar-id "default"
org-caldav-inbox "~/org/caldav-inbox.org"
org-caldav-files '("~/org/personal.org" "~/org/work.org")
org-caldav-sync-direction 'twoway)
;; Baikal uses Digest auth. Pre-register credentials from ~/.authinfo
;; so Emacs url package doesn't prompt interactively.
(defun my/caldav-setup-digest-auth ()
"Load Digest auth credentials for Baikal from auth-source (~/.authinfo).
Baikal uses Digest auth. url-digest-auth-storage stores HA1 = MD5(user:realm:pass)."
(require 'url-auth)
(let* ((found (car (auth-source-search :host "cal.apps.sukany.cz"
:user "martin" :max 1)))
(pass (when found
(let ((s (plist-get found :secret)))
(if (functionp s) (funcall s) s))))
(user "martin")
(realm "BaikalDAV")
(server "cal.apps.sukany.cz:443"))
(when pass
(let ((ha1 (md5 (concat user ":" realm ":" pass)))
(existing (assoc server url-digest-auth-storage)))
(if existing
(setcdr existing (list (cons realm ha1)))
(push (list server (cons realm ha1))
url-digest-auth-storage))))))
(defun my/org-caldav-sync ()
"Sync org-caldav after pre-registering Baikal Digest auth."
(interactive)
(my/caldav-setup-digest-auth)
(org-caldav-sync)))
(map! :leader "o c" #'my/org-caldav-sync)
;; envrc — direnv integration
(use-package! envrc
:hook (after-init . envrc-global-mode))
;; embark — custom prompter (already installed by Doom vertico module)
(after! embark
(setq embark-prompter #'embark-keymap-prompter))
;; wgrep — already installed by Doom vertico module, just configure
(after! wgrep
(setq wgrep-auto-save-buffer t))
;; kubel — Kubernetes management
(use-package! kubel
:commands kubel
:config
(map! :map kubel-mode-map
:n "g" #'kubel-get-resource-details
:n "E" #'kubel-exec-pod
:n "l" #'kubel-get-pod-logs
:n "d" #'kubel-describe-resource
:n "D" #'kubel-delete-resource))
(map! :leader "o k" #'kubel)
;;; --- Tier 2: Quality of life ---
;; org-clock — time tracking (built into org, just configure)
(after! org
(setq org-clock-persist 'history
org-clock-in-resume t
org-clock-out-remove-zero-time-clocks t
org-clock-report-include-clocking-task t
org-duration-format 'h:mm)
(org-clock-persistence-insinuate)
(map! :map org-mode-map
:localleader
"C i" #'org-clock-in
"C o" #'org-clock-out
"C r" #'org-clock-report
"C d" #'org-clock-display))
;; combobulate — tree-sitter structural editing
(use-package! combobulate
:hook ((python-mode . combobulate-mode)
(python-ts-mode . combobulate-mode)
(go-mode . combobulate-mode)
(go-ts-mode . combobulate-mode)
(js-mode . combobulate-mode)
(typescript-mode . combobulate-mode))
:config
(map! :map combobulate-mode-map
:n "C-M-n" #'combobulate-navigate-next
:n "C-M-p" #'combobulate-navigate-previous
:n "C-M-u" #'combobulate-navigate-up
:n "C-M-d" #'combobulate-navigate-down))
;; iedit — edit multiple occurrences
(use-package! iedit
:commands iedit-mode)
(map! :leader "s e" #'iedit-mode)
;; vundo — visual undo tree
(use-package! vundo
:commands vundo
:config (setq vundo-glyph-alist vundo-unicode-symbols))
(map! :leader "u" #'vundo)
;; breadcrumb — context in header-line
(use-package! breadcrumb
:hook ((prog-mode . breadcrumb-local-mode)
(cperl-mode . breadcrumb-local-mode)))
;;; --- Tier 3: Supplementary ---
;; org-anki — Anki flashcards from org
(use-package! org-anki
:commands (org-anki-sync-entry org-anki-sync-all org-anki-delete-entry)
:config
(setq org-anki-default-deck "Emacs")
(map! :map org-mode-map
:localleader
"A s" #'org-anki-sync-entry
"A S" #'org-anki-sync-all
"A d" #'org-anki-delete-entry))
;; org-ql — query language for org
(use-package! org-ql
:commands (org-ql-search org-ql-view org-ql-find)
:config
(map! :leader
"s q" #'org-ql-search
"s Q" #'org-ql-view))
;; calfw — visual calendar
(use-package! calfw
:commands cfw:open-org-calendar)
(use-package! calfw-org
:after calfw
:commands cfw:open-org-calendar)
(map! :leader "o C" #'cfw:open-org-calendar)
;; org-roam-ui — visual graph for org-roam
(use-package! org-roam-ui
:after org-roam
:commands org-roam-ui-mode
:config
(setq org-roam-ui-sync-theme t
org-roam-ui-follow t
org-roam-ui-update-on-save t))
(map! :leader "n r u" #'org-roam-ui-mode)
;; dirvish — modern dired replacement
(use-package! dirvish
:init (dirvish-override-dired-mode)
:config
(setq dirvish-mode-line-format '(:left (sort symlink) :right (omit yank index))
dirvish-attributes '(vc-state subtree-state nerd-icons collapse git-msg file-time file-size)
dirvish-side-width 35)
(map! :map dirvish-mode-map
:n "q" #'dirvish-quit
:n "h" #'dired-up-directory
:n "l" #'dired-find-file
:n "s" #'dirvish-quicksort
:n "S" #'dirvish-setup-menu
:n "TAB" #'dirvish-subtree-toggle
:n "M-f" #'dirvish-history-go-forward
:n "M-b" #'dirvish-history-go-backward))
;;; --- BibTeX / Citar (installed by Doom biblio module) ---
(after! citar
(setq citar-bibliography '("~/org/references.bib")
citar-notes-paths '("~/org/notes/")
citar-library-paths '("~/org/papers/"))
(map! :leader
"b b" #'citar-open
"b i" #'citar-insert-citation
"b n" #'citar-open-notes
"b r" #'citar-refresh))
;;; --- Grammar check (LanguageTool, installed by Doom grammar module) ---
(after! langtool
(setq langtool-language-tool-jar
(expand-file-name "~/languagetool/languagetool-commandline.jar")
langtool-default-language "cs"
langtool-mother-tongue "cs"))
(map! :leader
"t g" #'langtool-check
"t G" #'langtool-check-done)