Files
emacs-doom/config.el
2026-02-12 17:57:24 +01:00

501 lines
18 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; -*-
;; --------------------------------------------------
;; Theme / UI
;; --------------------------------------------------
(setq doom-theme 'modus-vivendi-deuteranopia
display-line-numbers-type t
doom-font (font-spec :family "JetBrains Mono" :size 14)
doom-variable-pitch-font nil)
;; --------------------------------------------------
;; macOS / UX
;; --------------------------------------------------
(setq mouse-autoselect-window t
focus-follows-mouse t
select-enable-clipboard t
select-enable-primary t
inhibit-splash-screen t)
;; --------------------------------------------------
;; Completion (Company) minimum, bezpečné
;; --------------------------------------------------
(after! company
(setq company-idle-delay 0.1
company-minimum-prefix-length 2
company-selection-wrap-around t
company-tooltip-limit 14
company-show-numbers t
company-require-match nil)
;; Spolehlivé vyvolání v TTY
(map! :i "C-." #'company-complete
:n "C-." #'company-complete))
;; --------------------------------------------------
;; Robustní file completion "kdekoliv pod kurzorem"
;; --------------------------------------------------
(defun martin/complete-file-name-at-point ()
"Doplň název souboru kolem kurzoru pomocí standardní file completion tabulky.
Funguje v libovolném textu, včetně Markdown linků (např. [x](./...))."
(interactive)
(let* ((stop-chars '(?\s ?\t ?\n ?\r ?\" ?\' ?\( ?\) ?\[ ?\] ?\< ?\> ?\{ ?\} ?, ?\; ))
(start (save-excursion
(while (and (> (point) (point-min))
(let ((c (char-before)))
(and c (not (memq c stop-chars)))))
(backward-char))
(point)))
(end (save-excursion
(while (and (< (point) (point-max))
(let ((c (char-after)))
(and c (not (memq c stop-chars)))))
(forward-char))
(point))))
(when (= start end)
(setq start (point) end (point)))
(let ((completion-category-defaults nil)
(completion-category-overrides '((file (styles basic partial-completion)))))
(completion-in-region start end #'completion-file-name-table))))
(map! :i "C-c f" #'martin/complete-file-name-at-point
:n "C-c f" #'martin/complete-file-name-at-point)
(after! markdown-mode
(add-hook 'markdown-mode-hook (lambda () (setq-local company-minimum-prefix-length 1)))
(add-hook 'gfm-mode-hook (lambda () (setq-local company-minimum-prefix-length 1))))
;; --------------------------------------------------
;; Org
;; --------------------------------------------------
(setq org-directory "~/org/")
(after! org
(setq org-default-notes-file (expand-file-name "inbox.org" org-directory))
(setq org-agenda-files (list org-directory
(expand-file-name "projects" org-directory)
(expand-file-name "notes" 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))))
(defun my/org-agenda-project ()
"Open Org agenda restricted to current project's project.org."
(interactive)
(let ((org-agenda-files (delq nil (list (my/project-org-file)))))
(if org-agenda-files
(org-agenda nil "a")
(user-error "No project.org found in this project"))))
(setq org-capture-templates
'(("i" "Inbox task" entry
(file "inbox.org")
"* TODO %?\n%U\n%a\n")
("n" "Note" entry
(file+headline "inbox.org" "Notes")
"* %?\n%U\n%a\n")
("p" "Project task" entry
(file "inbox.org")
"* TODO %? :project:\n%U\n%a\n"))))
;; --------------------------------------------------
;; Dired
;; --------------------------------------------------
(after! dired
(put 'dired-find-alternate-file 'disabled nil)
(map! :map dired-mode-map
"RET" #'dired-find-alternate-file
"^" #'dired-up-directory))
;; --------------------------------------------------
;; PlantUML (server)
;; --------------------------------------------------
(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)
"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)
"Odstraní vše před PNG signaturou."
(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 nenalezena"))
(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 aktuální .puml přes PlantUML server do PNG nebo SVG."
(interactive (list (completing-read "Type: " '("png" "svg") nil t "png")))
(unless buffer-file-name (user-error "Otevři .puml jako soubor"))
(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 uložen: %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"))))
;; --------------------------------------------------
;; PATH fix for MacTeX
;; --------------------------------------------------
(setenv "PATH" (concat "/Library/TeX/texbin:" (getenv "PATH")))
(add-to-list 'exec-path "/Library/TeX/texbin")
;; --------------------------------------------------
;; Python
;; --------------------------------------------------
(setq python-shell-interpreter "python3")
(after! org
(setq org-babel-python-command "python3")
(require 'ob-python))
;;; GPTel + OpenWebUI (OpenRouter behind it)
(use-package! gptel
:config
;; 1) API key z env (bez klíčů v configu)
(defun my/openwebui-key ()
(or (getenv "OPENWEBUI_API_KEY")
(user-error "Missing OPENWEBUI_API_KEY env var")))
;; 2) Stáhnout seznam modelů z OpenWebUI /api/models
(defun my/openwebui-fetch-model-ids ()
"Return list of model ids from OpenWebUI /api/models (field data[].id)."
(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) ;; přeskočit HTTP hlavičky
(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 ()
"Cached list of model ids. Falls back to a minimal list if API fails."
(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)
;; fallback zkus běžné OpenRouter ids (může, ale nemusí být dostupné)
'("openai/gpt-4o-mini" "openai/gpt-4.1-mini"))))))
(defun my/openwebui-refresh-models ()
"Clear cache and refetch OpenWebUI model list."
(interactive)
(setq my/openwebui-models-cache nil)
(message "OpenWebUI models refreshed: %d" (length (my/openwebui-models))))
;; 3) Backend pro OpenWebUI (OpenAI-compatible chat/completions)
(setq gptel-backend
(gptel-make-openai "OpenWebUI"
:host "ai.apps.sukany.cz"
:protocol "https"
:key #'my/openwebui-key
:endpoint "/api/chat/completions"
:stream t
;; často stabilnější přes reverzní proxy/ingress:
:curl-args '("--http1.1")
:models (my/openwebui-models)))
;; 4) Default model: ekonomický a praktický
;; Preferuj openai/gpt-4o-mini (levný, rychlý), jinak první dostupný model.
;; (GPT-4o mini je běžně uváděn jako “fast, affordable” a na OpenRouter má id openai/gpt-4o-mini) :contentReference[oaicite:2]{index=2}
(let* ((models (my/openwebui-models))
(preferred "openai/gpt-4o-mini"))
(setq gptel-model (if (member preferred models)
preferred
(car models))))
;; 5) Presety (rychlé přepínání podle úlohy) :contentReference[oaicite:3]{index=3}
;; Pozn.: Presety jen nastavují model/backend/system atd. žádná magie navíc.
(gptel-make-preset 'fast
:description "Default (rychlý/levný) běžná práce"
:backend "OpenWebUI"
:model "openai/gpt-4o-mini"
:system "Odpovídej česky. Buď konkrétní, krokový. Neomáčej to."
:temperature 0.2)
(gptel-make-preset 'coding
:description "Kód / refaktor / review (silnější model, když je potřeba)"
:backend "OpenWebUI"
;; nastav si sem model, který opravdu máš v /api/models:
:model "openai/gpt-4.1-mini"
:system "Jsi přísný code reviewer. Navrhuj konkrétní změny a rizika."
:temperature 0.1)
(gptel-make-preset 'deep
:description "Náročná analýza / architektura"
:backend "OpenWebUI"
;; nastav si sem něco silnějšího z tvého seznamu:
:model "openai/gpt-4.1"
:system "Postupuj systematicky. Dej varianty, tradeoffs a doporučení."
:temperature 0.2)
;; 6) (Volitelné) Debug log, když něco zlobí:
;; (setq gptel-log-level 'debug)
)
;; 7) Mini CLI: volání z command line přes `emacs --batch`
;; Použití níže v příkladech
(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 (safe in Doom) SPC o g ...
;; --------------------------------------------------
(after! gptel
(map! :leader
(:prefix ("o g" . "GPTel")
:desc "GPTel send (region or buffer)" "s" #'gptel-send
:desc "GPTel menu (model/scope/preset)" "m" #'gptel-menu
:desc "GPTel chat buffer" "c" #'gptel
:desc "GPTel abort request" "x" #'gptel-abort
:desc "Refresh OpenWebUI models" "R" #'my/openwebui-refresh-models)))
;; performance
(setq which-key-idle-delay 0)
;; auto save
(setq auto-save-default nil) ;; zruší #file# bordel
(defun my/save-all-buffers ()
(save-some-buffers t))
(run-with-idle-timer 5 t #'my/save-all-buffers)
;; centered cursor mode
(use-package! centered-cursor-mode
:config
(setq ccm-vpos-init 0.5) ;; 0.5 = střed okna
(global-centered-cursor-mode +1))
;; Profiling
(setq gc-cons-threshold (* 100 1024 1024) ;; 100 MB
gc-cons-percentage 0.6)
(add-hook 'focus-out-hook #'garbage-collect)
(setq doom-modeline-refresh-rate 1.0) ;; default je 0.10.2
(setq which-key-idle-delay 0.8
which-key-idle-secondary-delay 0.05)
(setq org-idle-time 1.0)
;; 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))
(after! mu4e
(setq mu4e-maildir (expand-file-name "~/.mail")
mu4e-get-mail-command "mbsync personal"
mu4e-update-interval 300
mu4e-change-filenames-when-moving t
mu4e-view-show-images t
;; TADY je to důležité:
mu4e-sent-folder "/personal/Sent"
mu4e-drafts-folder "/personal/Drafts"
mu4e-trash-folder "/personal/Trash"
mu4e-refile-folder "/personal/Archive"))
(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))
(setq user-mail-address "martin@sukany.cz"
user-full-name "Martin Sukany")
(map! :map mu4e-view-mode-map
:localleader
"l l" #'org-store-link
"l i" #'org-insert-link)
(map! :map mu4e-headers-mode-map
:localleader
"l l" #'org-store-link
"l i" #'org-insert-link)
;;; ================================
;;; Emacspeak robust ON/OFF for Doom
;;; Default: OFF (won't auto-restart)
;;; Keys:
;;; SPC t s -> Speech ON
;;; SPC t S -> Speech OFF
;;; ================================
(defconst my/emacspeak-dir (expand-file-name "~/.emacspeak"))
(defconst my/emacspeak-wrapper (expand-file-name "~/.local/bin/emacspeak-mac"))
;; Emacspeak uses this to start the speech server.
(setq dtk-program my/emacspeak-wrapper)
;; State flags
(defvar my/emacspeak-loaded nil)
(defvar my/emacspeak-enabled nil)
;; Hard inhibit: when non-nil, Emacspeak is NOT allowed to start/restart the server.
(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 late-ish (but still inside the command that enables it)
(load-file (expand-file-name "lisp/emacspeak-setup.el" emacspeak-directory))
;; After Emacspeak is present, install inhibition advices.
;; These prevent the common 'it restarts by itself' problem.
(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
;; OFF mode: do nothing (and crucially: don't restart speaker)
nil
(apply orig args)))))))))
(defun my/emacspeak-on ()
"Enable speech (and allow server 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 robustly (stop + prevent auto-restart)."
(interactive)
;; First: inhibit any future attempts to start/restart.
(setq my/emacspeak-enabled nil)
(setq my/emacspeak-inhibit-server t)
;; Stop current speech if any.
(when (fboundp 'dtk-stop)
(ignore-errors (dtk-stop)))
;; Kill the server process hard (if it exists).
(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)"))
;; Doom leader keys
(map! :leader
(:prefix ("t" . "toggle")
:desc "Speech ON" "s" #'my/emacspeak-on
:desc "Speech OFF" "S" #'my/emacspeak-off))
(setq dtk-speech-rate 300)
;; emacspeak additional settings
;;(setq emacspeak-speak-messages nil)
;;(setq emacspeak-audio-indentation nil)
(setq emacspeak-speak-echo nil)
(setq emacspeak-character-echo nil)
;;(setq emacspeak-word-echo nil)
(setq emacspeak-line-echo t)
;;(setq emacspeak-speak-messages nil)
(global-set-key (kbd "C-c SPC") #'emacspeak-speak-buffer)
(add-hook 'transient-post-exit-hook #'ignore)
(advice-add 'transient--show :after
(lambda (&rest _)
(dtk-speak (buffer-string))))