1278 lines
48 KiB
EmacsLisp
1278 lines
48 KiB
EmacsLisp
;;; $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: 
|
||
;;
|
||
;; 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 "" 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)
|
||
|
||
;; Ensure latexmk doesn't hang on errors (nonstopmode + force-continue)
|
||
;; Doom latex module usually sets this, but belt+suspenders for macOS.
|
||
(after! org
|
||
(setq org-latex-pdf-process
|
||
'("latexmk -f -pdf -%latex -interaction=nonstopmode -output-directory=%o %f")))
|
||
|
||
|
||
;;; ============================================================
|
||
;;; 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
|
||
;;; ============================================================
|
||
|
||
;; Guard: buffer-file-name zajisti ze olivetti nebezi v export temp bufferech
|
||
;; (ty nemaji prirazeny soubor a volani set-window-margins by selhalo).
|
||
;; Corfu popup: pokud se zobrazi mimo obrazovku, vypni olivetti (SPC t o).
|
||
(use-package! olivetti
|
||
:defer t
|
||
:config
|
||
(setq olivetti-body-width 90))
|
||
|
||
(add-hook 'org-mode-hook (lambda () (when buffer-file-name (olivetti-mode 1))))
|
||
(add-hook 'markdown-mode-hook (lambda () (when buffer-file-name (olivetti-mode 1))))
|
||
(add-hook 'text-mode-hook (lambda () (when buffer-file-name (olivetti-mode 1))))
|
||
|
||
(map! :leader
|
||
(:prefix ("t" . "toggle")
|
||
:desc "Olivetti mode" "o" #'olivetti-mode))
|
||
|
||
|
||
;;; ============================================================
|
||
;;; ORG-MODERN — lepší vizuální styl org-mode
|
||
;;; ============================================================
|
||
|
||
;; :after org zajisti spravny load-order. global-org-modern-mode aktivuje
|
||
;; org-modern ve vsech org bufferech (vcetne uz otevrenych).
|
||
;; org-modern-table nil -- tabulkove overlaye mohou kolidovat s LaTeX exportem.
|
||
(use-package! org-modern
|
||
:after org
|
||
:config
|
||
(setq org-modern-star '("◉" "○" "✸" "✿")
|
||
org-modern-table nil
|
||
org-modern-checkbox t)
|
||
(global-org-modern-mode))
|
||
|
||
|
||
;;; ============================================================
|
||
;;; ORG-FRAGTOG — auto-render LaTeX fragmentů
|
||
;;; ============================================================
|
||
|
||
;; Guard: pouze v file-backed bufferech, ne v exportních kopích.
|
||
(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 — skupiny v agenda view
|
||
;;; ============================================================
|
||
|
||
;; Pozn.: :deadline (before DATE) vyzaduje absolutni datum nebo evaluaci —
|
||
;; v plain quoted listu nefunguje spolehlive. Skupina "Brzy" odebrana.
|
||
;; org-super-agenda-mode zapnut az po loadu pres after! (jistejsi nez v :config).
|
||
(use-package! org-super-agenda
|
||
:after org-agenda
|
||
:config
|
||
(setq org-super-agenda-groups
|
||
'((:name "Dnes"
|
||
:scheduled today
|
||
:deadline today)
|
||
(:name "Brzy"
|
||
:deadline (before "+3d"))
|
||
(:name "Cekam"
|
||
:todo "WAIT")
|
||
(:name "Kyndryl"
|
||
:tag ("kyndryl" "work"))
|
||
(:name "ZTJ"
|
||
:tag "ztj")
|
||
(:name "Ostatni"
|
||
:anything t))))
|
||
|
||
(after! org-super-agenda
|
||
(org-super-agenda-mode 1))
|
||
|
||
|
||
;;; ============================================================
|
||
;;; 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)))
|