Files
emacs-doom/config.el

1880 lines
70 KiB
EmacsLisp

;;; $DOOMDIR/config.el -*- lexical-binding: t; -*-
;;; ============================================================
;;; USER IDENTITY
;;; ============================================================
(setq user-full-name "Martin Sukany"
user-mail-address "martin@sukany.cz")
;; Trust all TLS certificates (corporate MITM proxy with intermediate CA)
(setq gnutls-verify-error nil)
(setq tls-checktrust nil)
(setq network-security-level 'low)
;;; ============================================================
;;; 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))
;;; ============================================================
;;; 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
ccm-step-size 2
ccm-recenter-at-end-of-file t)
(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
focus-follows-mouse nil
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.
(let ((extra-paths '("/opt/local/bin"
"/opt/local/sbin"
"/opt/homebrew/bin"
"/opt/homebrew/sbin"
"/usr/local/bin"
"/Library/TeX/texbin")))
(dolist (p (reverse extra-paths))
(setenv "PATH" (concat p ":" (getenv "PATH")))
(add-to-list 'exec-path p)))
;; macOS clipboard integration via pbcopy/pbpaste
(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
(after! evil
(setq evil-want-clipboard t))
;; 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))
;; Disable mouse clicks in GUI (prevent accidental cursor movement)
;; Scroll wheel events are NOT disabled — scrolling works normally.
(when (display-graphic-p)
(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: standard mwheel.el with conservative settings.
;; pixel-scroll-precision-mode is NOT used (targets X11, breaks NS/Cocoa).
(when (display-graphic-p)
(require 'mwheel)
(setq mouse-wheel-scroll-amount '(3 ((shift) . 1) ((meta) . 0) ((control) . text-scale))
mouse-wheel-progressive-speed nil
mouse-wheel-follow-mouse t
mouse-wheel-tilt-scroll t
mouse-wheel-flip-direction nil)
(global-set-key [wheel-up] #'mwheel-scroll)
(global-set-key [wheel-down] #'mwheel-scroll))
;; Ensure dashboard buffer starts in normal state (required for SPC leader)
(after! evil
(evil-set-initial-state '+doom-dashboard-mode 'normal))
;; Cancel persp-mode's 2.5s cache timer after startup
;; (reduces unnecessary redraws that cause macOS 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))))
;;; ============================================================
;;; CLIPBOARD IMAGE PASTE (macOS — cmd+v)
;;; ============================================================
;; Requires: brew install pngpaste
;; cmd+v: clipboard has image → saves to attachments/ → inserts link
;; clipboard has text → normal paste (yank)
(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/ and insert link at point."
(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."
(interactive)
(if (my/clipboard-has-image-p)
(my/paste-image-from-clipboard)
(yank)))
(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)
;;; ============================================================
;;; PERFORMANCE & GC
;;; ============================================================
(setq gc-cons-threshold (* 100 1024 1024)
gc-cons-percentage 0.6)
(after! gcmh
(setq gcmh-idle-delay 'auto
gcmh-auto-idle-delay-factor 20
gcmh-high-cons-threshold (* 200 1024 1024)))
(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)
;; TLS: also allow TLS 1.2 fallback for self-signed local services
(setq gnutls-algorithm-priority "NORMAL:-VERS-TLS1.3")
;;; ============================================================
;;; ORG MODE — CORE
;;; ============================================================
;; Czech holidays only (no US/Hebrew/Christian defaults)
(setq calendar-holidays
'((holiday-fixed 1 1 "New Year / Czech Independence Restoration Day")
(holiday-easter-etc -2 "Good Friday")
(holiday-easter-etc 1 "Easter Monday")
(holiday-fixed 5 1 "Labour Day")
(holiday-fixed 5 8 "Victory Day")
(holiday-fixed 7 5 "Saints Cyril and Methodius Day")
(holiday-fixed 7 6 "Jan Hus Day")
(holiday-fixed 9 28 "Czech Statehood Day")
(holiday-fixed 10 28 "Czechoslovak Independence Day")
(holiday-fixed 11 17 "Freedom and Democracy Day")
(holiday-fixed 12 24 "Christmas Eve")
(holiday-fixed 12 25 "Christmas Day")
(holiday-fixed 12 26 "St. Stephen's Day")))
(after! org
(require 'ox-hugo)
(setq org-directory "~/org/")
(setq org-default-notes-file (expand-file-name "inbox.org" 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)
(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 ; 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)
(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
;;; ============================================================
;; Table export filter: tabular -> tabularx with auto-width Y columns.
;; Skipped for beamer exports (beamer uses adjustbox on plain tabular).
;; 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.
Skip for beamer exports — beamer uses adjustbox on plain tabular."
(when (and (stringp table)
(not (org-export-derived-backend-p backend 'beamer)))
(with-temp-buffer
(insert table)
(goto-char (point-min))
(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)))
(goto-char (point-min))
(while (re-search-forward "\\\\end{tabular}" nil t)
(replace-match "\\end{tabularx}" t t))
(buffer-string))))
(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)
;; Ensure latexmk doesn't hang on errors
(after! org
(setq org-latex-pdf-process
'("latexmk -f -pdf -%latex -interaction=nonstopmode -output-directory=%o %f")))
;; LaTeX aux file cleanup after export
(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>/
(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
;;; ============================================================
;; Agenda: position cursor at task name (after TODO keyword and 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."
(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)
;; Also trigger on post-command-hook in agenda buffers (catches Evil j/k,
;; super-agenda navigation, and any other motion commands)
(add-hook 'org-agenda-mode-hook
(lambda ()
(add-hook 'post-command-hook
#'my/org-agenda-goto-task-name nil t)))
;; Org buffer: snap cursor past TODO keyword/priority on headings in normal state
(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
(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)))
;; 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 "Improve the following text. Return ONLY the improved text, nothing else:\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)))
;;; ============================================================
;;; 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))
(use-package! cape
:after corfu
:config
(defun martin/cape-capf-setup-text ()
"Cape sources for text modes (org, markdown) — 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))
;; Yasnippet-capf first in CAPF list so corfu shows/expands snippets
(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 for Emacs < 31 (31+ handles it natively)
(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))
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
(setq message-signature-file (expand-file-name "~/.mail/signature")
message-signature t)
;; Move cursor past headers to message body when opening a message
(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))
(while (and (not (eobp))
(looking-at "^\\([A-Za-z-]+:\\|[ \t]\\)"))
(forward-line 1))
(while (and (not (eobp)) (looking-at "^\\s-*$"))
(forward-line 1)))))))
(add-hook 'gnus-article-prepare-hook #'my/mu4e-view-goto-body)
(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."
(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)
(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 (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 & DIRVISH
;;; ============================================================
;; Emacs 31 may not autoload dired-read-dir-and-switches early enough
(require 'dired)
;; macOS: use GNU ls (coreutils) for dired/dirvish sorting support
(setq insert-directory-program "gls")
(after! dired
(put 'dired-find-alternate-file 'disabled nil)
(map! :map dired-mode-map
"RET" #'dired-find-alternate-file
"^" #'dired-up-directory))
;; 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))
;;; ============================================================
;;; 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"))
(dolist (dir '("node_modules" "__pycache__" ".terraform" "vendor"))
(add-to-list 'projectile-globally-ignored-directories dir)))
;;; ============================================================
;;; PYTHON
;;; ============================================================
(setq python-shell-interpreter "python3")
(after! org
(setq org-babel-python-command "python3")
(require 'ob-python))
(after! python
(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))))))
;;; ============================================================
;;; DEVELOPER WORKFLOW — Perl, Go, Ansible, Terraform, Docker
;;; ============================================================
;;; --- Perl (cperl-mode) ---
(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))
(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)
(add-hook 'cperl-mode-hook
(lambda ()
(when (fboundp 'flycheck-select-checker)
(flycheck-select-checker 'perl)))))
;;; --- Go ---
(after! go-mode
(setq gofmt-command "goimports")
(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))))))
(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))))))
(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)))
;;; ============================================================
;;; 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)
(defvar my/emacspeak-inhibit-server t
"When non-nil, Emacspeak server will not start/restart.")
(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
(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))
(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 ZOOM (SPC z)
;;; ============================================================
;; Scales the global `default' face — all buffers, help, menus included.
;; Modeline + header-line pinned to base size. Step: x1.5 per step.
;; SPC z +/= zoom in, SPC z - zoom out, SPC z 0 reset, SPC z z restore.
(defvar my/zoom-base-height 140)
(defvar my/zoom-steps 0)
(defvar my/zoom-saved-steps nil)
(defvar my/zoom-pinned-faces
'(mode-line mode-line-inactive mode-line-active
header-line tab-bar tab-bar-tab tab-bar-tab-inactive))
(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))))
(after! which-key
(setq which-key-side-window-max-height 0.90
which-key-max-display-columns nil))
(defun my/zoom--apply (steps)
"Scale global default face to base x 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 x%.2f ~%dpt" steps (expt 1.5 steps) (/ new-h 10))
(when (and (not (minibufferp)) (window-live-p (selected-window)))
(scroll-right (window-hscroll))
(recenter nil))))
(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)))))
(add-hook 'doom-load-theme-hook #'my/zoom-pin-ui)
(defun my/zoom-in () (interactive) (cl-incf my/zoom-steps) (my/zoom--apply my/zoom-steps))
(defun my/zoom-out () (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)))
(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 (x1.5)" "+" #'my/zoom-in
:desc "Zoom in (x1.5)" "=" #'my/zoom-in
:desc "Zoom out (x1.5)" "-" #'my/zoom-out
:desc "Reset" "0" #'my/zoom-reset
:desc "Restore previous" "z" #'my/zoom-restore))
;;; ============================================================
;;; MATRIX — EMENT.EL
;;; ============================================================
;; Matrix client. SPC o M (open -> matrix).
;; SPC o m is taken by Doom's mu4e module, hence uppercase M.
(setq ement-save-sessions t
ement-sessions-file (expand-file-name "ement-sessions.el" doom-private-dir))
(after! ement
(setq ement-auto-sync t
ement-room-timestamp-format "%H:%M"
ement-room-show-avatars nil
ement-room-username-display-property '(raise 0)
ement-notify-mentions-p t
ement-notify-dingalings-p nil))
(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."
(require 'ement)
(condition-case err
(let ((sf (expand-file-name ement-sessions-file)))
(when (file-readable-p sf)
(unless ement-sessions
(when (fboundp 'ement--load-sessions)
(setq ement-sessions (ement--load-sessions))))
(if (fboundp 'ement--reconnect)
(dolist (entry ement-sessions)
(ement--reconnect (cdr entry)))
(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)."
(interactive)
(require 'ement)
(cond
((and (boundp 'ement-sessions) ement-sessions)
(ement-list-rooms))
((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))
(t
(add-hook 'ement-after-initial-sync-hook #'my/ement-open-after-sync)
(call-interactively #'ement-connect))))
(add-hook 'doom-after-init-hook #'my/ement-maybe-restore)
(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
;;; ============================================================
(after! pdf-tools
(pdf-tools-install :no-query))
(after! pdf-view
(setq-default pdf-view-display-size 'fit-page)
(setq pdf-view-use-scaling t
pdf-view-use-imagemagick nil
pdf-view-midnight-colors '("#d4d4d4" . "#1c1c1c")
pdf-view-continuous t)
(add-hook 'pdf-view-mode-hook (lambda () (display-line-numbers-mode -1)))
(add-hook 'pdf-view-mode-hook #'auto-revert-mode))
(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
(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 instead of Preview.app
(when (eq system-type 'darwin)
(setq org-file-apps
'((auto-mode . emacs)
(directory . emacs)
("\\.mm\\'" . default)
("\\.x?html?\\'" . default)
("\\.pdf\\'" . emacs))))
;;; ============================================================
;;; YASNIPPET — snippets from ~/org/snippets
;;; ============================================================
(after! yasnippet
(push (expand-file-name "snippets/" org-directory) yas-snippet-dirs)
(yas-reload-all)
;; Select default text when entering a snippet field in Evil
(add-hook 'yas-before-expand-snippet-hook
(lambda () (when (evil-normal-state-p) (evil-insert-state))))
(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
;;; ============================================================
(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
;;; ============================================================
(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-FRAGTOG — auto-render LaTeX fragments
;;; ============================================================
(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
;;; ============================================================
(use-package! org-super-agenda
:after org-agenda
:config
(setq org-super-agenda-groups
'((:name "Kyndryl — today"
:and (:tag ("kyndryl" "work") :scheduled today))
(:name "Kyndryl — deadline"
:and (:tag ("kyndryl" "work") :deadline t))
(:name "Kyndryl"
:tag ("kyndryl" "work"))
(:name "ZTJ — today"
:and (:tag "ztj" :scheduled today))
(:name "ZTJ"
:tag "ztj")
(:name "Today"
:scheduled today
:deadline today)
(:name "Waiting"
:todo "WAIT")
(:name "Other"
:anything t))))
(after! org-super-agenda
(org-super-agenda-mode 1))
;;; ============================================================
;;; ORG-NOTER — PDF annotations
;;; ============================================================
(use-package! org-noter
:after (:any org pdf-view)
:config
(setq org-noter-notes-window-location 'horizontal-split
org-noter-notes-search-path (list (expand-file-name "annotations/" org-directory))
org-noter-default-notes-file-names '("notes.org")
org-noter-auto-save-last-location t
org-noter-insert-note-no-questions nil
org-noter-use-indirect-buffer nil
org-noter-always-create-frame nil))
;; Smart org-noter launcher: repairs broken notes files and starts from PDF window
(defun my/org-noter-start ()
"Start org-noter for the PDF visible 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 path
(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)
(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)))
(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 for proper frame split
(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))
;;; ============================================================
;;; ORG-CALDAV — CalDAV sync (4 calendars)
;;; ============================================================
;; Baikal server: cal.apps.sukany.cz
;; Credentials: ~/.authinfo (machine cal.apps.sukany.cz login martin password ...)
;;
;; Files:
;; ~/org/calendar_outbox.org — events to upload (in agenda-files)
;; ~/org/caldav/suky.org — downloaded from Suky calendar (NOT in agenda)
;; ~/org/caldav/placeholders.org — Placeholders calendar (NOT in agenda)
;; ~/org/caldav/family.org — Family calendar (NOT in agenda)
;; ~/org/caldav/klara.org — Klara's personal calendar (NOT in agenda)
;;
;; caldav/ files are kept out of agenda to avoid polluting it with
;; historical events. View them via calfw (SPC o C).
(use-package! org-caldav
:commands my/org-caldav-sync
:config
(setq org-caldav-days-in-past 1825 ; 5 years back
org-caldav-delete-org-entries 'never
org-caldav-delete-calendar-entries 'never
org-caldav-save-directory "~/org/")
;; Error handler: catch errors during cal->org event update
;; so sync state is saved even if individual events fail
(defadvice org-caldav-update-events-in-org (around skip-failed-events activate)
"Catch errors during cal->org sync; log and return so sync state is saved."
(condition-case err
ad-do-it
(error
(message "org-caldav: update-events-in-org error (sync continues): %S" err)
(org-caldav-debug-print 1 (format "update-events-in-org error: %S" err)))))
(defun my/org-caldav-sync ()
"Sync 4 CalDAV calendars: Suky (twoway), Placeholders, Family, Klara (read-only)."
(interactive)
;; Ensure caldav/ directory and inbox files exist
(make-directory "~/org/caldav" t)
(dolist (f '("~/org/caldav/suky.org"
"~/org/caldav/placeholders.org"
"~/org/caldav/family.org"
"~/org/caldav/klara.org"))
(unless (file-exists-p (expand-file-name f))
(with-temp-file (expand-file-name f)
(insert "#+TITLE: CalDAV sync\n#+STARTUP: overview\n"))))
;; 1. Suky (twoway): download -> suky.org, upload from calendar_outbox.org
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
org-caldav-calendar-id "default"
org-caldav-inbox "~/org/caldav/suky.org"
org-caldav-files '("~/org/calendar_outbox.org")
org-caldav-sync-direction 'twoway)
(org-caldav-sync)
;; 2. Placeholders (read-only)
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
org-caldav-calendar-id "4C748EE5-ECFF-4D4A-A72E-6DE37BAADEB3"
org-caldav-inbox "~/org/caldav/placeholders.org"
org-caldav-files nil
org-caldav-sync-direction 'cal->org)
(org-caldav-sync)
;; 3. Family (read-only, shared via Baikal ACL)
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
org-caldav-calendar-id "family"
org-caldav-inbox "~/org/caldav/family.org"
org-caldav-files nil
org-caldav-sync-direction 'cal->org)
(org-caldav-sync)
;; 4. Klara (read-only, shared via Baikal ACL)
(setq org-caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin"
org-caldav-calendar-id "klara"
org-caldav-inbox "~/org/caldav/klara.org"
org-caldav-files nil
org-caldav-sync-direction 'cal->org)
(org-caldav-sync)
(message "CalDAV sync done: Suky + Placeholders + Family + Klara")))
(map! :leader "o c" #'my/org-caldav-sync)
;;; ============================================================
;;; CALFW — visual calendar
;;; ============================================================
(use-package! calfw :demand t)
(use-package! calfw-org
:demand t
:config
;; Evil normal state for calfw (SPC = Doom leader works)
(evil-set-initial-state 'calfw-calendar-mode 'normal)
(evil-set-initial-state 'calfw-details-mode 'normal)
(evil-define-key 'normal calfw-calendar-mode-map
"h" #'calfw-navi-previous-day-command
"l" #'calfw-navi-next-day-command
"j" #'calfw-navi-next-week-command
"k" #'calfw-navi-previous-week-command
"n" #'calfw-navi-next-week-command
"p" #'calfw-navi-previous-week-command
"t" #'calfw-navi-goto-today-command
"g" #'calfw-refresh-calendar-buffer
"W" #'calfw-change-view-week
"M" #'calfw-change-view-month
"D" #'calfw-change-view-day
"TAB" #'calfw-navi-next-item-command
"q" #'bury-buffer
"x" #'calfw-org-clean-exit
"RET" #'calfw-org-open-agenda-day
"<" #'calfw-navi-prev-view
">" #'calfw-navi-next-view)
;; Sort periods (same-day events from org-caldav active-range format)
(defun my/calfw-event-time-int (evt)
"Return start-time as HHMM integer, or nil."
(when-let ((st (calfw-event-start-time evt)))
(+ (* 100 (car st)) (cadr st))))
(defun my/calfw-sort-periods (result)
"Sort periods sublist in RESULT by start-time."
(mapcar (lambda (item)
(if (and (listp item) (eq 'periods (car item)))
(cons 'periods
(sort (copy-sequence (cdr item))
(lambda (a b)
(let ((ta (my/calfw-event-time-int a))
(tb (my/calfw-event-time-int b)))
(cond ((and ta tb) (< ta tb))
(ta t)
(t nil))))))
item))
result))
(defun my/calfw-sorted-file-source (name file color)
"Create calfw-org file source with sorted periods."
(let ((base (calfw-org-create-file-source name file color)))
(make-calfw-source
:name (calfw-source-name base)
:color (calfw-source-color base)
:data (lambda (begin end)
(my/calfw-sort-periods
(funcall (calfw-source-data base) begin end))))))
(defun my/calfw-sorter (x y)
(let* ((ex (get-text-property 0 'cfw:event x))
(ey (get-text-property 0 'cfw:event y))
(ta (or (and ex (my/calfw-event-time-int ex))
(get-text-property 0 'time-of-day x)))
(tb (or (and ey (my/calfw-event-time-int ey))
(get-text-property 0 'time-of-day y))))
(cond ((and ta tb) (< ta tb)) (ta t) (tb nil) (t (string-lessp x y)))))
(defun my/open-calendar ()
"Open calfw with org-agenda + CalDAV sources (Suky, Klara, Family)."
(interactive)
(condition-case err
(let* ((cd (expand-file-name "~/org/caldav/"))
(sources (delq nil
(list
(calfw-org-create-source nil "Agenda" "SeaGreen4")
(when (file-exists-p (concat cd "suky.org"))
(my/calfw-sorted-file-source "Suky" (concat cd "suky.org") "SteelBlue"))
(when (file-exists-p (concat cd "klara.org"))
(my/calfw-sorted-file-source "Klara" (concat cd "klara.org") "Gold"))
(when (file-exists-p (concat cd "family.org"))
(my/calfw-sorted-file-source "Family" (concat cd "family.org") "ForestGreen"))))))
(calfw-open-calendar-buffer
:contents-sources sources
:view 'month
:custom-map calfw-org-custom-map
:sorter #'my/calfw-sorter))
(error
(message "calfw multi-source: %s — fallback" (error-message-string err))
(calfw-org-open-calendar))))
(map! :leader "o C" #'my/open-calendar))
;;; ============================================================
;;; EXTENSIONS — envrc, embark, wgrep, kubel
;;; ============================================================
(use-package! envrc
:hook (after-init . envrc-global-mode))
(after! embark
(setq embark-prompter #'embark-keymap-prompter)
(map! "C-." #'embark-act
(:map minibuffer-local-map "C-." #'embark-act)))
(after! wgrep
(setq wgrep-auto-save-buffer t))
(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)
;;; ============================================================
;;; ORG-CLOCK — time tracking
;;; ============================================================
(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))
;;; ============================================================
;;; MISC EXTENSIONS — iedit, vundo, breadcrumb
;;; ============================================================
(use-package! iedit :commands iedit-mode)
(map! :leader "s e" #'iedit-mode)
(use-package! vundo
:commands vundo
:config (setq vundo-glyph-alist vundo-unicode-symbols))
(map! :leader "u" #'vundo)
(use-package! breadcrumb
:hook ((prog-mode . breadcrumb-local-mode)
(cperl-mode . breadcrumb-local-mode)))
;;; ============================================================
;;; EVIL — ORG TABLE CELL TEXT OBJECTS (di| ci| vi|)
;;; ============================================================
(after! evil-org
(evil-org-set-key-theme '(navigation insert textobjects additional calendar))
(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))
;;; ============================================================
;;; GIT — git-link, Forge (Gitea)
;;; ============================================================
(use-package! git-link
:defer t
:config
(setq git-link-default-branch "master")
(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))
(after! forge
(add-to-list 'forge-alist
'("git.apps.sukany.cz" "git.apps.sukany.cz/api/v1" "git.apps.sukany.cz" forge-gitea-repository)))
;;; ============================================================
;;; BIBTEX / CITAR
;;; ============================================================
(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))
;;; ============================================================
;;; ORG-ANKI — flashcards
;;; ============================================================
(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))
;;; ============================================================
;;; ORG-ROAM-UI — visual graph
;;; ============================================================
(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)
;;; ============================================================
;;; GRAMMAR CHECK — LanguageTool
;;; ============================================================
(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)
;; gls
(setq insert-directory-program "gls")