Files
emacs-doom/config.el

1275 lines
48 KiB
EmacsLisp
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
;;; $DOOMDIR/config.el -*- lexical-binding: t; -*-
;;; ============================================================
;;; USER IDENTITY
;;; ============================================================
(setq user-full-name "Martin Sukany"
user-mail-address "martin@sukany.cz")
;;; ============================================================
;;; THEME & FONT
;;; ============================================================
(setq doom-theme 'modus-vivendi-deuteranopia
doom-variable-pitch-font nil)
;; Font: JetBrains Mono preferred; fallback to Menlo (always on macOS)
;; Install: brew install --cask font-jetbrains-mono
(setq doom-font (if (find-font (font-spec :name "JetBrains Mono"))
(font-spec :family "JetBrains Mono" :size 14)
(font-spec :family "Menlo" :size 14)))
(setq display-line-numbers-type t)
;;; ============================================================
;;; 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: add MacTeX binaries
(setenv "PATH" (concat "/Library/TeX/texbin:" (getenv "PATH")))
(add-to-list 'exec-path "/Library/TeX/texbin")
;; 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)))
;; 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)
;; LuaLaTeX — nativní Unicode, žádné inputenc workaroundy.
;; -lualatex implicitně generuje PDF; -pdf (-pdflatex) se NEpoužívá.
(after! org
(setq org-latex-compiler "lualatex"
org-latex-pdf-process
'("latexmk -f -lualatex -interaction=nonstopmode -output-directory=%o %f")))
;;; ============================================================
;;; 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 0.15
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 ()
"Set up cape completion sources for prog-mode and text-mode."
(add-to-list 'completion-at-point-functions #'cape-dabbrev 0) ; words from buffers
(add-to-list 'completion-at-point-functions #'cape-file 0) ; file paths
(add-to-list 'completion-at-point-functions #'cape-keyword 0) ; language keywords
(add-to-list 'completion-at-point-functions #'cape-elisp-symbol 0))
(add-hook 'prog-mode-hook #'martin/cape-capf-setup)
(add-hook 'text-mode-hook #'martin/cape-capf-setup))
;; 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 . 22)
(:subject))))
(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"
projectile-indexing-method 'alien))
;; 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))
;; 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
;;; ============================================================
;;; 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
;;; ============================================================
;; olivetti NESMÍ být v org-mode-hook — mění vizuální marginy,
;; corfu pak počítá špatné souřadnice popupu (zdánlivě nefunguje).
;; Zapínáme jen manuálně přes SPC t o.
(use-package! olivetti
:defer t
:config
(setq olivetti-body-width 90))
(map! :leader
(:prefix ("t" . "toggle")
:desc "Olivetti mode" "o" #'olivetti-mode))
;;; ============================================================
;;; ORG-MODERN — lepší vizuální styl org-mode
;;; ============================================================
;; POZOR: global-org-modern-mode zapíná org-modern i v export temp bufferech
;; → rozbíjí org-latex export. Aktivujeme pouze v file-backed bufferech.
;; org-modern-table vypnuto — tabulkové overlaye mohou interferovat s exportem.
(use-package! org-modern
:after org
:config
(setq org-modern-star '("" "" "" "")
org-modern-table nil
org-modern-checkbox t)
(add-hook 'org-mode-hook
(lambda ()
(when buffer-file-name
(org-modern-mode 1)))))
;;; ============================================================
;;; ORG-FRAGTOG — auto-render LaTeX fragmentů
;;; ============================================================
;; POZOR: org-fragtog-mode NESMÍ být v export temp bufferech (org-modern stejný problém).
;; Guard: pouze v file-backed bufferech, ne v exportních kopích.
(use-package! org-fragtog
:after org
:config
(add-hook 'org-mode-hook
(lambda ()
(when buffer-file-name
(org-fragtog-mode 1)))))
;;; ============================================================
;;; ORG-SUPER-AGENDA — skupiny v agenda view
;;; ============================================================
(use-package! org-super-agenda
:after org-agenda
:config
;; org-read-date v backtick se vyhodnotí při startu a datum stárne.
;; Používáme statický quoted list s relativním řetězcem "+3d".
(setq org-super-agenda-groups
'((:name "Dnes"
:scheduled today
:deadline today)
(:name "Brzy"
:deadline (before "+3d"))
(:name "Čekám"
:todo "WAIT")
(:name "Projekt Kyndryl"
:tag ("kyndryl" "work"))
(:name "ZTJ"
:tag "ztj")
(:name "Ostatní"
:anything t)))
(org-super-agenda-mode))
;;; ============================================================
;;; ORG-NOTER — PDF anotace
;;; ============================================================
(use-package! org-noter
:after (:any org pdf-view)
:config
(setq org-noter-notes-window-location 'horizontal-split))
(map! :leader
(:prefix ("o" . "open")
:desc "org-noter" "n" #'org-noter
: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)
"Pošli označený region do GPTel s instrukcí 'vylepši text' a nahraď odpovědí."
(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 vylepšen"))
(message "GPTel rewrite failed: %s" (plist-get info :status)))))))
(defun my/gptel-org-heading-prompt ()
"Pošle aktuální org heading + obsah jako kontext do GPTel chatu."
(interactive)
(unless (derived-mode-p 'org-mode)
(user-error "Pouze v 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' odeslán jako kontext" 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")
;; Přidat podporu pro Gitea na 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))
;;; ============================================================
;;; FORGE — Gitea integrace
;;; ============================================================
;; Vyžaduje Gitea API token v ~/.authinfo:
;; machine git.apps.sukany.cz login daneel^forge password <TOKEN>
(after! forge
(add-to-list 'forge-alist
'("git.apps.sukany.cz" "git.apps.sukany.cz/api/v1" "git.apps.sukany.cz" forge-gitea-repository)))