;;; $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// (created automatically if absent). ;; .tex is intermediate for PDF export -- both routed to exports/pdf/. (defun my/org-export-directory (extension) "Return ~/exports// 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// 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) ;;; ============================================================ ;;; 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// ;; 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 (after! forge (add-to-list 'forge-alist '("git.apps.sukany.cz" "git.apps.sukany.cz/api/v1" "git.apps.sukany.cz" forge-gitea-repository)))