From 61f629350cb4eebbc4b57ed4045c918492b441d0 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Wed, 4 Mar 2026 14:22:16 +0100 Subject: [PATCH] fix: removed position stuff from agenda movement - caused VoiceOver issues --- config.el | 23 - flycheck_config.el | 2127 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 2127 insertions(+), 23 deletions(-) create mode 100644 flycheck_config.el diff --git a/config.el b/config.el index a8ff702..147d458 100644 --- a/config.el +++ b/config.el @@ -423,29 +423,6 @@ Skip for beamer exports — beamer uses adjustbox on plain tabular." ;;; 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 diff --git a/flycheck_config.el b/flycheck_config.el new file mode 100644 index 0000000..147d458 --- /dev/null +++ b/flycheck_config.el @@ -0,0 +1,2127 @@ +;;; $DOOMDIR/config.el -*- lexical-binding: t; -*- + +;;; ============================================================ +;;; USER IDENTITY +;;; ============================================================ + +(setq user-full-name "Martin Sukany" + user-mail-address "martin@sukany.cz") + +;; CalDAV calendar IDs (edit here to update sync targets) +(defconst my/caldav-url "https://cal.apps.sukany.cz/dav.php/calendars/martin") +(defconst my/caldav-id-suky "default") +(defconst my/caldav-id-placeholders "4C748EE5-ECFF-4D4A-A72E-6DE37BAADEB3") +(defconst my/caldav-id-family "family") +(defconst my/caldav-id-klara "klara") + +;; 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) + ;; Ensure dashboard buffer starts in normal state (required for SPC leader) + (evil-set-initial-state '+doom-dashboard-mode 'normal)) + +;; 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)) + +;; 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-startup-folded 'content ; show all headings, hide body text + 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) + + ("c" "Calendar event" entry + (file ,(ms/org-file "calendar_outbox.org")) + "* %?\n%(my/calfw-capture-timestamp)\n" + :empty-lines 1)))) + +;; Ensure ox-icalendar exports active timestamps with times correctly +(after! ox-icalendar + (setq org-icalendar-with-timestamps 'active + org-icalendar-timezone "Europe/Prague")) + + +;;; ============================================================ +;;; 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// +(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 +;;; ============================================================ + +;; 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 2.0 + corfu-auto-prefix 3 ; need 3+ chars before popup + corfu-cycle t + corfu-preselect 'prompt + corfu-quit-no-match 'separator + corfu-preview-current nil) + ;; Re-set delay after global-corfu-mode to override Doom defaults + (add-hook 'global-corfu-mode-hook + (lambda () (setq corfu-auto-delay 2.0))) + (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 + ;; --- Mailbox layout --- + (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") + + ;; --- Headers view --- + (setq 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 --- + ;; Keys: u=Unread i=Inbox d=Today w=Week + ;; Note: ?d for Today avoids conflict with maildir shortcut ?t=Trash + (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 ?d) + (:name "Week" :query "date:7d..now AND NOT maildir:/personal/Trash AND NOT maildir:/personal/Archive AND NOT maildir:/personal/Sent" :key ?w))) + + ;; --- Maildir shortcuts (jump with 'j') --- + ;; Keys: i=INBOX s=Sent T=Trash a=Archive + ;; ?T (uppercase) for Trash avoids conflict with bookmark ?t (was Today) + (setq mu4e-maildir-shortcuts + '(("/personal/INBOX" . ?i) + ("/personal/Sent" . ?s) + ("/personal/Trash" . ?T) + ("/personal/Archive" . ?a))) + + ;; --- Sending --- + (setq sendmail-program "msmtp" + message-send-mail-function #'message-send-mail-with-sendmail + mail-specify-envelope-from t + message-sendmail-envelope-from 'header) + + ;; --- Compose / reply --- + ;; message-cite-function is a message-mode setting but configured here + ;; because it only matters in the context of mu4e replies. + (setq message-cite-function #'message-cite-original-without-signature + message-signature-file (expand-file-name "~/.mail/signature") + message-signature t) + + ;; --- Citation colors --- + ;; gnus-cite-* : colors in the view (read) buffer + ;; message-cited-text-*: colors in the compose (reply) buffer + ;; Both use the same Dracula palette for visual consistency. + ;; Duplicate face names are intentional — gnus and message-mode + ;; use separate face systems even though they render the same content. + (setq gnus-cite-face-list + '(gnus-cite-1 gnus-cite-2 gnus-cite-3 gnus-cite-4)) + (custom-set-faces! + '(gnus-cite-1 :foreground "#8be9fd" :italic t) ; cyan — level 1 + '(gnus-cite-2 :foreground "#bd93f9" :italic t) ; purple — level 2 + '(gnus-cite-3 :foreground "#6272a4" :italic t) ; blue — level 3 + '(gnus-cite-4 :foreground "#44475a" :italic t) ; grey — level 4+ + '(message-cited-text-1 :foreground "#8be9fd" :italic t) + '(message-cited-text-2 :foreground "#bd93f9" :italic t) + '(message-cited-text-3 :foreground "#6272a4" :italic t) + '(message-cited-text-4 :foreground "#44475a" :italic t)) + + ;; --- View: skip to message body --- + ;; gnus-article-prepare-hook fires when the article buffer is ready, + ;; covering both the initial render and navigation between messages. + ;; No need for mu4e-view-mode-hook (that fires earlier, before content). + (defun my/mu4e-view-goto-body () + "Position cursor at message body, skipping RFC 2822 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) + + ;; --- Headers: keep cursor on subject column after j/k --- + (defun my/mu4e-goto-subject (&rest _) + "Move cursor to the start of the subject text in a 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 (plain T marks the thread for bulk action) + (evil-define-key 'normal mu4e-headers-mode-map + (kbd "zT") #'mu4e-headers-toggle-threading)) + +;;; ============================================================ +;;; RSS — 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)) + +(advice-add 'projectile-project-root :around + (lambda (orig-fn &rest args) + (unless (file-remote-p default-directory) + (apply orig-fn args)))) + +(setq remote-file-name-inhibit-cache nil + tramp-verbose 1) + + +;;; ============================================================ +;;; DIRED & DIRVISH +;;; ============================================================ + +;; Always hide file details (permissions, size, date) for VoiceOver. +;; Toggle visibility with ( in dired/dirvish buffers. +;; Three layers of insurance: dirvish-hide-details, dired-mode-hook, +;; and dired-after-readin-hook (catches late buffer setup). +(add-hook 'dired-mode-hook #'dired-hide-details-mode) +;; Attach marked files to mail compose buffer via C-c RET C-a +(add-hook 'dired-mode-hook #'turn-on-gnus-dired-mode) +(add-hook 'dired-after-readin-hook + (lambda () (unless dired-hide-details-mode + (dired-hide-details-mode 1)))) + +;; 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-hide-details t + dirvish-mode-line-format '(:left (sort symlink) :right (omit yank index)) + dirvish-attributes '(vc-state subtree-state nerd-icons collapse git-msg) + dirvish-side-width 35) + (defun my/dirvish-toggle-details () + "Toggle file-time and file-size dirvish attributes." + (interactive) + (if (memq 'file-size dirvish-attributes) + (setq-local dirvish-attributes + (seq-remove (lambda (a) (memq a '(file-time file-size))) + dirvish-attributes)) + (setq-local dirvish-attributes + (append dirvish-attributes '(file-time file-size)))) + (revert-buffer)) + (map! :map dirvish-mode-map + :n "D" #'my/dirvish-toggle-details + :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)")) + + +(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) + + +;;; ============================================================ +;;; 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 +;;; ============================================================ + +(after! olivetti + (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 + ;; Agenda: start from today, no past days, no duplicates + (setq org-agenda-start-on-weekday nil + org-agenda-start-day "0d" + org-agenda-span 7 + org-agenda-skip-scheduled-if-done t + org-agenda-skip-deadline-if-done t + org-agenda-skip-scheduled-if-deadline-is-shown t + org-agenda-skip-deadline-prewarning-if-scheduled 'pre-scheduled) + + ;; Sorting: priority first, then deadline, then scheduled + (setq org-agenda-sorting-strategy + '((agenda priority-down deadline-up scheduled-up) + (todo priority-down deadline-up) + (tags priority-down deadline-up))) + + (setq org-super-agenda-groups + '((:name "Overdue" + :deadline past) + (:name "Due today" + :deadline today) + (:name "Scheduled today" + :scheduled today) + (:name "Due soon" + :deadline future) + (:name "Waiting" + :todo "WAIT") + (:name "Kyndryl" + :tag ("kyndryl" "work")) + (:name "ZTJ" + :tag "ztj") + (:name "Other" + :anything t)))) + +(after! org-super-agenda + (org-super-agenda-mode 1) + ;; Fix: org-super-agenda applies its own keymap to group headers + ;; via text properties, overriding org-agenda keybindings. + ;; Setting it to nil disables the header keymap entirely so all + ;; standard agenda keys (m, B, t, etc.) work on every line. + (setq org-super-agenda-header-map nil)) + + +;;; ============================================================ +;;; 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 + (advice-add 'org-caldav-update-events-in-org :around + (lambda (orig-fn &rest args) + "Catch errors during cal->org sync; log and return so sync state is saved." + (condition-case err + (apply orig-fn args) + (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 my/caldav-url + org-caldav-calendar-id my/caldav-id-suky + 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 my/caldav-url + org-caldav-calendar-id my/caldav-id-placeholders + 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 my/caldav-url + org-caldav-calendar-id my/caldav-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 my/caldav-url + org-caldav-calendar-id my/caldav-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"))) + + + +;;; ============================================================ +;;; CALFW — visual calendar +;;; ============================================================ + +(use-package! calfw :demand t) +(use-package! calfw-org + :demand t + :config + ;; org-capture integration: "a" to add event on selected date + ;; Store date from calfw before capture starts + (defvar my/calfw-capture-date nil "Date selected in calfw for capture.") + + (defun my/calfw-capture () + "Start org-capture with calfw date context." + (interactive) + (setq my/calfw-capture-date (calfw-cursor-to-nearest-date)) + (org-capture nil "c")) + + ;; Prompts for start/end time; empty = all-day event + ;; Uses active timestamp with time range for timed events, + ;; or date-range (-->--) for multi-day events. + (defun my/calfw-capture-timestamp () + "Build org timestamp for selected calfw date with optional time prompt. +Formats matching what org-caldav/ox-icalendar export correctly: + - All-day single: <2026-02-26 Thu> + - Timed single day: <2026-02-26 Thu 14:00-15:30> + - All-day multi: <2026-02-26 Thu>--<2026-02-28 Sat> + - Timed multi-day: <2026-02-26 Thu 15:00>--<2026-02-28 Sat 22:00>" + (let* ((date (or my/calfw-capture-date (calendar-current-date))) + (m (nth 0 date)) + (d (nth 1 date)) + (y (nth 2 date)) + (dow (format-time-string "%a" (encode-time 0 0 12 d m y))) + (start-time (read-string "Start time (HH:MM, empty=all-day): ")) + (end-input (unless (string-empty-p start-time) + (read-string "End time or end date (HH:MM / YYYY-MM-DD / YYYY-MM-DD HH:MM, empty=same day open): ")))) + (cond + ;; All-day event (no time given) + ((string-empty-p start-time) + (let ((end-date (read-string "End date for multi-day (YYYY-MM-DD, empty=single day): "))) + (if (string-empty-p end-date) + (format "<%04d-%02d-%02d %s>" y m d dow) + (let* ((parsed (parse-time-string (concat end-date " 00:00"))) + (ed (nth 3 parsed)) (em (nth 4 parsed)) (ey (nth 5 parsed)) + (edow (format-time-string "%a" (encode-time 0 0 12 ed em ey)))) + (format "<%04d-%02d-%02d %s>--<%04d-%02d-%02d %s>" y m d dow ey em ed edow))))) + ;; Timed event - check if end contains a date (multi-day) + ((and end-input (not (string-empty-p end-input)) + (string-match "^[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" end-input)) + ;; Multi-day with times + (let* ((end-time-part (if (string-match " \\([0-9]\\{2\\}:[0-9]\\{2\\}\\)" end-input) + (match-string 1 end-input) "")) + (end-date-part (substring end-input 0 10)) + (parsed (parse-time-string (concat end-date-part " 00:00"))) + (ed (nth 3 parsed)) (em (nth 4 parsed)) (ey (nth 5 parsed)) + (edow (format-time-string "%a" (encode-time 0 0 12 ed em ey)))) + (if (string-empty-p end-time-part) + (format "<%04d-%02d-%02d %s %s>--<%04d-%02d-%02d %s>" + y m d dow start-time ey em ed edow) + (format "<%04d-%02d-%02d %s %s>--<%04d-%02d-%02d %s %s>" + y m d dow start-time ey em ed edow end-time-part)))) + ;; Same-day timed event with end time (HH:MM) + ((and end-input (not (string-empty-p end-input))) + (format "<%04d-%02d-%02d %s %s-%s>" y m d dow start-time end-input)) + ;; Same-day timed event, no end time + (t + (format "<%04d-%02d-%02d %s %s>" y m d dow start-time))))) + + (setq calfw-org-capture-template + '("c" "Calendar event" entry + (file "~/org/calendar_outbox.org") + "* %?\n%(my/calfw-capture-timestamp)\n")) + + ;; 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 + "a" #'my/calfw-capture + "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)) + + +;;; ============================================================ +;;; 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) + +(use-package! vundo + :commands vundo + :config (setq vundo-glyph-alist vundo-unicode-symbols)) + +(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))) + + +;;; ============================================================ +;;; BIBLIOGRAPHY — citar + biblio + org-cite +;;; ============================================================ +;; Global .bib: ~/org/references.bib (always available) +;; Per-project: add #+BIBLIOGRAPHY: ./refs.bib in org file header +;; +;; Workflow: +;; SPC B n → create new .bib (prompted for path) +;; SPC B s → search CrossRef → in results press 'i' to save to .bib +;; SPC B i → insert citation in org file +;; Export: org-cite + CSL auto-formats references +;; +;; Per-project setup: put .dir-locals.el in project root: +;; ((org-mode . ((citar-bibliography . ("./refs.bib"))))) + +;; Global bibliography paths (setq! required for Doom's custom setter) +(after! citar + (setq! citar-bibliography '("~/org/references.bib")) + (setq! citar-notes-paths '("~/org/notes/")) + (setq! citar-library-paths '("~/org/papers/")) + + ;; Auto-create global .bib if missing + (let ((bib (expand-file-name "~/org/references.bib"))) + (unless (file-exists-p bib) + (make-directory (file-name-directory bib) t) + (with-temp-file bib (insert "% Global bibliography\n\n"))))) + +;; org-cite: CSL processor (no BibLaTeX toolchain needed) +(after! oc + (require 'citeproc) + (setq org-cite-global-bibliography '("~/org/references.bib") + org-cite-export-processors '((latex csl) + (html csl) + (t csl)))) + +;; biblio.el: polite CrossRef access (required by their API policy) +(setq biblio-crossref-user-email-address "martin@sukany.cz") + +;; BibTeX editing defaults +(setq bibtex-dialect 'biblatex + bibtex-autokey-year-length 4 + bibtex-autokey-name-year-separator "" + bibtex-autokey-year-title-separator "-" + bibtex-autokey-titleword-length 5 + bibtex-autokey-titlewords 3) + +;; Helper: create new .bib file for a project +(defun my/bib-new () + "Create a new .bib file at prompted path and open it." + (interactive) + (let ((path (read-file-name "New .bib file: " default-directory nil nil "refs.bib"))) + (make-directory (file-name-directory path) t) + (unless (file-exists-p path) + (with-temp-file path + (insert (format "%% Bibliography: %s\n%% Add entries via SPC B s (search) or SPC B m (manual)\n\n" + (file-name-nondirectory path))))) + (find-file path) + (message "Opened %s — search online (SPC B s) or add manual entry (SPC B m)" path))) + +;; Helper: import from DOI → appends to currently open .bib or global +(defun my/bib-import-doi () + "Fetch BibTeX for a DOI and insert at point (open a .bib file first)." + (interactive) + (require 'biblio) + (require 'biblio-doi) + (let ((doi (read-string "DOI: "))) + (biblio-doi-insert-bibtex doi))) + +;; Helper: insert a manual BibTeX entry template +(defun my/bib-insert-manual () + "Insert a BibTeX entry template at point for manual editing." + (interactive) + (let ((type (completing-read "Entry type: " + '("article" "book" "inproceedings" "misc" "online" + "techreport" "thesis" "manual") nil t))) + (insert (pcase type + ("article" + "@article{KEY,\n author = {},\n title = {},\n journal = {},\n year = {},\n volume = {},\n pages = {},\n doi = {},\n}\n") + ("book" + "@book{KEY,\n author = {},\n title = {},\n publisher = {},\n year = {},\n isbn = {},\n}\n") + ("inproceedings" + "@inproceedings{KEY,\n author = {},\n title = {},\n booktitle = {},\n year = {},\n pages = {},\n doi = {},\n}\n") + ("misc" + "@misc{KEY,\n author = {},\n title = {},\n year = {},\n url = {},\n note = {},\n}\n") + ("online" + "@online{KEY,\n author = {},\n title = {},\n url = {},\n urldate = {},\n year = {},\n}\n") + ("techreport" + "@techreport{KEY,\n author = {},\n title = {},\n institution = {},\n year = {},\n number = {},\n}\n") + ("thesis" + "@thesis{KEY,\n author = {},\n title = {},\n school = {},\n year = {},\n type = {phdthesis},\n}\n") + ("manual" + "@manual{KEY,\n title = {},\n organization = {},\n year = {},\n url = {},\n}\n"))) + ;; Position cursor at KEY for immediate editing + (search-backward "KEY" nil t) + (message "Fill in KEY and fields. KEY = unique citation identifier (e.g. burns2016)."))) + +;; biblio-lookup is the universal search command (prompts for backend) +;; crossref-lookup, arxiv-lookup, dblp-lookup are backend-specific +(map! :leader + (:prefix ("B" . "bibliography") + :desc "Browse references" "b" #'citar-open + :desc "Insert citation [cite:]" "i" #'citar-insert-citation + :desc "Open notes for reference" "n" #'citar-open-notes + :desc "Refresh bibliography" "r" #'citar-refresh + :desc "New .bib file" "N" #'my/bib-new + :desc "Search online" "s" (cmd! (require 'biblio) (call-interactively #'biblio-lookup)) + :desc "Import from DOI" "D" #'my/bib-import-doi + :desc "Insert manual entry" "m" #'my/bib-insert-manual + :desc "Open global .bib" "f" (cmd! (find-file (expand-file-name "~/org/references.bib"))))) + + +;;; ============================================================ +;;; 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)) + + +;;; ============================================================ +;;; 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")) + + +;;; ============================================================ +;;; KEYBINDINGS — central reference +;;; ============================================================ +;;; Standalone bindings (package-independent, safe to define top-level). +;; +;; The following bindings are defined near their packages (load-order sensitive): +;; SPC o r — elfeed (elfeed section) +;; SPC o n/N — org-noter (org-noter section) +;; SPC o C — calendar/calfw (calfw section) +;; SPC j k/K — link-hint (link-hint section) +;; SPC t o — olivetti (olivetti section) +;; SPC z +/= — zoom in/out (keybindings section below) +;; SPC o M — Matrix/ement (Matrix section) +;; SPC B b/i — bibliography/citar (bibliography section) +;; SPC s q/Q — org-ql (org-ql section) +;; SPC | di| ci| vi| — table cell objects (evil-org section) + +(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)) + +(map! :leader :desc "Elfeed" "o r" #'elfeed) +(map! :leader + (:prefix ("t" . "toggle") + :desc "Speech ON" "s" #'my/emacspeak-on + :desc "Speech OFF" "S" #'my/emacspeak-off)) +(map! :leader + (:prefix ("h" . "help") + :desc "Describe bindings (buffer-local)" "B" #'describe-bindings)) +(map! :leader "o k" #'kubel) +(map! :leader "s e" #'iedit-mode) +(map! :leader "u" #'vundo) +(map! :leader "n r u" #'org-roam-ui-mode) +(map! :leader + "t g" #'langtool-check + "t G" #'langtool-check-done) +(map! :leader "o c" #'my/org-caldav-sync) + +;; gls +(setq insert-directory-program "gls") + + +;; Always enable macOS accessibility (VoiceOver + Zoom cursor tracking). +;; Override auto-detection so the AX tree is available from startup +;; regardless of whether an AT is currently active. +(when (eq system-type 'darwin) + (setq ns-accessibility-enabled t))