diff options
| author | Craig Jennings <c@cjennings.net> | 2025-10-12 11:47:26 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-10-12 11:47:26 -0500 |
| commit | 092304d9e0ccc37cc0ddaa9b136457e56a1cac20 (patch) | |
| tree | ea81999b8442246c978b364dd90e8c752af50db5 /modules | |
changing repositories
Diffstat (limited to 'modules')
82 files changed, 13879 insertions, 0 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el new file mode 100644 index 00000000..ef574412 --- /dev/null +++ b/modules/ai-config.el @@ -0,0 +1,419 @@ +;;; ai-config.el --- Configuration for AI Integrations -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Configuration for AI integrations in Emacs, focused on GPTel. +;; +;; Main Features: +;; - Quick toggle for AI assistant window (F9 or M-a t) +;; - Custom keymap (M-a prefix, overrides 'backwards-sentence') for AI-related commands. +;; - Enhanced org-mode conversation formatting with timestamps +;; allows switching models and easily compare and track responses. +;; - Various specialized AI directives (coder, reviewer, etc.) +;; - Context management for adding files/buffers to conversations +;; - Conversation persistence with save/load functionality +;; - Integration with Magit for code review +;; +;; Basic Workflow +;; +;; Using a side-chat window: +;; - Launch GPTel via F9 or M-a t, and chat in the AI-Assistant side window (C-<return> to send) +;; - Change system prompt (expertise, personalities) with M-a p +;; - Add context from files (M-a f) or buffers (M-a b) +;; - Save conversations with M-a s, load previous ones with M-a l +;; - Clear the conversation and start over with M-a x +;; Or in any buffer: +;; - Add directive as above, and select a region to rewrite with M-a r. +;; + +;;; Code: + +(autoload 'cj/gptel-save-conversation "ai-conversations" "Save the AI conversation to a file." t) +(autoload 'cj/gptel-load-conversation "ai-conversations" "Load a saved AI conversation." t) +(autoload 'cj/gptel-delete-conversation "ai-conversations" "Delete a saved AI conversation." t) + +(with-eval-after-load 'gptel + (require 'ai-conversations)) + +;;; ------------------------- AI Config Helper Functions ------------------------ + +;; Define all our variables upfront +(defvar cj/anthropic-api-key-cached nil "Cached Anthropic API key.") +(defvar cj/openai-api-key-cached nil "Cached OpenAI API key.") +(defvar gptel-claude-backend nil "Claude backend, lazy-initialized.") +(defvar gptel-chatgpt-backend nil "ChatGPT backend, lazy-initialized.") + + +(defun cj/auth-source-secret (host user) + "Fetch a secret from auth-source for HOST and USER. + +HOST and USER must be strings that identify the credential to return." + (let* ((found (auth-source-search :host host :user user :require '(:secret) :max 1)) + (secret (plist-get (car found) :secret))) + (cond + ((functionp secret) (funcall secret)) + ((stringp secret) secret) + (t (error "No usable secret found for host %s and user %s" host user))))) + +(defun cj/anthropic-api-key () + "Return the Anthropic API key, caching the result after first retrieval." + (or cj/anthropic-api-key-cached + (setq cj/anthropic-api-key-cached + (cj/auth-source-secret "api.anthropic.com" "apikey")))) + +(defun cj/openai-api-key () + "Return the OpenAI API key, caching the result after first retrieval." + (or cj/openai-api-key-cached + (setq cj/openai-api-key-cached + (cj/auth-source-secret "api.openai.com" "apikey")))) + +(defun cj/ensure-gptel-backends () + "Initialize GPTel backends if they are not already available. + +Call this only after loading 'gptel' so the backend constructors exist." + (unless gptel-claude-backend + (setq gptel-claude-backend + (gptel-make-anthropic + "Claude" + :key (cj/anthropic-api-key) + :models '( + "claude-opus-4-1-20250805" + "claude-3-5-sonnet-20241022" + "claude-3-opus-20240229" + "claude-3-5-haiku-20241022" + ) + :stream t))) + (unless gptel-chatgpt-backend + (setq gptel-chatgpt-backend + (gptel-make-openai + "ChatGPT" + :key (cj/openai-api-key) + :models '( + "gpt-4o" + "gpt-5" + "gpt-4.1" + "o1" + ) + :stream t))) + ;; Set default backend + (unless gptel-backend + (setq gptel-backend (or gptel-chatgpt-backend gptel-claude-backend)))) + +(autoload 'cj/toggle-gptel "ai-config" "Toggle the AI-Assistant window" t) + +;; ------------------ Gptel Conversation And Utility Commands ------------------ + +(defun cj/gptel--available-backends () + "Return an alist of (NAME . BACKEND), ensuring gptel and backends are initialized." + (unless (featurep 'gptel) + (require 'gptel)) + (cj/ensure-gptel-backends) + (delq nil + (list (and (bound-and-true-p gptel-claude-backend) + (cons "Anthropic - Claude" gptel-claude-backend)) + (and (bound-and-true-p gptel-chatgpt-backend) + (cons "OpenAI - ChatGPT" gptel-chatgpt-backend))))) + +(defun cj/gptel--model->string (m) + (cond + ((stringp m) m) + ((symbolp m) (symbol-name m)) + (t (format "%s" m)))) + +;; Backend/model switching commands (moved out of use-package so they are commandp) +(defun cj/gptel-change-model () + "Change the GPTel backend and select a model from that backend. + +Present all available models from every backend, switching backends when +necessary. Prompt for whether to apply the selection globally or buffer-locally." + (interactive) + (let* ((backends (cj/gptel--available-backends)) + (all-models + (mapcan + (lambda (pair) + (let* ((backend-name (car pair)) + (backend (cdr pair)) + (models (when (fboundp 'gptel-backend-models) + (gptel-backend-models backend)))) + (mapcar (lambda (m) + (list (format "%s: %s" backend-name (cj/gptel--model->string m)) + backend + (cj/gptel--model->string m) + backend-name)) + models))) + backends)) + (current-backend-name (car (rassoc (bound-and-true-p gptel-backend) backends))) + (current-selection (format "%s: %s" + (or current-backend-name "AI") + (cj/gptel--model->string (bound-and-true-p gptel-model)))) + (scope (completing-read "Set model for: " '("buffer" "global") nil t)) + (selected (completing-read + (format "Select model (current: %s): " current-selection) + (mapcar #'car all-models) nil t nil nil current-selection))) + (let* ((model-info (assoc selected all-models)) + (backend (nth 1 model-info)) + (model (intern (nth 2 model-info))) ;; Convert string to symbol + (backend-name (nth 3 model-info))) + (if (string= scope "global") + (progn + (setq gptel-backend backend) + (setq gptel-model model) + (message "Changed to %s model: %s (global)" backend-name model)) + (setq-local gptel-backend backend) + (setq-local gptel-model (if (stringp model) (intern model) model)) + (message "Changed to %s model: %s (buffer-local)" backend-name model))))) + +(defun cj/gptel-switch-backend () + "Switch the GPTel backend and then choose one of its models." + (interactive) + (let* ((backends (cj/gptel--available-backends)) + (choice (completing-read "Select GPTel backend: " (mapcar #'car backends) nil t)) + (backend (cdr (assoc choice backends)))) + (unless backend + (user-error "Invalid GPTel backend: %s" choice)) + (let* ((models (when (fboundp 'gptel-backend-models) + (gptel-backend-models backend))) + (model (completing-read (format "Select %s model: " choice) + (mapcar #'cj/gptel--model->string models) + nil t nil nil (cj/gptel--model->string (bound-and-true-p gptel-model))))) + (setq gptel-backend backend + gptel-model model) + (message "Switched to %s with model: %s" choice model)))) + +;; Clear assistant buffer (moved out so it's always available) +(defun cj/gptel-clear-buffer () + "Erase the current GPTel buffer while preserving the initial Org heading. + +Operate only when `gptel-mode' is active in an Org buffer so the heading +can be reinserted." + (interactive) + (let ((is-gptel (bound-and-true-p gptel-mode)) + (is-org (derived-mode-p 'org-mode))) + (if (and is-gptel is-org) + (progn + (erase-buffer) + (when (fboundp 'cj/gptel--fresh-org-prefix) + (insert (cj/gptel--fresh-org-prefix))) + (message "GPTel buffer cleared and heading reset")) + (message "Not a GPTel buffer in org-mode. Nothing cleared.")))) + +;; ----------------------------- Context Management ---------------------------- + +(defun cj/gptel--add-file-to-context (file-path) + "Add FILE-PATH to the GPTel context. + +Returns t on success, nil on failure. +Provides consistent user feedback about the context state." + (when (and file-path (file-exists-p file-path)) + (gptel-add-file file-path) + (let ((context-count (if (boundp 'gptel-context--alist) + (length gptel-context--alist) + 0))) + (message "Added %s to GPTel context (%d sources total)" + (file-name-nondirectory file-path) + context-count)) + t)) + +(defun cj/gptel-add-file () + "Add a file to the GPTel context. + +If inside a Projectile project, prompt from that project's file list. +Otherwise, prompt with `read-file-name'." + (interactive) + (let* ((in-proj (and (featurep 'projectile) + (fboundp 'projectile-project-p) + (projectile-project-p))) + (file-name (if in-proj + (let ((cands (projectile-current-project-files))) + (if (fboundp 'projectile-completing-read) + (projectile-completing-read "GPTel add file: " cands) + (completing-read "GPTel add file: " cands nil t))) + (read-file-name "GPTel add file: "))) + (file-path (if in-proj + (expand-file-name file-name (projectile-project-root)) + file-name))) + (unless (cj/gptel--add-file-to-context file-path) + (error "Failed to add file: %s" file-path)))) + +(defun cj/gptel-add-buffer-file () + "Select a buffer and add its associated file to the GPTel context. + +Lists all open buffers for selection. If the selected buffer is visiting +a file, that file is added to the GPTel context. Otherwise, an error +message is displayed." + (interactive) + (let* ((buffers (mapcar #'buffer-name (buffer-list))) + (selected-buffer-name (completing-read "Add file from buffer: " buffers nil t)) + (selected-buffer (get-buffer selected-buffer-name)) + (file-path (and selected-buffer + (buffer-file-name selected-buffer)))) + (if file-path + (cj/gptel--add-file-to-context file-path) + (message "Buffer '%s' is not visiting a file" selected-buffer-name)))) + +(defun cj/gptel-add-this-buffer () + "Add the current buffer to the GPTel context. + +Works for any buffer, whether it's visiting a file or not." + (interactive) + ;; Load gptel-context if needed + (unless (featurep 'gptel-context) + (require 'gptel-context)) + ;; Use gptel-add with prefix arg '(4) to add current buffer + (gptel-add '(4)) + (message "Added buffer '%s' to GPTel context" (buffer-name))) + +;;; ---------------------------- GPTel Configuration ---------------------------- + +(use-package gptel + :defer t + :commands (gptel gptel-send gptel-menu) + :bind + (("<f9>" . cj/toggle-gptel) + :map gptel-mode-map + ("C-<return>" . gptel-send)) + :custom + (gptel-default-mode 'org-mode) + (gptel-expert-commands t) + (gptel-track-media t) + ;; TODO: add reasoning to a buffer. See docstring. + (gptel-include-reasoning 'ignore) + (gptel-log-level 'info) + (gptel--debug nil) + :config + (cj/ensure-gptel-backends) + ;; Set ChatGPT as default after initialization + (setq gptel-backend gptel-chatgpt-backend) + + ;; Named backend list for switching + (defvar cj/gptel-backends + `(("Anthropic - Claude" . ,gptel-claude-backend) + ("OpenAI - ChatGPT" . ,gptel-chatgpt-backend)) + "Alist of GPTel backends for interactive switching.") + + (setq gptel-confirm-tool-calls nil) ;; allow tool access by default + ;;; ---------------------------- Backend Management --------------------------- + + (setq gptel-backend gptel-chatgpt-backend) ;; use ChatGPT as default + ;; (setq gptel-backend gptel-claude-backend) ;; use Claude as default + +;;; -------------------------- Org Header Construction -------------------------- + + ;; Dynamic user prefix for org-mode heading (string, refreshed just before send) + (defun cj/gptel--fresh-org-prefix () + "Generate a fresh org-mode header with current timestamp for user messages." + (concat "* " user-login-name " " (format-time-string "[%Y-%m-%d %H:%M:%S]") "\n")) + + ;; Initialize as a string (GPTel expectation) + (setf (alist-get 'org-mode gptel-prompt-prefix-alist) + (cj/gptel--fresh-org-prefix)) + + ;; Refresh immediately before each send for accurate timestamp + (defun cj/gptel--refresh-org-prefix (&rest _) + "Update the org-mode prefix with fresh timestamp before sending message." + (setf (alist-get 'org-mode gptel-prompt-prefix-alist) + (cj/gptel--fresh-org-prefix))) + (advice-add 'gptel-send :before #'cj/gptel--refresh-org-prefix) + + ;; AI header on each reply: (e.g. "*** AI: <model> [timestamp]") + (defun cj/gptel-backend-and-model () + "Return backend, model, and timestamp as a single string." + (let* ((backend (pcase (bound-and-true-p gptel-backend) + ((and v (pred vectorp)) (aref v 1)) ;; display name if vector + (_ "AI"))) + (model (format "%s" (or (bound-and-true-p gptel-model) ""))) + (ts (format-time-string "[%Y-%m-%d %H:%M:%S]"))) + (format "%s: %s %s" backend model ts))) + + (defun cj/gptel-insert-model-heading (response-begin-pos _response-end-pos) + "Insert an Org heading for the AI reply at RESPONSE-BEGIN-POS." + (save-excursion + (goto-char response-begin-pos) + (insert (format "* %s\n" (cj/gptel-backend-and-model))))) + + (add-hook 'gptel-post-response-functions #'cj/gptel-insert-model-heading)) + +;;; ---------------------------- Toggle GPTel Window ---------------------------- + +(defun cj/toggle-gptel () + "Toggle the visibility of the AI-Assistant buffer, and place point at its end." + (interactive) + (let* ((buf-name "*AI-Assistant*") + (buffer (get-buffer buf-name)) + (win (and buffer (get-buffer-window buffer)))) + (if win + (delete-window win) + ;; Ensure GPTel and our backends are initialized before creating the buffer + (unless (featurep 'gptel) + (require 'gptel)) + (cj/ensure-gptel-backends) + (unless buffer + ;; Pass backend, not model + (gptel buf-name gptel-backend)) + (setq buffer (get-buffer buf-name)) + (setq win + (display-buffer-in-side-window + buffer + '((side . right) + (window-width . 0.4)))) + (select-window win) + (with-current-buffer buffer + (goto-char (point-max)))))) + +;; ------------------------------- Clear Context ------------------------------- + +(defun cj/gptel-context-clear () + "Clear all GPTel context sources, with compatibility across GPTel versions." + (interactive) + (cond + ((fboundp 'gptel-context-remove-all) + (call-interactively 'gptel-context-remove-all) + (message "GPTel context cleared")) + ((fboundp 'gptel-context-clear) + (call-interactively 'gptel-context-clear) + (message "GPTel context cleared")) + ((boundp 'gptel-context--alist) + (setq gptel-context--alist nil) + (message "GPTel context cleared")) + (t + (message "No known GPTel context clearing function available")))) + +;;; -------------------------------- GPTel-Magit -------------------------------- + +(use-package gptel-magit + :defer t + :hook (magit-mode . gptel-magit-install)) + +;; ------------------------------ GPTel Directives ----------------------------- + +(use-package gptel-prompts + :load-path (lambda () (expand-file-name "custom/" user-emacs-directory)) + :after gptel + :if (file-exists-p (expand-file-name "custom/gptel-prompts.el" user-emacs-directory)) + :custom + (gptel-prompts-directory (concat user-emacs-directory "ai-prompts")) + :config + (gptel-prompts-update) + (gptel-prompts-add-update-watchers)) + +;;; --------------------------------- AI Keymap --------------------------------- + +(define-prefix-command 'cj/ai-keymap nil + "Keymap for AI operations.") +(define-key cj/custom-keymap "a" 'cj/ai-keymap) +(define-key cj/ai-keymap "B" #'cj/gptel-switch-backend) ;; change the backend (OpenAI, Anthropic, etc.) +(define-key cj/ai-keymap "M" #'gptel-menu) ;; gptel's transient menu +(define-key cj/ai-keymap "d" #'cj/gptel-delete-conversation) ;; delete conversation +(define-key cj/ai-keymap "." #'cj/gptel-add-this-buffer) ;; add buffer to context +(define-key cj/ai-keymap "f" #'cj/gptel-add-file) ;; add a file to context +(define-key cj/ai-keymap "l" #'cj/gptel-load-conversation) ;; load and continue conversation +(define-key cj/ai-keymap "m" #'cj/gptel-change-model) ;; change the LLM model +(define-key cj/ai-keymap "p" #'gptel-system-prompt) ;; change prompt +(define-key cj/ai-keymap "&" #'gptel-rewrite) ;; rewrite a region of code/text +(define-key cj/ai-keymap "r" #'cj/gptel-context-clear) ;; remove all context +(define-key cj/ai-keymap "s" #'cj/gptel-save-conversation) ;; save conversation +(define-key cj/ai-keymap "t" #'cj/toggle-gptel) ;; toggles the ai-assistant window +(define-key cj/ai-keymap "x" #'cj/gptel-clear-buffer) ;; clears the assistant buffer + +(provide 'ai-config) +;;; ai-config.el ends here. diff --git a/modules/ai-conversations.el b/modules/ai-conversations.el new file mode 100644 index 00000000..92549176 --- /dev/null +++ b/modules/ai-conversations.el @@ -0,0 +1,277 @@ +;;; ai-conversations.el --- GPTel conversation persistence and autosave -*- lexical-binding: t; coding: utf-8; -*- +;; Author: Craig Jennings <c@cjennings.net> +;; Maintainer: Craig Jennings <c@cjennings.net> +;; Version 0.1 +;; Package-Requires: ((emacs "27.1")) +;; Keywords: convenience, tools +;; +;;; Commentary: +;; Provides conversation save/load/delete, autosave after responses, and +;; org-visibility headers for GPTel-powered assistant buffers. +;; +;; Loads lazily via autoloads for the interactive entry points. + +;;; Code: + +(defgroup cj/ai-conversations nil + "Conversation persistence for GPTel (save/load/delete, autosave)." + :group 'gptel + :prefix "cj/") + +(defcustom cj/gptel-conversations-directory + (expand-file-name "ai-conversations" user-emacs-directory) + "Directory where GPTel conversations are stored." + :type 'directory + :group 'cj/ai-conversations) + +(defcustom cj/gptel-conversations-window-side 'right + "Side to display the AI-Assistant buffer when loading a conversation." + :type '(choice (const :tag "Right" right) + (const :tag "Left" left) + (const :tag "Bottom" bottom) + (const :tag "Top" top)) + :group 'cj/ai-conversations) + +(defcustom cj/gptel-conversations-window-width 0.4 + "Set the side window width when loading a conversation. + +If displaying on the top or bottom, treat this value as a height fraction." + :type 'number + :group 'cj/ai-conversations) + +(defcustom cj/gptel-conversations-sort-order 'newest-first + "Sort order for conversation selection prompts." + :type '(choice (const :tag "Newest first" newest-first) + (const :tag "Oldest first" oldest-first)) + :group 'cj/ai-conversations) + +(defvar-local cj/gptel-autosave-enabled nil + "Non-nil means auto-save after each AI response in GPTel buffers.") + +(defvar-local cj/gptel-autosave-filepath nil + "File path used for auto-saving the conversation buffer.") + +(defcustom cj/gptel-conversations-autosave-on-send t + "Non-nil means auto-save the conversation immediately after `gptel-send'." + :type 'boolean + :group 'cj/ai-conversations) + +(defun cj/gptel--autosave-after-send (&rest _args) + "Auto-save current GPTel buffer right after `gptel-send' if enabled." + (when (and cj/gptel-conversations-autosave-on-send + (bound-and-true-p gptel-mode) + cj/gptel-autosave-enabled + (stringp cj/gptel-autosave-filepath) + (> (length cj/gptel-autosave-filepath) 0)) + (condition-case err + (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath) + (error (message "cj/gptel autosave-on-send failed: %s" (error-message-string err)))))) + +(with-eval-after-load 'gptel + (unless (advice-member-p #'cj/gptel--autosave-after-send #'gptel-send) + (advice-add 'gptel-send :after #'cj/gptel--autosave-after-send))) + +(defun cj/gptel--slugify-topic (s) + "Return a filesystem-friendly slug for topic string S." + (let* ((down (downcase (or s ""))) + (repl (replace-regexp-in-string "[^a-z0-9]+" "-" down)) + (trim (replace-regexp-in-string "^-+\\|-+$" "" repl))) + (or (and (> (length trim) 0) trim) "conversation"))) + +(defun cj/gptel--existing-topics () + "Return topic slugs, without timestamps, present in the conversations directory." + (when (file-exists-p cj/gptel-conversations-directory) + (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$"))) + (delete-dups + (mapcar + (lambda (f) + (replace-regexp-in-string "_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$" "" f)) + files))))) + +(defun cj/gptel--latest-file-for-topic (topic-slug) + "Return the newest saved conversation filename for TOPIC-SLUG, or nil." + (let* ((rx (format "^%s_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$" + (regexp-quote topic-slug))) + (files (and (file-exists-p cj/gptel-conversations-directory) + (directory-files cj/gptel-conversations-directory nil rx)))) + (car (sort files #'string>)))) + +(defun cj/gptel--timestamp-from-filename (filename) + "Return an Emacs timestamp extracted from FILENAME, or nil. + +Expect FILENAME to match _YYYYMMDD-HHMMSS.gptel." + (when (string-match "_\\([0-9]\\{8\\}\\)-\\([0-9]\\{6\\}\\)\\.gptel\\'" filename) + (let* ((date (match-string 1 filename)) + (time (match-string 2 filename)) + (Y (string-to-number (substring date 0 4))) + (M (string-to-number (substring date 4 6))) + (D (string-to-number (substring date 6 8))) + (h (string-to-number (substring time 0 2))) + (m (string-to-number (substring time 2 4))) + (s (string-to-number (substring time 4 6)))) + (encode-time s m h D M Y)))) + +(defun cj/gptel--conversation-candidates () + "Return conversation candidates sorted per `cj/gptel-conversations-sort-order'." + (unless (file-exists-p cj/gptel-conversations-directory) + (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory)) + (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$")) + (enriched + (mapcar + (lambda (f) + (let* ((full (expand-file-name f cj/gptel-conversations-directory)) + (ptime (or (cj/gptel--timestamp-from-filename f) + (nth 5 (file-attributes full)))) + (disp (format "%s [%s]" f (format-time-string "%Y-%m-%d %H:%M" ptime)))) + (list :file f :time ptime :display disp))) + files)) + (sorted + (sort enriched + (lambda (a b) + (let ((ta (plist-get a :time)) + (tb (plist-get b :time))) + (if (eq cj/gptel-conversations-sort-order 'newest-first) + (time-less-p tb ta) ;; tb earlier than ta => a first + (time-less-p ta tb)))))) + (cands (mapcar (lambda (pl) + (cons (plist-get pl :display) + (plist-get pl :file))) + sorted))) + cands)) + +(defun cj/gptel--save-buffer-to-file (buffer filepath) + "Save BUFFER content to FILEPATH with Org visibility properties." + (with-current-buffer buffer + (let ((content (buffer-string))) + (with-temp-buffer + (insert "#+STARTUP: showeverything\n") + (insert "#+VISIBILITY: all\n\n") + (insert content) + (write-region (point-min) (point-max) filepath nil 'silent)))) + filepath) + +(defun cj/gptel--ensure-ai-buffer () + "Return the *AI-Assistant* buffer, creating it via `gptel' if needed." + (let* ((buf-name "*AI-Assistant*") + (buffer (get-buffer buf-name))) + (unless buffer + (gptel buf-name)) + (or (get-buffer buf-name) + (user-error "Could not create or find *AI-Assistant* buffer")))) + +;;;###autoload +(defun cj/gptel-save-conversation () + "Save the current AI-Assistant buffer to a .gptel file. + +Enable autosave for subsequent AI responses to the same file." + (interactive) + (let ((buf (get-buffer "*AI-Assistant*"))) + (unless buf + (user-error "No AI-Assistant buffer found")) + (unless (file-exists-p cj/gptel-conversations-directory) + (make-directory cj/gptel-conversations-directory t) + (message "Created directory: %s" cj/gptel-conversations-directory)) + (let* ((topics (or (cj/gptel--existing-topics) '())) + (input (completing-read "Conversation topic: " topics nil nil)) + (topic-slug (cj/gptel--slugify-topic input)) + (latest (cj/gptel--latest-file-for-topic topic-slug)) + (use-existing (and latest + (y-or-n-p (format "Update existing file %s? " latest)))) + (filepath (if use-existing + (expand-file-name latest cj/gptel-conversations-directory) + (let* ((timestamp (format-time-string "%Y%m%d-%H%M%S")) + (filename (format "%s_%s.gptel" topic-slug timestamp))) + (expand-file-name filename cj/gptel-conversations-directory))))) + (cj/gptel--save-buffer-to-file buf filepath) + (with-current-buffer buf + (setq-local cj/gptel-autosave-filepath filepath) + (setq-local cj/gptel-autosave-enabled t)) + (message "Conversation saved to: %s" filepath)))) + +;;;###autoload +(defun cj/gptel-delete-conversation () + "Delete a saved GPTel conversation file (chronologically sorted candidates)." + (interactive) + (unless (file-exists-p cj/gptel-conversations-directory) + (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory)) + (let* ((cands (cj/gptel--conversation-candidates))) + (unless cands + (user-error "No saved conversations found in %s" cj/gptel-conversations-directory)) + (let* ((completion-extra-properties '(:display-sort-function identity + :cycle-sort-function identity)) + (selection (completing-read "Delete conversation: " cands nil t)) + (filename (cdr (assoc selection cands))) + (filepath (and filename + (expand-file-name filename cj/gptel-conversations-directory)))) + (unless filename + (user-error "No conversation selected")) + (when (y-or-n-p (format "Really delete %s? " filename)) + (delete-file filepath) + (message "Deleted conversation: %s" filename))))) + +(defun cj/gptel--strip-visibility-headers () + "Strip org visibility headers at the top of the current buffer if present." + (save-excursion + (goto-char (point-min)) + (while (looking-at "^#\\+\\(STARTUP\\|VISIBILITY\\):.*\n") + (delete-region (match-beginning 0) (match-end 0))) + (when (looking-at "^\n+") + (delete-region (point) (match-end 0))))) + +;;;###autoload +(defun cj/gptel-load-conversation () + "Load a saved GPTel conversation into the AI-Assistant buffer. + +Prompt to save the current conversation first when appropriate, then enable autosave." + (interactive) + (let ((ai-buffer (get-buffer-create "*AI-Assistant*"))) + (when (and (with-current-buffer ai-buffer (> (buffer-size) 0)) + (with-current-buffer ai-buffer (bound-and-true-p gptel-mode))) + (when (y-or-n-p "Save current conversation before loading new one? ") + (with-current-buffer ai-buffer + (call-interactively #'cj/gptel-save-conversation))))) + (unless (file-exists-p cj/gptel-conversations-directory) + (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory)) + (let* ((cands (cj/gptel--conversation-candidates))) + (unless cands + (user-error "No saved conversations found in %s" cj/gptel-conversations-directory)) + (let* ((completion-extra-properties '(:display-sort-function identity + :cycle-sort-function identity)) + (selection (completing-read "Load conversation: " cands nil t)) + (filename (cdr (assoc selection cands))) + (filepath (and filename + (expand-file-name filename cj/gptel-conversations-directory)))) + (unless filename + (user-error "No conversation selected")) + (with-current-buffer (cj/gptel--ensure-ai-buffer) + (erase-buffer) + (insert-file-contents filepath) + (cj/gptel--strip-visibility-headers) + (goto-char (point-max)) + (set-buffer-modified-p t) + (setq-local cj/gptel-autosave-filepath filepath) + (setq-local cj/gptel-autosave-enabled t)) + (let ((buf (get-buffer "*AI-Assistant*"))) + (unless (get-buffer-window buf) + (display-buffer-in-side-window + buf `((side . ,cj/gptel-conversations-window-side) + (window-width . ,cj/gptel-conversations-window-width))))) + (select-window (get-buffer-window "*AI-Assistant*")) + (message "Loaded conversation from: %s" filepath)))) + +(defun cj/gptel--autosave-after-response (&rest _args) + "Auto-save the current GPTel buffer when enabled." + (when (and (bound-and-true-p gptel-mode) + cj/gptel-autosave-enabled + (stringp cj/gptel-autosave-filepath) + (> (length cj/gptel-autosave-filepath) 0)) + (condition-case err + (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath) + (error (message "cj/gptel autosave failed: %s" (error-message-string err)))))) + +(with-eval-after-load 'gptel + (unless (member #'cj/gptel--autosave-after-response gptel-post-response-functions) + (add-hook 'gptel-post-response-functions #'cj/gptel--autosave-after-response))) + +(provide 'ai-conversations) +;;; ai-conversations.el ends here diff --git a/modules/auth-config.el b/modules/auth-config.el new file mode 100644 index 00000000..a42bd52a --- /dev/null +++ b/modules/auth-config.el @@ -0,0 +1,45 @@ +;; auth-config.el --- Configuration for Authentication Utilities -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; Configuration for Emacs authentication and GPG integration: + +;; • auth-source +;; – Forces use of your default authinfo file +;; – Disable external GPG agent in favor of Emacs’s own prompt +;; – Enable auth-source debug messages + +;; • Easy PG Assistant (epa) +;; – Force using the ‘gpg2’ executable for encryption/decryption operations + +;;; Code: + +(require 'user-constants) ;; defines authinfo-file location + +;; -------------------------------- Auth Sources ------------------------------- +;; auth sources settings + +(use-package auth-source + :ensure nil ;; built in + :demand t ;; load this package immediately + :config + (setenv "GPG_AGENT_INFO" nil) ;; disassociate with external gpg agent + (setq auth-sources `(,authinfo-file)) ;; use authinfo.gpg (see user-constants.el) + (setq auth-source-debug t)) ;; echo debug info to Messages + +;; ----------------------------- Easy PG Assistant ----------------------------- +;; Key management, cryptographic operations on regions and files, dired +;; integration, and automatic encryption/decryption of *.gpg files. + +(use-package epa + :ensure nil ;; built-in + :demand t + :config + (epa-file-enable) + ;; (setq epa-pinentry-mode 'loopback) ;; emacs request passwords in minibuffer + (setq epg-gpg-program "gpg2")) ;; force use gpg2 (not gpg v.1) + + +(provide 'auth-config) +;;; auth-config.el ends here. diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el new file mode 100644 index 00000000..bd82c588 --- /dev/null +++ b/modules/calibredb-epub-config.el @@ -0,0 +1,223 @@ +;;; calibredb-epub-config --- Functionality for Ebook Management and Display -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; This module provides a comprehensive ebook management and reading experience +;; within Emacs, integrating CalibreDB for library management and Nov for EPUB +;; reading. +;; +;; FEATURES: +;; - CalibreDB integration for managing your Calibre ebook library +;; - Nov mode for reading EPUB files with customized typography and layout +;; - Seamless navigation between Nov reading buffers and CalibreDB entries +;; - Image centering in EPUB documents without modifying buffer text +;; - Quick filtering and searching within your ebook library +;; +;; KEY BINDINGS: +;; - M-B: Open CalibreDB library browser +;; - In CalibreDB search mode: +;; - l: Filter by tag +;; - L: Clear all filters +;; - In Nov mode: +;; - z: Open current EPUB in external viewer (zathura) +;; - C-c C-b: Jump to CalibreDB entry for current book +;; - m: Set bookmark +;; - b: List bookmarks +;; +;; WORKFLOW: +;; 1. Press M-B to browse your Calibre library +;; 2. Use filters (l for tags, L to clear) to narrow results +;; 3. Open an EPUB to read it in Nov with optimized typography +;; 4. While reading, use C-c C-b to jump back to the book's metadata +;; 5. Use z to open in external reader when needed +;; +;; CONFIGURATION NOTES: +;; - Prefers EPUB format when available, falls back to PDF +;; - Centers images in EPUB documents using display properties +;; - Applies custom typography with larger fonts for comfortable reading +;; - Uses visual-fill-column for centered text with appropriate margins + +;;; Code: + +(require 'user-constants) ;; for books-dir + +;; -------------------------- CalibreDB Ebook Manager -------------------------- + +(use-package calibredb + :defer 1 + :commands calibredb + :bind + ("M-B" . calibredb) + ;; use built-in filter by tag, add clear-filters + (:map calibredb-search-mode-map + ("l" . calibredb-filter-by-tag) + ("L" . cj/calibredb-clear-filters)) + :config + ;; basic config + (setq calibredb-root-dir books-dir) + (setq calibredb-db-dir (expand-file-name "metadata.db" calibredb-root-dir)) + (setq calibredb-program "/usr/bin/calibredb") + (setq calibredb-preferred-format "epub") + (setq calibredb-search-page-max-rows 20000) + + ;; search window display + (setq calibredb-size-show nil) + (setq calibredb-order "asc") + (setq calibredb-id-width 7)) + +(defun cj/calibredb-clear-filters () + "Clear active filters and show all results." + (interactive) + (setq calibredb-tag-filter-p nil + calibredb-favorite-filter-p nil + calibredb-author-filter-p nil + calibredb-date-filter-p nil + calibredb-format-filter-p nil + calibredb-search-current-page 1) + ;; empty string resets keyword filter and refreshes listing + (calibredb-search-keyword-filter "")) + +;; ------------------------------ Nov Epub Reader ------------------------------ + +(use-package nov + :defer .5 + :after visual-fill-column + :mode ("\\.epub\\'" . nov-mode) + :hook (nov-mode . cj/nov-apply-preferences) + :bind + (:map nov-mode-map + ("m" . bookmark-set) + ("b" . bookmark-bmenu-list) + ("r" . nov-render-document) + ("l" . recenter-top-bottom) + ("d" . sdcv-search-input) + ("." . cj/forward-paragraph-and-center) + ("<" . nov-history-back) + (">" . nov-history-forward) + ("," . backward-paragraph) + ;; open current EPUB with zathura (same key in pdf-view) + ("z" . (lambda () (interactive) (cj/open-file-with-command "zathura"))) + ("t" . nov-goto-toc) + ("C-c C-b" . cj/nov-jump-to-calibredb))) + +(defun cj/forward-paragraph-and-center () + "Forward one paragraph and center the page." + (interactive) + (forward-paragraph) + (recenter)) + +(defun cj/nov-apply-preferences () + "Apply preferences after nov-mode has launched." + (interactive) + (face-remap-add-relative 'variable-pitch :height 180) + (face-remap-add-relative 'fixed-pitch :height 180) + ;; Make this buffer-local so other Nov buffers can choose differently + (setq-local nov-text-width 115) + (when (require 'visual-fill-column nil t) + (setq-local visual-fill-column-center-text t + ;; small cushion above nov-text-width prevents truncation + visual-fill-column-width (+ nov-text-width 10)) + (hl-line-mode) + (visual-fill-column-mode 1)) + (nov-render-document)) + +(defun cj/nov-center-images () + "Center images in the current Nov buffer without modifying text. + +Use line-prefix and wrap-prefix with a space display property aligned to a +computed column based on the window text area width." + (let ((inhibit-read-only t)) + ;; Clear any prior centering prefixes first (fresh render usually makes this + ;; unnecessary, but it makes the function idempotent). + (remove-text-properties (point-min) (point-max) + '(line-prefix nil wrap-prefix nil)) + (save-excursion + (goto-char (point-min)) + ;; Work in the selected window showing this buffer (if any). + (when-let* ((win (get-buffer-window (current-buffer) t)) + (col-width (window-body-width win)) ;; columns + (col-px (* col-width (window-font-width win)))) + (while (let ((m (text-property-search-forward + 'display nil + (lambda (_ p) (and (consp p) (eq (car-safe p) 'image)))))) + (when m + (let* ((img (prop-match-value m)) + (img-px (car (image-size img t))) ;; pixel width + ;; Convert pixel image width to columns for alignment. + (img-cols (max 1 (ceiling (/ (float img-px) + (max 1 (window-font-width win)))))) + (pad-cols (max 0 (/ (- col-width img-cols) 2))) + (prefix (propertize " " 'display `(space :align-to ,pad-cols)))) + (save-excursion + (goto-char (prop-match-beginning m)) + (beginning-of-line) + (let ((bol (point)) + (eol (line-end-position))) + (add-text-properties bol eol + `(line-prefix ,prefix + wrap-prefix ,prefix))))) + t))))))) + +(add-hook 'nov-post-html-render-hook #'cj/nov-center-images) + +;; Jump from a Nov buffer to the corresponding CalibreDB entry. +(defun cj/nov--metadata-get (key) + "Return a metadata value from nov-metadata trying KEY as symbol and string." + (let* ((v (or (and (boundp 'nov-metadata) + (or (alist-get key nov-metadata nil nil #'equal) + (alist-get (if (symbolp key) (symbol-name key) key) + nov-metadata nil nil #'equal))) + nil))) + (cond + ((and (listp v) (= (length v) 1)) (car v)) + ((stringp v) v) + (t v)))) + +(defun cj/nov--file-path () + "Return the current EPUB file path when in nov-mode, or nil." + (when (derived-mode-p 'nov-mode) + ;; In nov, the buffer visits the .epub; buffer-file-name is usually the EPUB. + (or buffer-file-name + (and (boundp 'nov-epub-filename) nov-epub-filename) + (and (boundp 'nov-epub-file) nov-epub-file)))) + +(defun cj/nov-jump-to-calibredb () + "Open CalibreDB focused on the current EPUB's book entry. + +Try to use the Calibre book id from the parent folder name (for example, +\"Title (123)\"). Fall back to a title or author search when no id exists." + (interactive) + (require 'calibredb) + (let* ((file (cj/nov--file-path)) + (title (or (cj/nov--metadata-get 'title) + (cj/nov--metadata-get "title"))) + (author (or (cj/nov--metadata-get 'creator) + (cj/nov--metadata-get 'author) + (cj/nov--metadata-get "creator") + (cj/nov--metadata-get "author"))) + (id (when file + (let* ((parent (file-name-nondirectory + (directory-file-name (file-name-directory file))))) + (when (string-match " (\\([0-9]+\\))\\'" parent) + (match-string 1 parent)))))) + (calibredb) + (with-current-buffer (calibredb-find-create-search-buffer) + (setq calibredb-search-current-page 1) + (cond + (id + (calibredb-search-keyword-filter (format "id:%s" id)) + (message "CalibreDB: focused by id:%s" id)) + ((or title author) + (let* ((q (string-join + (delq nil (list (and title (format "title:\"%s\"" title)) + (and author (format "authors:\"%s\"" author)))) + " and "))) + (calibredb-search-keyword-filter q) + (message "CalibreDB: search %s" (if (string-empty-p q) "<all>" q)))) + (t + (calibredb-search-keyword-filter "") + (message "CalibreDB: no metadata; showing all")))))) + +(provide 'calibredb-epub-config) +;;; calibredb-epub-config.el ends here diff --git a/modules/chrono-tools.el b/modules/chrono-tools.el new file mode 100644 index 00000000..f6c4c0f6 --- /dev/null +++ b/modules/chrono-tools.el @@ -0,0 +1,118 @@ +;;; chrono-tools.el --- Config for Date and Time-Related Utils -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; +;; This module centralizes configuration for Emacs time-related tools: +;; +;; – world-clock: predefined city list and custom time format +;; – calendar: quick navigation keybindings by day, month, and year +;; – tmr: lightweight timer setup with sounds, notifications, and history +;; +;;; Code: + +(require 'user-constants) + +(use-package time + :ensure nil ;; built-in + :defer 0.5 + :bind ("C-x c" . world-clock) + :config + (setq world-clock-list + '(("Pacific/Honolulu" " Honolulu") + ("America/Los_Angeles" " San Francisco, LA") + ("America/Chicago" " Chicago, New Orleans") + ("America/New_York" " New York, Boston") + ("Etc/UTC" " UTC =================") + ("Europe/London" " London, Lisbon") + ("Europe/Paris" " Paris, Berlin, Rome") + ("Europe/Athens" " Athens, Istanbul, Moscow") + ("Asia/Kolkata" " India") + ("Asia/Shanghai" " Shanghai, Singapore") + ("Asia/Tokyo" " Tokyo, Seoul"))) + (setq world-clock-time-format " %a, %d %b @ %I:%M %p %Z")) + +(use-package calendar + :ensure nil ;; built-in + :defer 0.5 + :bind (("M-#" . calendar) + :map calendar-mode-map + ("," . calendar-backward-day) + ("." . calendar-forward-day) + ("<" . calendar-backward-month) + (">" . calendar-forward-month) + ("M-," . calendar-backward-year) + ("M-." . calendar-forward-year))) + + +;; ------------------------------------ TMR ------------------------------------ + +(defun cj/tmr-select-sound-file () + "Select a sound file from `sounds-dir' to use for tmr timers. + +Present all audio files in the sounds directory and set the chosen file as +`tmr-sound-file'. Use \\[universal-argument] to reset to the default sound." + (interactive) + (if current-prefix-arg + ;; With prefix arg, reset to default + (progn + (setq tmr-sound-file notification-sound) + (message "Timer sound reset to default: %s" + (file-name-nondirectory notification-sound))) + ;; Otherwise, select a new sound + (let* ((audio-extensions '("mp3" "m4a" "ogg" "opus" "wav" "flac" "aac")) + (extension-regex (concat "\\." (regexp-opt audio-extensions t) "$")) + (sound-files (when (file-directory-p sounds-dir) + (directory-files sounds-dir nil extension-regex))) + (current-file (when (and tmr-sound-file (file-exists-p tmr-sound-file)) + (file-name-nondirectory tmr-sound-file))) + (selected-file (when sound-files + (completing-read + (format "Select timer sound%s: " + (if current-file + (format " (current: %s)" current-file) + "")) + sound-files + nil + t + nil + nil + current-file)))) ; Default to current file + (cond + ((not (file-directory-p sounds-dir)) + (message "Sounds directory does not exist: %s" sounds-dir)) + ((null sound-files) + (message "No audio files found in %s" sounds-dir)) + (selected-file + (setq tmr-sound-file (expand-file-name selected-file sounds-dir)) + (when (equal tmr-sound-file notification-sound) + (message "Timer sound set to default: %s" selected-file)) + (unless (equal tmr-sound-file notification-sound) + (message "Timer sound set to: %s" selected-file))) + (t + (message "No file selected")))))) + +(defun cj/tmr-reset-sound-to-default () + "Reset the tmr sound file to the default notification sound." + (interactive) + (setq tmr-sound-file notification-sound) + (message "Timer sound reset to default: %s" + (file-name-nondirectory notification-package))) + +(use-package tmr + :defer 0.5 + :init + (global-unset-key (kbd "M-t")) + :bind (("M-t" . tmr-prefix-map) + :map tmr-prefix-map + ("*" . tmr) + ("t" . tmr-with-details) + ("S" . cj/tmr-select-sound-file) + ("R" . cj/tmr-reset-sound-to-default)) + :config + (setq tmr-sound-file notification-sound) + (setq tmr-notification-urgency 'normal) + (setq tmr-descriptions-list 'tmr-description-history)) + +(provide 'chrono-tools) +;;; chrono-tools.el ends here diff --git a/modules/config-utilities.el b/modules/config-utilities.el new file mode 100644 index 00000000..beb44bf7 --- /dev/null +++ b/modules/config-utilities.el @@ -0,0 +1,291 @@ +;;; config-utilities --- Config Hacking Utilities -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; Convenience utilities for working on Emacs configuration. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +;; ------------------------------ Reload Init File ----------------------------- +;; it does what it says it does. + +(defun cj/reload-init-file () + "Reload the init file. Useful when modifying Emacs config." + (interactive) + (load-file user-init-file)) + +;; ---------------------------- Recompile Emacs Home --------------------------- +;; deletes all .elc and .eln files in user-emacs-directory, then compiles +;; all emacs-lisp files natively if supported, or byte-compiles them if not. + +(defun cj/recompile-emacs-home() + "Delete all compiled files in the Emacs home before recompiling. + +Recompile natively when supported, otherwise fall back to byte compilation." + (interactive) + (let* ((native-comp-supported (boundp 'native-compile-async)) + (elt-dir + (expand-file-name (if native-comp-supported "eln" "elc") + user-emacs-directory)) + (message-format + (format "Please confirm recursive %s recompilation of %%s: " + (if native-comp-supported "native" "byte"))) + (compile-message (format "%scompiling all emacs-lisp files in %%s" + (if native-comp-supported "Natively " "Byte-")))) + (if (yes-or-no-p (format message-format user-emacs-directory)) + (progn + (message "Deleting all compiled files in %s" user-emacs-directory) + (dolist (file (directory-files-recursively user-emacs-directory + "\\(\\.elc\\|\\.eln\\)$")) + (delete-file file)) + (when (file-directory-p elt-dir) + (delete-directory elt-dir t t)) + (message compile-message user-emacs-directory) + (if native-comp-supported + (let ((comp-async-report-warnings-errors nil)) + (native-compile-async user-emacs-directory 'recursively)) + (byte-recompile-directory user-emacs-directory 0))) + (message "Cancelled recompilation of %s" user-emacs-directory)))) + +;; ---------------------- Delete Emacs Home Compiled Files --------------------- +;; removes all compiled files and deletes the eln directory + +(defun cj/delete-emacs-home-compiled-files () + "Delete all compiled files recursively in \='user-emacs-directory\='." + (interactive) + (message "Deleting compiled files under %s. This may take a while." + user-emacs-directory) + (require 'find-lisp) ;; make sure the package is required + (mapc (lambda (path) + (when (or (string-suffix-p ".elc" path) + (string-suffix-p ".eln" path)) + (delete-file path))) + (find-lisp-find-files user-emacs-directory "")) + (message "Done. Compiled files removed under %s" user-emacs-directory)) + +;; ---------------------- List Loaded Packages --------------------- +;; you don't really need an explanation for this function, do you? + +(defvar cj--loaded-file-paths nil + "All file paths that are loaded.") +(defvar cj--loaded-packages-buffer "*loaded-packages*" + "Buffer name for data about loaded packages.") +(defvar cj--loaded-features-buffer "*loaded-features*" + "Buffer name for data about loaded features.") + +(defun cj/list-loaded-packages() + "List all currently loaded packages." + (interactive) + (with-current-buffer (get-buffer-create cj--loaded-packages-buffer) + (erase-buffer) + (pop-to-buffer (current-buffer)) + + (insert "* Live Packages Exploration\n\n") + (insert (format "%s total packages currently loaded\n" + (length cj--loaded-file-paths))) + + ;; Extract data from builtin variable `load-history'. + (setq cj--loaded-file-paths + (seq-filter #'stringp + (mapcar #'car load-history))) + (cl-sort cj--loaded-file-paths 'string-lessp) + (cl-loop for file in cj--loaded-file-paths + do (insert "\n" file)) + + (goto-char (point-min)))) + +;; ---------------------------- List Loaded Features --------------------------- +;; this function's also self-explanatory + +(defun cj/list-loaded-features() + "List all currently loaded features." + (interactive) + (with-current-buffer (get-buffer-create cj--loaded-features-buffer) + (erase-buffer) + (pop-to-buffer (current-buffer)) + + (insert (format "\n** %d features currently loaded\n" + (length features))) + + (let ((features-vec (apply 'vector features))) + (cl-sort features-vec 'string-lessp) + (cl-loop for x across features-vec + do (insert (format " - %-25s: %s\n" x + (locate-library (symbol-name x)))))) + (goto-char (point-min)))) + +;; ------------------------ Validate Org Agenda Entries ------------------------ + +(defun cj/check-org-agenda-invalid-timestamps () + "Scan all files in `org-agenda-files' for invalid timestamps. + +Checks DEADLINE, SCHEDULED, TIMESTAMP properties and inline timestamps in headline contents. + +Generates an Org-mode report buffer with links to problematic entries, property/type, and raw timestamp string." + (interactive) + (require 'org-element) + (let ((report-buffer (get-buffer-create "*Org Invalid Timestamps Report*"))) + (with-current-buffer report-buffer + (erase-buffer) + (org-mode) + (insert "#+TITLE: Org Invalid Timestamps Report\n\n") + (insert "* Overview\nScan of org-agenda-files for invalid timestamps.\n\n")) + (dolist (file org-agenda-files) + (with-current-buffer (find-file-noselect file) + (let ((invalid-entries '()) + (props '("DEADLINE" "SCHEDULED" "TIMESTAMP")) + (parse-tree (org-element-parse-buffer 'headline))) + (org-element-map parse-tree 'headline + (lambda (hl) + (let ((headline-text (org-element-property :raw-value hl)) + (begin-pos (org-element-property :begin hl))) + (dolist (prop props) + (let ((timestamp (org-element-property (intern (downcase prop)) hl))) + (when timestamp + (let ((time-str (org-element-property :raw-value timestamp))) + (unless (ignore-errors (org-time-string-to-absolute time-str)) + (push (list file begin-pos headline-text prop time-str) invalid-entries)))))) + (let ((contents-begin (org-element-property :contents-begin hl)) + (contents-end (org-element-property :contents-end hl))) + (when (and contents-begin contents-end) + (save-excursion + (goto-char contents-begin) + (while (re-search-forward org-ts-regexp contents-end t) + (let ((ts-string (match-string 0))) + (unless (ignore-errors (org-time-string-to-absolute ts-string)) + (push (list file begin-pos headline-text "inline timestamp" ts-string) invalid-entries)))))))))) + + (with-current-buffer report-buffer + (insert (format "* %s\n" file)) + (if invalid-entries + (dolist (entry (reverse invalid-entries)) + (cl-destructuring-bind (f pos head prop ts) entry + (insert (format "- [[file:%s::%d][%s]]\n - Property/Type: %s\n - Invalid timestamp: \"%s\"\n" + f pos head prop ts)))) + (insert "No invalid timestamps found.\n"))) + (with-current-buffer report-buffer (insert "\n"))))) + (pop-to-buffer report-buffer))) + +;; ----------------------------- Reset-Auth-Sources ---------------------------- + +(defun cj/reset-auth-cache () + "Clear Emacs auth-source cache." + (interactive) + (auth-source-forget-all-cached) + (message "Emacs auth-source cache cleared.")) + +;; --------------------------- Org-Alert-Check Timers -------------------------- +;; Utility to list timers running org-alert-check + +(defun cj/org-alert-list-timers () + "List all active timers running `org-alert-check' with next run time in human-readable form." + (interactive) + (let ((timers (cl-remove-if-not + (lambda (timer) + (eq (timer--function timer) #'org-alert-check)) + timer-list))) + (if timers + (let ((lines + (mapcar + (lambda (timer) + (let* ((next-run (timer--time timer)) + (next-run-str (format-time-string "%Y-%m-%d %H:%M:%S" next-run))) + (format "Timer next runs at: %s" next-run-str))) + timers))) + (message "org-alert-check timers:\n%s" (string-join lines "\n"))) + (message "No org-alert-check timers found.")))) + +;; ------------------------------- Sqlite Tracing ------------------------------ + + +(defvar cj/sqlite-tracing-enabled nil) +(defvar cj/sqlite--db-origins (make-hash-table :test 'eq :weakness 'key)) + +(defun cj/capture-backtrace () + (condition-case nil + (if (fboundp 'backtrace-frames) + (mapcar (lambda (fr) (car fr)) (backtrace-frames)) + (list "no-backtrace-frames")) + (error (list "failed-to-capture-backtrace")))) + +(defun cj/take (n xs) + (cl-subseq xs 0 (min n (length xs)))) + +(defun cj--ad-sqlite-open (orig file &rest opts) + (let ((db (apply orig file opts))) + (puthash db + (list :file file + :opts opts + :where (or load-file-name buffer-file-name) + :time (current-time-string) + :stack (cj/capture-backtrace)) + cj/sqlite--db-origins) + db)) + +(defun cj--ad-sqlite-close (orig db &rest args) + (let ((info (gethash db cj/sqlite--db-origins))) + (when info + (message "cj/sqlite: closing %s opened at %s by %s" + (plist-get info :file) + (plist-get info :time) + (or (plist-get info :where) "unknown")))) + (apply orig db args)) + +(defun cj--ad-set-finalizer (orig obj fn) + (let* ((origin (list :time (current-time-string) + :where (or load-file-name buffer-file-name) + :stack (cj/capture-backtrace) + :sqlite-open (when (and (fboundp 'sqlitep) + (ignore-errors (sqlitep obj))) + (gethash obj cj/sqlite--db-origins)))) + (wrapped + (lambda (&rest args) + (condition-case err + (apply fn args) + (error + (let* ((stack (cj/take 8 (plist-get origin :stack))) + (dbi (plist-get origin :sqlite-open)) + (extra (if dbi + (format " db=%s opened at %s by %s" + (plist-get dbi :file) + (plist-get dbi :time) + (or (plist-get dbi :where) "unknown")) + ""))) + (message "cj/finalizer: failed; created at %s (%s); callers=%S;%s; error=%S" + (plist-get origin :time) + (or (plist-get origin :where) "unknown") + stack extra err)) + ;; Re-signal so Emacs still shows the standard finalizer message. + (signal (car err) (cdr err))))))) + (funcall orig obj wrapped))) + +(defun cj/sqlite-tracing-enable () + "Enable tracing of sqlite opens/closes and annotate failing finalizers." + (interactive) + (unless cj/sqlite-tracing-enabled + (setq cj/sqlite-tracing-enabled t) + (advice-add 'set-finalizer :around #'cj--ad-set-finalizer) + (when (fboundp 'sqlite-open) + (advice-add 'sqlite-open :around #'cj--ad-sqlite-open) + (advice-add 'sqlite-close :around #'cj--ad-sqlite-close)) + (message "cj/sqlite tracing enabled"))) + +(defun cj/sqlite-tracing-disable () + "Disable sqlite/finalizer tracing and clear recorded origins." + (interactive) + (setq cj/sqlite-tracing-enabled nil) + (ignore-errors (advice-remove 'set-finalizer #'cj--ad-set-finalizer)) + (when (fboundp 'sqlite-open) + (ignore-errors (advice-remove 'sqlite-open #'cj--ad-sqlite-open)) + (ignore-errors (advice-remove 'sqlite-close #'cj--ad-sqlite-close))) + (clrhash cj/sqlite--db-origins) + (message "cj/sqlite tracing disabled")) + +(cj/sqlite-tracing-enable) +(setq debug-on-message (rx bos "finalizer failed")) + +(provide 'config-utilities) +;;; config-utilities.el ends here diff --git a/modules/custom-functions.el b/modules/custom-functions.el new file mode 100644 index 00000000..5899eec7 --- /dev/null +++ b/modules/custom-functions.el @@ -0,0 +1,1012 @@ +;;; custom-functions.el --- My Custom Functions and Keymaps -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; These are custom utility functions I use frequently. +;; For convenience, they are bound to a custom keymap with a prefix of "C-;". + +;; Additional keymaps are created on top of this prefix to collect similar operations. +;; +;; C-; --- Custom Key Map +;; C-; ) → jump to matching parenthesis +;; C-; f → re-formats region or buffer (delete trailing whitespace, reindent, and untabify). +;; C-; W → counts words in region or buffer displaying results in echo area. +;; C-; / → replace common glyph fractions (½) to text (1/2) (text to glyph with C-u). +;; C-; A → align text by regexp with spaces +;; C-; | → toggle visibility of the fill-column indicator +;; +;; C-; b --- Buffer & File Operations +;; C-; b m → move buffer and file to another directory +;; C-; b r → rename buffer and its file simultaneously +;; C-; b d → delete buffer and its file simultaneously +;; C-; b l → copy file:// link of buffer’s source file +;; C-; b c → copy entire buffer to the kill rung +;; C-; b b → clear contents of buffer from point to beginnning +;; C-; b e → clear contents of buffer from point to end +;; +;; C-; w --- Whitespace Operations +;; C-; w r → remove leading/trailing whitespace from line or region (buffer with C-u). +;; C-; w c → collapses runs of whitespace to one space. +;; C-; w l → delete all blank lines in region or buffer +;; C-; w h → hyphenate all whitespace in region +;; +;; C-; s --- Surround, Append & Prepend +;; C-; s s → surround word or region with string +;; C-; s a → append a string to each line +;; C-; s p → prepend a string to each line +;; +;; C-; d --- Date/Time Insertion +;; C-; d r → readable date and time : Sunday, August 31, 2025 at 04:07:02 PM CDT +;; C-; d s → sortable date and time : 2025-08-31 Sun @ 16:07:30 -0500 +;; C-; d t → sortable time only : 04:07:50 PM CDT +;; C-; d D → readable time only : 4:08 PM +;; C-; d T → readable date only : Sunday, August 31, 2025 +;; C-; d d → sortable date only : 2025-08-31 Sun +;; +;; C-; l --- Line & Paragraph Operations +;; C-; l j → join lines (or selected region of lines) +;; C-; l J → join entire paragraph. guesses at the lines that constitute paragraph. +;; C-; l d → duplicates the line or region +;; C-; l r → remove duplicate lines from the buffer, keeping the first occurrence. +;; C-; l R → remove lines containing specific text from the region or buffer. +;; C-; l u → "underline" current line: repeat a chosen character to same length on line below. + +;; +;; C-; m --- Comment Styling & Removal +;; C-; m r → reformats selecton into a commented paragraph re-wrapping at fill column width. +;; C-; m c → insert centered comment +;; C-; m - → insert hyphen-style comment +;; C-; m b → draw a comment box +;; C-; m D → delete all comments in buffer +;; +;; C-; o --- Ordering & Sorting +;; C-; o a → arrayify lines into quoted list +;; C-; o u → unarrayify list into lines +;; C-; o A → alphabetize items in region +;; C-; o l → split comma-separated text onto lines +;; +;; C-; c --- Case-Change Operations +;; C-; c t → Change selected text to Title Case : This is the Title of a Movie +;; C-; c u → Change word or region to Upper Case : THIS IS THE TITLE OF A MOVIE +;; C-; c d → Change word or region to Lower Case : this is the title of a movie + +;;; Code: + +(require 'subr-x) + +(use-package expand-region + :demand t) ;; used w/in join paragraph + +;;; ----------------- Miscellaneous Functions And Custom Keymap ----------------- + +(defun cj/jump-to-matching-paren () + "Jump to the matching parenthesis when point is on one. + +Signal a message when point is not on a parenthesis." + (interactive) + (cond ((looking-at "\\s\(\\|\\s\{\\|\\s\[") + (forward-list)) + ((looking-back "\\s\)\\|\\s\}\\|\\s\\]") + (backward-list)) + (t (message "Cursor doesn't follow parenthesis, so there's no match.")))) + +(defun cj/format-region-or-buffer () + "Reformat the region or the entire buffer. + +Replaces tabs with spaces, deletes trailing whitespace, and reindents the region." + (interactive) + (let ((start-pos (if (use-region-p) (region-beginning) (point-min))) + (end-pos (if (use-region-p) (region-end) (point-max)))) + (save-excursion + (save-restriction + (narrow-to-region start-pos end-pos) + (untabify (point-min) (point-max))) + (indent-region (point-min) (point-max)) + (delete-trailing-whitespace)))) + +(defun cj/count-words-buffer-or-region () + "Count the number of words in the buffer or region. + +Display the result in the minibuffer and *Messages* buffer." + (interactive) + (let ((begin (point-min)) + (end (point-max)) + (area_type "the buffer")) + (when mark-active + (setq begin (region-beginning) + end (region-end) + area_type "the region")) + (message (format "There are %d words in %s." (count-words begin end) area_type)))) + +(defun cj/replace-fraction-glyphs (start end) + "Replace common fraction glyphs between START and END. + +Operate on the buffer or region designated by START and END. +Replace the text representations with glyphs when called with a \[universal-argument] prefix." + (interactive (if (use-region-p) + (list (region-beginning) (region-end)) + (list (point-min) (point-max)))) + (let ((replacements (if current-prefix-arg + '(("1/4" . "¼") + ("1/2" . "½") + ("3/4" . "¾") + ("1/3" . "⅓") + ("2/3" . "⅔")) + '(("¼" . "1/4") + ("½" . "1/2") + ("¾" . "3/4") + ("⅓" . "1/3") + ("⅔" . "2/3"))))) + (save-excursion + (dolist (r replacements) + (goto-char start) + (while (search-forward (car r) end t) + (replace-match (cdr r))))))) + +(defun cj/align-regexp-with-spaces (orig-fun &rest args) + "Call ORIG-FUN with ARGS while temporarily disabling tabs for alignment. + +This advice ensures `align-regexp' uses spaces by binding `indent-tabs-mode' to nil." + (let ((indent-tabs-mode nil)) + (apply orig-fun args))) + +(advice-remove 'align-regexp #'align-regexp-with-spaces) ; in case this is reloaded +(advice-add 'align-regexp :around #'cj/align-regexp-with-spaces) + +;; Must unbind Flyspell's 'C-;' keybinding before it's assigned to cj/custom-keymap +(global-unset-key (kbd "C-;")) +(eval-after-load "flyspell" + '(define-key flyspell-mode-map (kbd "C-;") nil)) + +(defvar cj/custom-keymap + (let ((map (make-sparse-keymap))) + (define-key map ")" 'cj/jump-to-matching-paren) + (define-key map "f" 'cj/format-region-or-buffer) + (define-key map "W" 'cj/count-words-buffer-or-region) + (define-key map "/" 'cj/replace-fraction-glyphs) + (define-key map "A" 'align-regexp) + (define-key map "B" 'toggle-debug-on-error) + (define-key map "|" 'display-fill-column-indicator-mode) + + ;; load debug helpers only on this keybinding + map) + "The base key map for custom elisp functions holding miscellaneous functions. +Other key maps extend from this key map to hold categorized functions.") +(global-set-key (kbd "C-;") cj/custom-keymap) + +;;; ------------------- Buffer And File Operations And Keymap ------------------- + +(defun cj/move-buffer-and-file (dir) + "Move both current buffer and the file it visits to DIR." + (interactive "DMove buffer and file (to new directory): ") + (let* ((name (buffer-name)) + (filename (buffer-file-name)) + (dir + (if (string-match dir "\\(?:/\\|\\\\)$") + (substring dir 0 -1) dir)) + (newname (concat dir "/" name))) + (if (not filename) + (message "Buffer '%s' is not visiting a file!" name) + (progn (copy-file filename newname 1) (delete-file filename) + (set-visited-file-name newname) (set-buffer-modified-p nil) t)))) + +(defun cj/rename-buffer-and-file (new-name) + "Rename both current buffer and the file it visits to NEW-NAME." + (interactive + (list (if (not (buffer-file-name)) + (user-error "Buffer '%s' is not visiting a file!" (buffer-name)) + (read-string "Rename buffer and file (to new name): " + (file-name-nondirectory (buffer-file-name)))))) + (let ((name (buffer-name)) + (filename (buffer-file-name))) + (if (get-buffer new-name) + (message "A buffer named '%s' already exists!" new-name) + (progn + (rename-file filename new-name 1) + (rename-buffer new-name) + (set-visited-file-name new-name) + (set-buffer-modified-p nil))))) + +(defun cj/delete-buffer-and-file () + "Kill the current buffer and delete the file it visits." + (interactive) + (let ((filename (buffer-file-name))) + (when filename + (if (vc-backend filename) + (vc-delete-file filename) + (progn + (delete-file filename t) + (message "Deleted file %s" filename) + (kill-buffer)))))) + +(defun cj/copy-link-to-buffer-file () + "Copy the full file:// path of the current buffer's source file to the kill ring." + (interactive) + (let ((file-path (buffer-file-name))) + (when file-path + (setq file-path (concat "file://" file-path)) + (kill-new file-path) + (message "Copied file link to kill ring: %s" file-path)))) + +(defun cj/copy-path-to-buffer-file-as-kill () + "Copy the full path of the current buffer's file to the kill ring. +Signal an error if the buffer is not visiting a file." + (interactive) + (let ((path (buffer-file-name))) + (if (not path) + (user-error "Current buffer is not visiting a file") + (kill-new path) + (message "Copied file path: %s" path) + path))) + +(defun cj/copy-whole-buffer () + "Copy the entire contents of the current buffer to the kill ring. + +Point and mark are left exactly where they were. No transient region +is created. A message is displayed when done." + (interactive) + (let ((contents (buffer-substring-no-properties (point-min) (point-max)))) + (kill-new contents) + (message "Buffer contents copied to kill ring"))) + +(defun cj/clear-to-bottom-of-buffer () + "Delete all text from point to the end of the current buffer. + +This does not save the deleted text in the kill ring." + (interactive) + (delete-region (point) (point-max)) + (message "Buffer contents removed to the end of the buffer.")) + +(defun cj/clear-to-top-of-buffer () + "Delete all text from point to the beginning of the current buffer. + +Do not save the deleted text in the kill ring." + (interactive) + (delete-region (point) (point-min)) + (message "Buffer contents removed to the beginning of the buffer.")) + +;; prints using postscript for much nicer output +(use-package ps-print + :ensure nil ;; built-in + :config + (defun cj/print-buffer-ps () + "Print the current buffer as PostScript (monochrome) to the system default printer. +Sends directly to the spooler (no temp files), with no page header." + (interactive) + (let* ((spooler + (cond + ((executable-find "lpr") "lpr") + ((executable-find "lp") "lp") + (t (user-error "Cannot print: neither 'lpr' nor 'lp' found in PATH")))) + ;; Configure spooler for this invocation + (ps-lpr-command spooler) + (ps-printer-name nil) ;; nil => system default printer + (ps-lpr-switches nil) + ;; Force monochrome and ignore face backgrounds for this job + (ps-print-color-p nil) + (ps-use-face-background nil) + ;; Ensure no headers + (ps-print-header nil) + (ps-header-lines 0) + (ps-left-header nil) + (ps-right-header nil)) + (ps-print-buffer-with-faces) + (message "Sent print job via %s to default printer (no header)" spooler)))) + +;; Buffer & file operations prefix and keymap +(define-prefix-command 'cj/buffer-and-file-map nil + "Keymap for buffer-and-file operations.") +(define-key cj/custom-keymap "b" 'cj/buffer-and-file-map) +(define-key cj/buffer-and-file-map "m" 'cj/move-buffer-and-file) +(define-key cj/buffer-and-file-map "r" 'cj/rename-buffer-and-file) +(define-key cj/buffer-and-file-map "p" 'cj/print-buffer-ps) +(define-key cj/buffer-and-file-map "d" 'cj/delete-buffer-and-file) +(define-key cj/buffer-and-file-map "c" 'cj/copy-whole-buffer) +(define-key cj/buffer-and-file-map "t" 'cj/clear-to-top-of-buffer) +(define-key cj/buffer-and-file-map "b" 'cj/clear-to-bottom-of-buffer) +(define-key cj/buffer-and-file-map "x" 'erase-buffer) +(define-key cj/buffer-and-file-map "s" 'write-file) ;; save as :) + +(define-key cj/buffer-and-file-map "l" 'cj/copy-link-to-buffer-file) +(define-key cj/buffer-and-file-map "P" 'cj/copy-path-to-buffer-file-as-kill) + +;;; ---------------------- Whitespace Operations And Keymap --------------------- + +(defun cj/remove-leading-trailing-whitespace () + "Remove leading and trailing whitespace in a region, line, or buffer. + +When called interactively: +- If a region is active, operate on the region. +- If called with a \[universal-argument] prefix, operate on the entire buffer. +- Otherwise, operate on the current line." + (interactive) + (let ((start (cond (current-prefix-arg (point-min)) + ((use-region-p) (region-beginning)) + (t (line-beginning-position)))) + (end (cond (current-prefix-arg (point-max)) + ((use-region-p) (region-end)) + (t (line-end-position))))) + (save-excursion + (save-restriction + (narrow-to-region start end) + (goto-char (point-min)) + (while (re-search-forward "^[ \t]+" nil t) (replace-match "")) + (goto-char (point-min)) + (while (re-search-forward "[ \t]+$" nil t) (replace-match "")))))) + +(defun cj/collapse-whitespace-line-or-region () + "Collapse whitespace to one space in the current line or active region. + +Ensure there is exactly one space between words and remove leading and trailing whitespace." + (interactive) + (save-excursion + (let* ((region-active (use-region-p)) + (beg (if region-active (region-beginning) (line-beginning-position))) + (end (if region-active (region-end) (line-end-position)))) + (save-restriction + (narrow-to-region beg end) + ;; Replace all tabs with space + (goto-char (point-min)) + (while (search-forward "\t" nil t) + (replace-match " " nil t)) + ;; Remove leading and trailing spaces + (goto-char (point-min)) + (while (re-search-forward "^\\s-+\\|\\s-+$" nil t) + (replace-match "" nil nil)) + ;; Ensure only one space between words/symbols + (goto-char (point-min)) + (while (re-search-forward "\\s-\\{2,\\}" nil t) + (replace-match " " nil nil)))))) + +(defun cj/delete-blank-lines-region-or-buffer (start end) + "Delete blank lines between START and END. + +Treat blank lines as lines that contain nothing or only whitespace. +Operate on the active region when one exists. +Prompt before operating on the whole buffer when no region is selected. +Signal a user error and do nothing when the user declines. +Restore point to its original position after deletion." + (interactive + (if (use-region-p) + ;; grab its boundaries if there's a region + (list (region-beginning) (region-end)) + ;; or ask if user intended operating on whole buffer + (if (yes-or-no-p "Delete blank lines in entire buffer? ") + (list (point-min) (point-max)) + (user-error "Aborted")))) + (save-excursion + (save-restriction + (widen) + ;; Regexp "^[[:space:]]*$" matches lines of zero or more spaces/tabs. + (flush-lines "^[[:space:]]*$" start end))) + ;; Return nil (Emacs conventions). Point is already restored. + nil) + +(defun cj/hyphenate-whitespace-in-region (start end) + "Replace runs of whitespace between START and END with hyphens. + +Operate on the active region designated by START and END." + (interactive "*r") + (if (use-region-p) + (save-excursion + (save-restriction + (narrow-to-region start end) + (goto-char (point-min)) + (while (re-search-forward "[ \t\n\r]+" nil t) + (replace-match "-")))) + (message "No region; nothing to hyphenate."))) + + +;; Whitespace operations prefix and keymap +(define-prefix-command 'cj/whitespace-map nil + "Keymap for whitespace operations.") +(define-key cj/custom-keymap "w" 'cj/whitespace-map) +(define-key cj/whitespace-map "r" 'cj/remove-leading-trailing-whitespace) +(define-key cj/whitespace-map "c" 'cj/collapse-whitespace-line-or-region) +(define-key cj/whitespace-map "l" 'cj/delete-blank-lines-region-or-buffer) +(define-key cj/whitespace-map "-" 'cj/hyphenate-whitespace-in-region) + +;;; ------------------------- Surround, Append, Prepend ------------------------- + +(defun cj/surround-word-or-region () + "Surround the word at point or active region with a string read from the minibuffer." + (interactive) + (let ((str (read-string "Surround with: ")) + (regionp (use-region-p))) + (save-excursion + (if regionp + (let ((beg (region-beginning)) + (end (region-end))) + (goto-char end) + (insert str) + (goto-char beg) + (insert str)) + (if (thing-at-point 'word) + (let ((bounds (bounds-of-thing-at-point 'word))) + (goto-char (cdr bounds)) + (insert str) + (goto-char (car bounds)) + (insert str)) + (message "Can't insert around. No word at point and no region selected.")))))) + +(defun cj/append-to-lines-in-region-or-buffer (str) + "Append STR to the end of each line in the region or entire buffer." + (interactive "sEnter string to append: ") + (let ((start-pos (if (use-region-p) + (region-beginning) + (point-min))) + (end-pos (if (use-region-p) + (region-end) + (point-max)))) + (save-excursion + (goto-char start-pos) + (while (< (point) end-pos) + (move-end-of-line 1) + (insert str) + (forward-line 1))))) + +(defun cj/prepend-to-lines-in-region-or-buffer (str) + "Prepend STR to the beginning of each line in the region or entire buffer." + (interactive "sEnter string to prepend: ") + (let ((start-pos (if (use-region-p) + (region-beginning) + (point-min))) + (end-pos (if (use-region-p) + (region-end) + (point-max)))) + (save-excursion + (goto-char start-pos) + (while (< (point) end-pos) + (beginning-of-line 1) + (insert str) + (forward-line 1))))) + +;; Surround, append, prepend prefix keymap +(define-prefix-command 'cj/surround-map nil + "Keymap for surrounding, appending, and prepending operations.") +(define-key cj/custom-keymap "s" 'cj/surround-map) +(define-key cj/surround-map "s" 'cj/surround-word-or-region) +(define-key cj/surround-map "a" 'cj/append-to-lines-in-region-or-buffer) +(define-key cj/surround-map "p" 'cj/prepend-to-lines-in-region-or-buffer) + +;;; -------------------------- Date And Time Insertion -------------------------- + +(defvar readable-date-time-format "%A, %B %d, %Y at %I:%M:%S %p %Z " + "Format string used by `cj/insert-readable-date-time'. + +See `format-time-string' for possible replacements.") + +(defun cj/insert-readable-date-time () + "Insert the current date and time into the current buffer. + +Use `readable-date-time-format' for formatting." + (interactive) + (insert (format-time-string readable-date-time-format (current-time)))) + +(defvar sortable-date-time-format "%Y-%m-%d %a @ %H:%M:%S %z " + "Format string used by `cj/insert-sortable-date-time'. + +See `format-time-string' for possible replacements.") + +(defun cj/insert-sortable-date-time () + "Insert the current date and time into the current buffer. + +Use `sortable-date-time-format' for formatting." + (interactive) + (insert (format-time-string sortable-date-time-format (current-time)))) + +(defvar sortable-time-format "%I:%M:%S %p %Z " + "Format string used by `cj/insert-sortable-time'. + +See `format-time-string' for possible replacements.") + +(defun cj/insert-sortable-time () + "Insert the current time into the current buffer. + +Use `sortable-time-format' for formatting." + (interactive) + (insert (format-time-string sortable-time-format (current-time)))) + +(defvar readable-time-format "%-I:%M %p " + "Format string used by `cj/insert-readable-time'. + +See `format-time-string' for possible replacements.") + +(defun cj/insert-readable-time () + "Insert the current time into the current buffer. + +Use `readable-time-format' for formatting." + (interactive) + (insert (format-time-string readable-time-format (current-time)))) + +(defvar sortable-date-format "%Y-%m-%d %a" + "Format string used by `cj/insert-sortable-date'. + +See `format-time-string' for possible replacements.") + +(defun cj/insert-sortable-date () + "Insert the current date into the current buffer. + +Use `sortable-date-format' for formatting." + (interactive) + (insert (format-time-string sortable-date-format (current-time)))) + +(defvar readable-date-format "%A, %B %d, %Y" + "Format string used by `cj/insert-readable-date'. + +See `format-time-string' for possible replacements.") + +(defun cj/insert-readable-date () + "Insert the current date into the current buffer. + +Use `readable-date-format' for formatting." + (interactive) + (insert (format-time-string readable-date-format (current-time)))) + +;; Date/time insertion prefix and keymap +(define-prefix-command 'cj/datetime-map nil + "Keymap for inserting various date/time formats.") +(define-key cj/custom-keymap "d" 'cj/datetime-map) +(define-key cj/datetime-map "r" 'cj/insert-readable-date-time) +(define-key cj/datetime-map "s" 'cj/insert-sortable-date-time) +(define-key cj/datetime-map "t" 'cj/insert-sortable-time) +(define-key cj/datetime-map "T" 'cj/insert-readable-time) +(define-key cj/datetime-map "d" 'cj/insert-sortable-date) +(define-key cj/datetime-map "D" 'cj/insert-readable-date) + +;;; ----------------------- Line And Paragraph Operations ----------------------- + + +(defun cj/join-line-or-region () + "Join lines in the active region or join the current line with the previous one." + (interactive) + (if (use-region-p) + (let ((beg (region-beginning)) + (end (copy-marker (region-end)))) + (goto-char beg) + (while (< (point) end) + (join-line 1)) + (goto-char end) + (newline)) + ;; No region - only join if there's a previous line + (when (> (line-number-at-pos) 1) + (join-line)) + (newline))) + +(defun cj/join-paragraph () + "Join all lines in the current paragraph using `cj/join-line-or-region'." + (interactive) + (er/mark-paragraph) ;; from package expand region + (cj/join-line-or-region (region-beginning)(region-end)) + (forward-line)) + +(defun cj/duplicate-line-or-region (&optional comment) + "Duplicate the current line or active region below. + +Comment the duplicated text when optional COMMENT is non-nil." + (interactive "P") + (let* ((b (if (region-active-p) (region-beginning) (line-beginning-position))) + (e (if (region-active-p) (region-end) (line-end-position))) + (lines (split-string (buffer-substring-no-properties b e) "\n"))) + (save-excursion + (goto-char e) + (dolist (line lines) + (open-line 1) + (forward-line 1) + (insert line) + ;; If the COMMENT prefix argument is non-nil, comment the inserted text + (when comment + (comment-region (line-beginning-position) (line-end-position))))))) + +(defun cj/remove-duplicate-lines-region-or-buffer () + "Remove duplicate lines in the region or buffer, keeping the first occurrence. + +Operate on the active region when one exists; otherwise operate on the whole buffer." + (interactive) + (let ((start (if (use-region-p) (region-beginning) (point-min))) + (end (if (use-region-p) (region-end) (point-max)))) + (save-excursion + (let ((end-marker (copy-marker end))) + (while + (progn + (goto-char start) + (re-search-forward "^\\(.*\\)\n\\(\\(.*\n\\)*\\)\\1\n" end-marker t)) + (replace-match "\\1\n\\2")))))) + + +(defun cj/remove-lines-containing (text) + "Remove all lines containing TEXT. + +If region is active, operate only on the region, otherwise on entire buffer. +The operation is undoable." + (interactive "sRemove lines containing: ") + (save-excursion + (save-restriction + (let ((region-active (use-region-p)) + (count 0)) + (when region-active + (narrow-to-region (region-beginning) (region-end))) + (goto-char (point-min)) + ;; Count lines before deletion + (while (re-search-forward (regexp-quote text) nil t) + (setq count (1+ count)) + (beginning-of-line) + (forward-line)) + ;; Go back and delete + (goto-char (point-min)) + (delete-matching-lines (regexp-quote text)) + ;; Report what was done + (message "Removed %d line%s containing '%s' from %s" + count + (if (= count 1) "" "s") + text + (if region-active "region" "buffer")))))) + +(defun cj/underscore-line () + "Underline the current line by inserting a row of characters below it. + +If the line is empty or contains only whitespace, abort with a message." + (interactive) + (let ((line (buffer-substring-no-properties + (line-beginning-position) + (line-end-position)))) + (if (string-match-p "^[[:space:]]*$" line) + (message "Line empty or only whitespace. Aborting.") + (let* ((char (read-char "Enter character for underlining: ")) + (len (save-excursion + (goto-char (line-end-position)) + (current-column)))) + (save-excursion + (end-of-line) + (insert "\n" (make-string len char))))))) + + +;; Line & paragraph operations prefix and keymap +(define-prefix-command 'cj/line-and-paragraph-map nil + "Keymap for line and paragraph manipulation.") +(define-key cj/custom-keymap "l" 'cj/line-and-paragraph-map) +(define-key cj/line-and-paragraph-map "j" 'cj/join-line-or-region) +(define-key cj/line-and-paragraph-map "J" 'cj/join-paragraph) +(define-key cj/line-and-paragraph-map "d" 'cj/duplicate-line-or-region) +(define-key cj/line-and-paragraph-map "R" 'cj/remove-duplicate-lines-region-or-buffer) +(define-key cj/line-and-paragraph-map "r" 'cj/remove-lines-containing) +(define-key cj/line-and-paragraph-map "u" 'cj/underscore-line) + +;;; ---------------------------------- Comments --------------------------------- + +(defun cj/comment-reformat () + "Reformat commented text into a single paragraph." + (interactive) + + (if mark-active + (let ((beg (region-beginning)) + (end (copy-marker (region-end))) + (orig-fill-column fill-column)) + (uncomment-region beg end) + (setq fill-column (- fill-column 3)) + (cj/join-line-or-region beg end) + (comment-region beg end) + (setq fill-column orig-fill-column ))) + ;; if no region + (message "No region was selected. Select the comment lines to reformat.")) + +(defun cj/comment-centered (&optional comment-char) + "Insert comment text centered around the COMMENT-CHAR character. + +Default to the hash character when COMMENT-CHAR is nil. + +Use the lesser of `fill-column' or 80 to calculate the comment length. +Begin and end the line with the appropriate comment symbols for the current mode." + (interactive) + (if (not (char-or-string-p comment-char)) + (setq comment-char "#")) + (let* ((comment (capitalize (string-trim (read-from-minibuffer "Comment: ")))) + (fill-column (min fill-column 80)) + (comment-length (length comment)) + (comment-start-length (length comment-start)) + (comment-end-length (length comment-end)) + (current-column-pos (current-column)) + (space-on-each-side (/ (- fill-column + current-column-pos + comment-length + (length comment-start) + (length comment-end) + ;; Single space on each side of comment + (if (> comment-length 0) 2 0) + ;; Single space after comment syntax sting + 1) + 2))) + (if (< space-on-each-side 2) + (message "Comment string is too big to fit in one line") + (progn + (insert comment-start) + (when (equal comment-start ";") ; emacs-lisp line comments are ;; + (insert comment-start)) ; so insert comment-char again + (insert " ") + (dotimes (_ space-on-each-side) (insert comment-char)) + (when (> comment-length 0) (insert " ")) + (insert comment) + (when (> comment-length 0) (insert " ")) + (dotimes (_ (if (= (% comment-length 2) 0) + (- space-on-each-side 1) + space-on-each-side)) + (insert comment-char)) + ;; Only insert trailing space and comment-end if comment-end is not empty + (when (not (string-empty-p comment-end)) + (insert " ") + (insert comment-end)))))) + +(defun cj/comment-box () + "Insert a comment box around text that the user inputs. + +The box extends to the fill column, centers the text, and uses the current +mode's comment syntax at both the beginning and end of each line. The box +respects the current indentation level and avoids trailing whitespace." + (interactive) + (let* ((comment-char (if (equal comment-start ";") ";;" + (string-trim comment-start))) + (comment-end-char (if (string-empty-p comment-end) + comment-char + (string-trim comment-end))) + (line-char (if (equal comment-char ";;") "-" "#")) + (comment (capitalize (string-trim (read-from-minibuffer "Comment: ")))) + (comment-length (length comment)) + (current-column-pos (current-column)) + (max-width (min fill-column 80)) + ;; Calculate available width between comment markers + (available-width (- max-width + current-column-pos + (length comment-char) + (length comment-end-char))) + ;; Inner width is the width without the spaces after comment start and before comment end + (inner-width (- available-width 2)) + ;; Calculate padding for each side of the centered text + (padding-each-side (max 1 (/ (- inner-width comment-length) 2))) + ;; Adjust for odd-length comments + (right-padding (if (= (% (- inner-width comment-length) 2) 0) + padding-each-side + (1+ padding-each-side)))) + + ;; Check if we have enough space + (if (< inner-width (+ comment-length 4)) ; minimum sensible width + (message "Comment string is too big to fit in one line") + (progn + ;; Top line - fill entirely with line characters except for space after comment start + (insert comment-char) + (insert " ") + (insert (make-string inner-width (string-to-char line-char))) + (insert " ") + (insert comment-end-char) + (newline) + + ;; Add indentation on the new line to match current column + (dotimes (_ current-column-pos) (insert " ")) + + ;; Middle line with centered text + (insert comment-char) + (insert " ") + ;; Left padding + (dotimes (_ padding-each-side) (insert " ")) + ;; The comment text + (insert comment) + ;; Right padding + (dotimes (_ right-padding) (insert " ")) + (insert " ") + (insert comment-end-char) + (newline) + + ;; Add indentation on the new line to match current column + (dotimes (_ current-column-pos) (insert " ")) + + ;; Bottom line - same as top line + (insert comment-char) + (insert " ") + (dotimes (_ inner-width) (insert line-char)) + (insert " ") + (insert comment-end-char) + (newline))))) + +(defun cj/comment-hyphen() + "Insert a centered comment with '-' (hyphens) on each side." + (interactive) + (cj/comment-centered "-")) + +(defun cj/delete-buffer-comments () + "Delete all comments within the current buffer." + (interactive) + (goto-char (point-min)) + (let (kill-ring) + (comment-kill (count-lines (point-min) (point-max))))) + +;; Comment styles & removal prefix and keymap +(define-prefix-command 'cj/comment-map nil + "Keymap for comment styling and removal.") +(define-key cj/custom-keymap "C" 'cj/comment-map) +(define-key cj/comment-map "r" 'cj/comment-reformat) +(define-key cj/comment-map "c" 'cj/comment-centered) +(define-key cj/comment-map "-" 'cj/comment-hyphen) +(define-key cj/comment-map "b" 'cj/comment-box) +(define-key cj/comment-map "D" 'cj/delete-buffer-comments) + +;;; ---------------------- Ordering And Sorting Operations ---------------------- + +(defun cj/arrayify (start end quote) + "Convert lines between START and END into quoted, comma-separated strings. + +START and END identify the active region. +QUOTE specifies the quotation characters to surround each element." + (interactive "r\nMQuotation character to use for array element: ") + (let ((insertion + (mapconcat + (lambda (x) (format "%s%s%s" quote x quote)) + (split-string (buffer-substring start end)) ", "))) + (delete-region start end) + (insert insertion))) + +(defun cj/unarrayify (start end) + "Convert quoted, comma-separated strings between START and END into separate lines. + +START and END identify the active region." + (interactive "r") + (let ((insertion + (mapconcat + (lambda (x) (replace-regexp-in-string "[\"']" "" x)) + (split-string (buffer-substring start end) ", ") "\n"))) + (delete-region start end) + (insert insertion))) + +(defun cj/alphabetize-region () + "Alphabetize words in the active region and replace the original text. + +Produce a comma-separated list as the result." + (interactive) + (unless (use-region-p) + (user-error "No region selected")) + (let ((start (region-beginning)) + (end (region-end)) + (string (buffer-substring-no-properties (region-beginning) (region-end)))) + (delete-region start end) + (goto-char start) + (insert + (mapconcat #'identity + (sort (split-string string "[[:space:],]+" t) + #'string-lessp) + ", ")))) + +(defun cj/comma-separated-text-to-lines () + "Break up comma-separated text in the active region so each item is on its own line." + (interactive) + (if (not (region-active-p)) + (error "No region selected")) + + (let ((beg (region-beginning)) + (end (region-end)) + (text (buffer-substring-no-properties (region-beginning) (region-end)))) + (with-temp-buffer + (insert text) + (goto-char (point-min)) + (while (search-forward "," nil t) + (replace-match "\n" nil t)) + (delete-trailing-whitespace) + (setq text (buffer-string))) + + (delete-region beg end) + (goto-char beg) + (insert text))) + + +;; Ordering & sorting prefix and keymap +(define-prefix-command 'cj/ordering-map nil + "Keymap for text ordering and sorting operations.") +(define-key cj/custom-keymap "o" 'cj/ordering-map) +(define-key cj/ordering-map "a" 'cj/arrayify) +(define-key cj/ordering-map "u" 'cj/unarrayify) +(define-key cj/ordering-map "A" 'cj/alphabetize-region) +(define-key cj/ordering-map "l" 'cj/comma-separated-text-to-lines) + +;;; --------------------------- Case Change Operations -------------------------- + +(defun cj/title-case-region () + "Capitalize the region in title case format. + +Title case is a capitalization convention where major words +are capitalized,and most minor words are lowercase. Nouns, +verbs (including linking verbs), adjectives, adverbs,pronouns, +and all words of four letters or more are considered major words. +Short (i.e., three letters or fewer) conjunctions, short prepositions, +and all articles are considered minor words." + (interactive) + (let ((beg nil) + (end nil) + (prev-word-end nil) + ;; Allow capitals for skip characters after this, so: + ;; Warning: An Example + ;; Capitalizes the `An'. + (chars-skip-reset '(?: ?! ??)) + ;; Don't capitalize characters directly after these. e.g. + ;; "Foo-bar" or "Foo\bar" or "Foo's". + + (chars-separator '(?\\ ?- ?' ?.)) + + (word-chars "[:alnum:]") + (word-skip + (list "a" "an" "and" "as" "at" "but" "by" + "for" "if" "in" "is" "nor" "of" + "on" "or" "so" "the" "to" "yet")) + (is-first t)) + (cond + ((region-active-p) + (setq beg (region-beginning)) + (setq end (region-end))) + (t + (setq beg (line-beginning-position)) + (setq end (line-end-position)))) + (save-excursion + ;; work on uppercased text (e.g., headlines) by downcasing first + (downcase-region beg end) + (goto-char beg) + + (while (< (point) end) + (setq prev-word-end (point)) + (skip-chars-forward (concat "^" word-chars) end) + (let ((word-end + (save-excursion + (skip-chars-forward word-chars end) + (point)))) + + (unless (memq (char-before (point)) chars-separator) + (let* ((c-orig (char-to-string (char-after (point)))) + (c-up (capitalize c-orig))) + (unless (string-equal c-orig c-up) + (let ((word (buffer-substring-no-properties (point) word-end))) + (when + (or + ;; Always allow capitalization. + is-first + ;; If it's not a skip word, allow. + (not (member word word-skip)) + ;; Check the beginning of the previous word doesn't reset first. + (save-excursion + (and + (not (zerop + (skip-chars-backward "[:blank:]" prev-word-end))) + (memq (char-before (point)) chars-skip-reset)))) + (delete-region (point) (1+ (point))) + (insert c-up)))))) + (goto-char word-end) + (setq is-first nil)))))) + +;; replace the capitalize-region keybinding to call title-case +(global-set-key [remap capitalize-region] 'cj/title-case-region) + +(defun cj/upcase-dwim () + "Upcase the active region, or upcase the symbol at point if no region." + (interactive) + (if (use-region-p) + (upcase-region (region-beginning) (region-end)) + (let ((bounds (bounds-of-thing-at-point 'symbol))) + (if bounds + (upcase-region (car bounds) (cdr bounds)) + (user-error "No symbol at point"))))) + +(defun cj/downcase-dwim () + "Downcase the active region, or downcase the symbol at point if no region." + (interactive) + (if (use-region-p) + (downcase-region (region-beginning) (region-end)) + (let ((bounds (bounds-of-thing-at-point 'symbol))) + (if bounds + (downcase-region (car bounds) (cdr bounds)) + (user-error "No symbol at point"))))) + +;; Case-change operations prefix and keymap +(define-prefix-command 'cj/case-map nil + "Keymap for case-change operations.") +(define-key cj/custom-keymap "c" 'cj/case-map) +(define-key cj/case-map "t" 'cj/title-case-region) +(define-key cj/case-map "u" 'cj/upcase-dwim) +(define-key cj/case-map "l" 'cj/downcase-dwim) ;; for "lower" case + +(provide 'custom-functions) +;;; custom-functions.el ends here. diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el new file mode 100644 index 00000000..73a76b6b --- /dev/null +++ b/modules/dashboard-config.el @@ -0,0 +1,144 @@ +;;; dashboard-config.el --- Dashboard Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; Note: +;; Nerd-Icons Cheat Sheet: https://www.nerdfonts.com/cheat-sheet + +;;; Code: + +(require 'undead-buffers) + +;; ------------------------ Dashboard Bookmarks Override ----------------------- +;; overrides the bookmark insertion from the dashboard package to provide an +;; option that only shows the bookmark name, avoiding the path. Paths are often +;; too long and the truncation options aren't aesthetically pleasing. Should be +;; accompanied by the setting (setq dashboard-bookmarks-show-path nil) in +;; config. + +(defcustom dashboard-bookmarks-item-format "%s" + "Format to use when showing the base of the file name." + :type 'string + :group 'dashboard) + +(defun dashboard-insert-bookmarks (list-size) + "Add the list of LIST-SIZE items of bookmarks." + (require 'bookmark) + (dashboard-insert-section + "Bookmarks:" + (dashboard-subseq (bookmark-all-names) list-size) + list-size + 'bookmarks + (dashboard-get-shortcut 'bookmarks) + `(lambda (&rest _) (bookmark-jump ,el)) + (if-let* ((filename el) + (path (bookmark-get-filename el)) + (path-shorten (dashboard-shorten-path path 'bookmarks))) + (cl-case dashboard-bookmarks-show-path + (`align + (unless dashboard--bookmarks-cache-item-format + (let* ((len-align (dashboard--align-length-by-type 'bookmarks)) + (new-fmt (dashboard--generate-align-format + dashboard-bookmarks-item-format len-align))) + (setq dashboard--bookmarks-cache-item-format new-fmt))) + (format dashboard--bookmarks-cache-item-format filename path-shorten)) + (`nil filename) + (t (format dashboard-bookmarks-item-format filename path-shorten))) + el))) + +;; ----------------------------- Display Dashboard ----------------------------- +;; convenience function to redisplay dashboard and kill all other windows + +(defun cj/dashboard-only () + "Switch to *dashboard* buffer and kill all other buffers and windows." + (interactive) + (dired-sidebar-hide-sidebar) + (if (get-buffer "*dashboard*") + (progn + (switch-to-buffer "*dashboard*") + (cj/kill-all-other-buffers-and-windows)) + (dashboard-open))) + +;; --------------------------------- Dashboard --------------------------------- +;; a useful startup screen for Emacs + +(use-package dashboard + :defer t + :hook (emacs-startup . cj/dashboard-only) + :bind ("<f4>" . cj/dashboard-only) + :custom + (dashboard-projects-backend 'projectile) + + (dashboard-item-generators + '((projects . dashboard-insert-projects) + (bookmarks . dashboard-insert-bookmarks))) + + (dashboard-items '((projects . 5) + (bookmarks . 15))) + + (dashboard-startupify-list + '(dashboard-insert-banner + dashboard-insert-banner-title + dashboard-insert-newline + dashboard-insert-newline + dashboard-insert-navigator + dashboard-insert-init-info + dashboard-insert-newline + dashboard-insert-newline + dashboard-insert-items + dashboard-insert-newline)) + :config + + ;; == general + (dashboard-setup-startup-hook) ;; run dashboard post emacs init + + (if (< (length command-line-args) 2) + (setq initial-buffer-choice (lambda () (get-buffer "*dashboard*")))) ;; don't display dashboard if opening a file + (setq dashboard-display-icons-p t) ;; display icons on both GUI and terminal + (setq dashboard-icon-type 'nerd-icons) ;; use `nerd-icons' package + (setq dashboard-center-content t) ;; horizontally center dashboard content + (setq dashboard-bookmarks-show-path nil) ;; don't show paths in bookmarks + (setq dashboard-set-footer nil) ;; don't show footer and quotes + + ;; == banner + (setq dashboard-startup-banner (concat user-emacs-directory "assets/M-x_butterfly.png")) + (setq dashboard-banner-logo-title "Emacs: The Editor That Saves Your Soul") + + ;; == navigation + (setq dashboard-set-navigator t) + (setq dashboard-navigator-buttons + `(((,(nerd-icons-faicon "nf-fa-envelope") + "Email" "Mu4e Email Client" + (lambda (&rest _) (mu4e))) + + (,(nerd-icons-faicon "nf-fae-book_open_o") + "Ebooks" "Calibre Ebook Reader" + (lambda (&rest _) (calibredb))) + + (,(nerd-icons-mdicon "nf-md-school") + "Flashcards" "Org-Drill" + (lambda (&rest _) (cj/drill-start))) + + (,(nerd-icons-faicon "nf-fa-rss_square") + "Feeds" "Elfeed Feed Reader" + (lambda (&rest _) (cj/elfeed-open))) + + (,(nerd-icons-faicon "nf-fa-comments") + "IRC" "Emacs Relay Chat" + (lambda (&rest _) (cj/erc-start-or-switch))) + + ;; (,(nerd-icons-faicon "nf-fae-telegram") + ;; "Telegram" "Telega Chat Client" + ;; (lambda (&rest _) (telega))) + + (,(nerd-icons-faicon "nf-fa-folder_o") + "Files" "Dirvish File Manager" + (lambda (&rest _) (dirvish user-home-dir)))))) + + ;; == content + (setq dashboard-show-shortcuts nil) ;; don't show dashboard item abbreviations + ) ;; end use-package dashboard + +(provide 'dashboard-config) +;;; dashboard-config.el ends here. diff --git a/modules/diff-config.el b/modules/diff-config.el new file mode 100644 index 00000000..ff106ead --- /dev/null +++ b/modules/diff-config.el @@ -0,0 +1,53 @@ +;;; diff-config.el --- diff Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; I've configured Ediff for a clean and efficient diff experience. + +;; • Ediff will use a plain control window, horizontal splits, ignore whitespace, and only highlight the current change. +;; • A single keymap under "C-c D" has bindings: +;; - ediff-files (f) +;; - ediff-buffers (b) +;; - ediff-revision (r) +;; - ediff-directories (D) +;; • An Ediff hook that remaps j/k to next/previous differences for easier navigation +;; • The winner-mode functionality ensures window layouts are restored after quitting Ediff + +;; Note: Here's a highly useful setup for configuring ediff. +;; https://oremacs.com/2015/01/17/setting-up-ediff/ + +;;; Code: + +(use-package ediff + :ensure nil ;; built-in + :defer t + :custom + (ediff-window-setup-function 'ediff-setup-windows-plain) + (ediff-split-window-function 'split-window-horizontally) + (ediff-diff-options "-w") + (ediff-highlight-all-diffs nil) + :bind-keymap ("C-c D" . cj/ediff-map) + :init + ;; adding this to a hook to make sure ediff is loaded due to :defer + (defvar cj/ediff-map + (let ((m (make-sparse-keymap))) + (define-key m "f" #'ediff-files) ; C-c D f + (define-key m "b" #'ediff-buffers) ; C-c D b + (define-key m "r" #'ediff-revision) ; C-c D r + (define-key m "D" #'ediff-directories) ; C-c D D + m) + "Prefix map for quick Ediff commands under C-c D.") + :config + (defun cj/ediff-hook () + "Use j/k to navigate differences in Ediff." + (ediff-setup-keymap) ;; keep the defaults… + (define-key ediff-mode-map "j" #'ediff-next-difference) + (define-key ediff-mode-map "k" #'ediff-previous-difference)) + + (add-hook 'ediff-mode-hook #'cj/ediff-hook) + (add-hook 'ediff-after-quit-hook-internal #'winner-undo)) + + +(provide 'diff-config) +;;; diff-config.el ends here diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el new file mode 100644 index 00000000..82b44008 --- /dev/null +++ b/modules/dirvish-config.el @@ -0,0 +1,403 @@ +;;; dirvish-config.el --- Dired/Dirvish Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; DIRVISH NOTES: +;; access the quick access directories by pressing 'g' (for "go") + +;;; Code: + +(require 'user-constants) +(require 'system-utils) + +;;; ----------------------------- Dired Ediff Files ----------------------------- + +(defun cj/dired-ediff-files () + "Ediff two selected files within Dired." + (interactive) + (let ((files (dired-get-marked-files)) + (wnd (current-window-configuration))) + (if (<= (length files) 2) + (let ((file1 (car files)) + (file2 (if (cdr files) + (cadr files) + (read-file-name + "file: " + (dired-dwim-target-directory))))) + (if (file-newer-than-file-p file1 file2) + (ediff-files file2 file1) + (ediff-files file1 file2)) + (add-hook 'ediff-after-quit-hook-internal + (lambda () + (setq ediff-after-quit-hook-internal nil) + (set-window-configuration wnd)))) + (error "No more than 2 files should be marked")))) + +;; ------------------------ Create Playlist From Marked ------------------------ + +(defvar cj/audio-file-extensions + '("mp3" "flac" "m4a" "wav" "ogg" "aac" "opus" "aiff" "alac" "wma") + "List of audio file extensions (lowercase, no dot). +Used to filter files for M3U playlists.") + +(defun cj/dired-create-playlist-from-marked () + "Create an .m3u playlist file from marked files in Dired (or Dirvish). + +Filters for audio files, prompts for the playlist name, and saves the resulting +.m3u in the directory specified by =music-dir=. Interactive use only." + (interactive) + (let* ((marked-files (dired-get-marked-files)) + (audio-files + (cl-remove-if-not + (lambda (f) + (let ((ext (file-name-extension f))) + (and ext + (member (downcase ext) cj/audio-file-extensions)))) + marked-files)) + (count (length audio-files))) + (if (zerop count) + (user-error "No audio files marked (extensions: %s)" + (string-join cj/audio-file-extensions ", ")) + (let ((base-name nil) + (playlist-path nil) + (done nil)) + (while (not done) + (setq base-name (read-string + (format "Playlist name (without .m3u): "))) + ;; Sanitize: strip any trailing .m3u + (setq base-name (replace-regexp-in-string "\\.m3u\\'" "" base-name)) + (setq playlist-path (expand-file-name (concat base-name ".m3u") music-dir)) + (cond + ((not (file-exists-p playlist-path)) + ;; Safe to write + (setq done t)) + (t + (let ((choice (read-char-choice + (format "Playlist '%s' exists. [o]verwrite, [c]ancel, [r]ename? " + (file-name-nondirectory playlist-path)) + '(?o ?c ?r)))) + (cl-case choice + (?o (setq done t)) + (?c (user-error "Cancelled playlist creation")) + (?r (setq done nil))))))) + ;; Actually write the file + (with-temp-file playlist-path + (dolist (af audio-files) + (insert af "\n"))) + (message "Wrote playlist %s with %d tracks" (file-name-nondirectory playlist-path) count))))) + +;;; ----------------------------------- Dired ----------------------------------- + +(use-package dired + :ensure nil ;; built-in + :defer t + :bind + (:map dired-mode-map + ([remap dired-summary] . which-key-show-major-mode) + ("E" . wdired-change-to-wdired-mode) ;; edit names and properties in buffer + ("e" . cj/dired-ediff-files)) ;; ediff files + :custom + (dired-use-ls-dired nil) ;; non GNU FreeBSD doesn't support a "--dired" switch + :config + (setq dired-listing-switches "-l --almost-all --human-readable --group-directories-first") + (setq dired-dwim-target t) + (setq dired-clean-up-buffers-too t) ;; offer to kill buffers associated deleted files and dirs + (setq dired-clean-confirm-killing-deleted-buffers t) ;; don't ask; just kill buffers associated with deleted files + (setq dired-recursive-copies (quote always)) ;; “always” means no asking + (setq dired-recursive-deletes (quote top))) ;; “top” means ask once + +;; note: disabled as it prevents marking and moving files to another directory +;; (setq dired-kill-when-opening-new-dired-buffer t) ;; don't litter by leaving buffers when navigating directories + +(add-hook 'dired-mode-hook 'auto-revert-mode) ;; auto revert dired when files change + +;;; --------------------------- Dired Open HTML In EWW -------------------------- + +(defun cj/dirvish-open-html-in-eww () + "Open HTML file at point in dired/dirvish using eww." + (interactive) + (let ((file (dired-get-file-for-visit))) + (if (string-match-p "\\.html?\\'" file) + (eww-open-file file) + (message "Not an HTML file: %s" file)))) + +;;; ------------------------ Dired Mark All Visible Files ----------------------- + +(defun cj/dired-mark-all-visible-files () + "Mark all visible files in Dired mode." + (interactive) + (save-excursion + (goto-char (point-min)) + (while (not (eobp)) + (if (not (looking-at "^. d")) + (dired-mark 1)) + (forward-line 1)))) + +;;; ----------------------- Dirvish Open File Manager Here ---------------------- + +(defun cj/dirvish-open-file-manager-here () + "Open system's default file manager in the current dired/dirvish directory. + +Always opens the file manager in the directory currently being displayed, +regardless of what file or subdirectory the point is on." + (interactive) + (let ((current-dir (dired-current-directory))) + (if (and current-dir (file-exists-p current-dir)) + (progn + (message "Opening file manager in %s..." current-dir) + ;; Use shell-command with & to run asynchronously and detached + (let ((process-connection-type nil)) ; Use pipe instead of pty + (cond + ;; Linux/Unix with xdg-open + ((executable-find "xdg-open") + (call-process "xdg-open" nil 0 nil current-dir)) + ;; macOS + ((eq system-type 'darwin) + (call-process "open" nil 0 nil current-dir)) + ;; Windows + ((eq system-type 'windows-nt) + (call-process "explorer" nil 0 nil current-dir)) + ;; Fallback to shell-command + (t + (shell-command (format "xdg-open %s &" + (shell-quote-argument current-dir))))))) + (message "Could not determine current directory.")))) + +;;; ---------------------------------- Dirvish ---------------------------------- + +(use-package dirvish + :defer 1 + :init + (dirvish-override-dired-mode) + :custom + ;; This MUST be in :custom section, not :config + (dirvish-quick-access-entries + `(("h" "~/" "home") + ("cx" ,code-dir "code directory") + ("ex" ,user-emacs-directory "emacs home") + ("es" ,sounds-dir "notification sounds") + ("ra" ,video-recordings-dir "video recordings") + ("rv" ,audio-recordings-dir "audio recordings") + ("dl" ,dl-dir "downloads") + ("dr" ,(concat sync-dir "/drill/") "drill files") + ("dt" ,(concat dl-dir "/torrents/complete/") "torrents") + ("dx" "~/documents/" "documents") + ("lx" "~/lectures/" "lectures") + ("mb" "/media/backup/" "backup directory") + ("mx" "~/music/" "music") + ("pD" "~/projects/documents/" "project documents") + ("pd" "~/projects/danneel/" "project danneel") + ("pl" "~/projects/elibrary/" "project elibrary") + ("pf" "~/projects/finances/" "project finances") + ("pjr" "~/projects/jr-estate/" "project jr-estate") + ("ps" ,(concat pix-dir "/screenshots/") "pictures screenshots") + ("pw" ,(concat pix-dir "/wallpaper/") "pictures wallpaper") + ("px" ,pix-dir "pictures directory") + ("rcj" "/sshx:cjennings@cjennings.net:~" "remote cjennings.net") + ("rsb" "/sshx:cjennings@wolf.usbx.me:/home/cjennings/" "remote seedbox") + ("sx" ,sync-dir "sync directory") + ("so" "~/sync/org" "org directory") + ("sv" "~/sync/videos/" "sync/videos directory") + ("tg" ,(concat sync-dir "/text.games") "text games") + ("vr" ,video-recordings-dir "video recordings directory") + ("vx" ,videos-dir "videos"))) + :config + ;; Add the extensions directory to load-path + (let ((extensions-dir (expand-file-name "extensions" + (file-name-directory (locate-library "dirvish"))))) + (when (file-directory-p extensions-dir) + (add-to-list 'load-path extensions-dir))) + + ;; Load dirvish modules with error checking + (let ((dirvish-modules '(dirvish-emerge + dirvish-subtree + dirvish-narrow + dirvish-history + dirvish-ls + dirvish-yank + dirvish-quick-access + dirvish-collapse + dirvish-rsync + dirvish-vc + dirvish-icons + dirvish-side + dirvish-peek))) + (dolist (module dirvish-modules) + (condition-case err + (require module) + (error + (message "Failed to load %s: %s" module (error-message-string err)))))) + + ;; Enable peek mode with error checking + (condition-case err + (dirvish-peek-mode 1) + (error (message "Failed to enable dirvish-peek-mode: %s" (error-message-string err)))) + + ;; Enable side-follow mode with error checking + (condition-case err + (dirvish-side-follow-mode 1) + (error (message "Failed to enable dirvish-side-follow-mode: %s" + (error-message-string err)))) + + ;; Your other configuration settings + (setq dirvish-attributes '(nerd-icons file-size)) + (setq dirvish-preview-dispatchers '(image gif video audio epub pdf archive)) + (setq dirvish-use-mode-line nil) + (setq dirvish-use-header-line nil) + :bind + (("C-x d" . dirvish) + ("C-x C-d" . dirvish) + ("C-x D" . dirvish) + ("<f11>" . dirvish-side) + :map dirvish-mode-map + ("bg" . (lambda () (interactive) + (shell-command + (concat "nitrogen --save --set-zoom-fill " + (dired-file-name-at-point) " >>/dev/null 2>&1")))) + ("/" . dirvish-narrow) + ("<left>" . dired-up-directory) + ("<right>" . dired-find-file) + ("C-," . dirvish-history-go-backward) + ("C-." . dirvish-history-go-forward) + ("F" . dirvish-file-info-menu) + ("G" . revert-buffer) + ("l" . (lambda () (interactive) (cj/dired-copy-path-as-kill))) ;; overwrites dired-do-redisplay + ("h" . cj/dirvish-open-html-in-eww) ;; it does what it says it does + ("M" . cj/dired-mark-all-visible-files) + ("M-e" . dirvish-emerge-menu) + ("M-l" . dirvish-ls-switches-menu) + ("M-m" . dirvish-mark-menu) + ("M-p" . dirvish-peek-toggle) + ("M-s" . dirvish-setup-menu) + ("TAB" . dirvish-subtree-toggle) + ("d". dired-flag-file-deletion) + ("f" . cj/dirvish-open-file-manager-here) + ("g" . dirvish-quick-access) + ("o" . cj/xdg-open) + ("O" . cj/open-file-with-command) ; Prompts for command to run + ("r" . dirvish-rsync) + ("p" . cj/dired-create-playlist-from-marked) + ("s" . dirvish-quicksort) + ("v" . dirvish-vc-menu) + ("y" . dirvish-yank-menu))) + +;;; -------------------------------- Nerd Icons ------------------------------- + +(use-package nerd-icons + :defer .5) + +(use-package nerd-icons-dired + :commands (nerd-icons-dired-mode)) + +;;; ---------------------------- Dired Hide Dotfiles ---------------------------- + +(use-package dired-hide-dotfiles + :after dired + :hook + ;; Auto-hide dotfiles when entering dired/dirvish + ((dired-mode . dired-hide-dotfiles-mode) + (dirvish-mode . dired-hide-dotfiles-mode)) + :bind + (:map dired-mode-map + ("." . dired-hide-dotfiles-mode))) + +;;; ------------------------------- Dired Sidebar ------------------------------- + +(use-package dired-sidebar + :after (dired projectile) + :bind (("<f11>" . dired-sidebar-toggle-sidebar)) + :commands (dired-sidebar-toggle-sidebar) + :init + (add-hook 'dired-sidebar-mode-hook + (lambda () + (unless (file-remote-p default-directory) + (auto-revert-mode)))) + :config + (push 'toggle-window-split dired-sidebar-toggle-hidden-commands) ;; disallow splitting dired window when it's showing + (push 'rotate-windows dired-sidebar-toggle-hidden-commands) ;; disallow rotating windows when sidebar is showing + (setq dired-sidebar-subtree-line-prefix " ") ;; two spaces give simple and aesthetic indentation + (setq dired-sidebar-no-delete-other-windows t) ;; don't close when calling 'delete other windows' + (setq dired-sidebar-theme 'nerd-icons) ;; gimme fancy icons, please + (setq dired-sidebar-use-custom-font 'nil) ;; keep the same font as the rest of Emacs + (setq dired-sidebar-delay-auto-revert-updates 'nil) ;; don't delay auto-reverting + (setq dired-sidebar-pop-to-sidebar-on-toggle-open 'nil)) ;; don't jump to sidebar when it's toggled on + +;; --------------------------------- Copy Path --------------------------------- + +(defun cj/dired-copy-path-as-kill (&optional as-org-link) + "Copy path of file at point in Dired/Dirvish. +Copies relative path from project root if in a project, otherwise from home +directory (with ~ prefix) if applicable, otherwise the absolute path. + +With prefix arg or when AS-ORG-LINK is non-nil, format as \='org-mode\=' link." + (interactive "P") + (unless (derived-mode-p 'dired-mode) + (user-error "Not in a Dired buffer")) + + (let* ((file (dired-get-filename nil t)) + (file-name (file-name-nondirectory file)) + (project-root (cj/get-project-root)) + (home-dir (expand-file-name "~")) + path path-type) + + (unless file + (user-error "No file at point")) + + (cond + ;; Project-relative path + (project-root + (setq path (file-relative-name file project-root) + path-type "project-relative")) + + ;; Home-relative path + ((string-prefix-p home-dir file) + (let ((relative-from-home (file-relative-name file home-dir))) + (setq path (if (string= relative-from-home ".") + "~" + (concat "~/" relative-from-home)) + path-type "home-relative"))) + + ;; Absolute path + (t + (setq path file + path-type "absolute"))) + + ;; Format as org-link if requested + (when as-org-link + (setq path (format "[[file:%s][%s]]" path file-name))) + + ;; Copy to kill-ring and clipboard + (kill-new path) + + ;; Provide feedback + (message "Copied %s path%s: %s" + path-type + (if as-org-link " as org-link" "") + (if (> (length path) 60) + (concat (substring path 0 57) "...") + path)))) + +(defun cj/get-project-root () + "Get project root using projectile or project.el. +Returns nil if not in a project." + (cond + ;; Try projectile first if available + ((and (fboundp 'projectile-project-root) + (ignore-errors (projectile-project-root)))) + + ;; Fallback to project.el + ((and (fboundp 'project-current) + (project-current)) + (let ((proj (project-current))) + (if (fboundp 'project-root) + (project-root proj) + ;; Compatibility with older versions + (car (project-roots proj))))) + + ;; No project found + (t nil))) + + +(provide 'dirvish-config) +;;; dirvish-config.el ends here. diff --git a/modules/dwim-shell-config.el b/modules/dwim-shell-config.el new file mode 100644 index 00000000..a1ace2be --- /dev/null +++ b/modules/dwim-shell-config.el @@ -0,0 +1,732 @@ +;; dwim-shell-config.el --- Dired Shell Commands -*- coding: utf-8; lexical-binding: t; -*- +;; +;;; Commentary: +;; +;; This module provides a collection of DWIM (Do What I Mean) shell commands +;; for common file operations in Dired and other buffers. It leverages the +;; `dwim-shell-command' package to execute shell commands on marked files +;; with smart templating and progress tracking. +;; +;; Features: +;; - Audio/Video conversion (mp3, opus, webp, HEVC) +;; - Image manipulation (resize, flip, format conversion) +;; - PDF operations (merge, split, password protection, OCR) +;; - Archive management (zip/unzip) +;; - Document conversion (epub to org, docx to pdf, pdf to txt) +1;; - Git operations (clone from clipboard) +;; - External file opening with context awareness +;; +;; Workflow: +;; 1. *Mark files in Dired/Dirvish* +;; - Use =m= to mark individual files +;; - Use =* .= to mark by extension +;; - Use =% m= to mark by regexp +;; - Or operate on the file under cursor if nothing is marked +;; +;; 2. *Execute a DWIM command* +;; - Call the command via =M-x dwim-shell-commands-[command-name]= +;; - Or bind frequently used commands to keys +;; +;; 3. *Command execution* +;; - The command runs asynchronously in the background +;; - A =*Async Shell Command*= buffer shows progress +;; - Files are processed with smart templating (replacing =<<f>>=, =<<fne>>=, etc.) +;; +;; 4. *Results* +;; - New files appear in the Dired/Dirvish buffer +;; - Buffer auto-refreshes when command completes +;; - Errors appear in the async buffer if something fails +;; +;; Requirements: +;; The commands rely on various external utilities that need to be installed: +;; - ffmpeg: Audio/video conversion +;; - imagemagick (convert): Image manipulation +;; - qpdf: PDF operations +;; - tesseract: OCR functionality +;; - pandoc: Document conversion +;; - atool: Archive extraction +;; - rsvg-convert: SVG to PNG conversion +;; - pdftotext: PDF text extraction +;; - git: Version control operations +;; - gpgconf: GPG agent management +;; +;; On Arch Linux, install the requirements with: +;; #+begin_src bash +;; sudo pacman -S --needed ffmpeg imagemagick qpdf tesseract tesseract-data-eng pandoc atool librsvg poppler git gnupg zip unzip mkvtoolnix-cli mpv ruby +;; #+end_src +;; +;; On MacOS, install the requirements with: +;; #+begin_src bash +;; brew install ffmpeg imagemagick qpdf tesseract pandoc atool librsvg poppler gnupg mkvtoolnix mpv +;; #+end_src +;; +;; Usage: +;; Commands operate on marked files in Dired or the current file in other modes. +;; The package automatically replaces standard shell commands with DWIM versions +;; for a more intuitive experience. +;; +;; Template Variables: +;; - <<f>>: Full path to file +;; - <<fne>>: File name without extension +;; - <<e>>: File extension +;; - <<td>>: Temporary directory +;; - <<cb>>: Clipboard contents +;; - <<*>>: All marked files +;; + +;;; Code: + +(require 'system-utils) + +;; -------------------------- Dwim Shell Commands Menu ------------------------- + +(defun dwim-shell-commands-menu () + "Select and execute a dwim-shell-command function with prettified names." + (interactive) + (let* ((commands (cl-loop for symbol being the symbols + when (and (fboundp symbol) + (string-prefix-p "cj/dwim-shell-commands-" (symbol-name symbol)) + (not (eq symbol 'dwim-shell-commands-menu))) + collect symbol)) + ;; Create alist of (pretty-name . command-symbol) + (command-alist (mapcar (lambda (cmd) + (cons (replace-regexp-in-string + "-" " " + (replace-regexp-in-string + "^cj/dwim-shell-commands-" + "" + (symbol-name cmd))) + cmd)) + commands)) + (selected (completing-read "Command: " + command-alist + nil + t + nil + 'dwim-shell-command-history)) + (command (alist-get selected command-alist nil nil #'string=))) + (when command + (call-interactively command)))) + +(with-eval-after-load 'dired + (define-key dired-mode-map (kbd "M-D") #'dwim-shell-commands-menu)) + +;; ----------------------------- Dwim Shell Command ---------------------------- + +(use-package dwim-shell-command + :defer 0.5 + :bind (([remap shell-command] . dwim-shell-command) + :map dired-mode-map + ([remap dired-do-async-shell-command] . dwim-shell-command) + ([remap dired-do-shell-command] . dwim-shell-command) + ([remap dired-smart-shell-command] . dwim-shell-command)) + :init + (defun cj/dwim-shell-commands-convert-audio-to-mp3 () + "Convert all marked audio to mp3(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Convert to mp3" + "ffmpeg -stats -n -i '<<f>>' -acodec libmp3lame '<<fne>>.mp3'" + :utils "ffmpeg")) + + (defun cj/dwim-shell-commands-convert-audio-to-opus () + "Convert all marked audio to opus(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Convert to opus" + "ffmpeg -stats -n -i '<<f>>' -c:a libopus -vbr on -compression_level 10 -b:a 256k '<<fne>>.opus'" + :utils "ffmpeg")) + + (defun cj/dwim-shell-commands-view-image-exif-metadata () + "View EXIF metadata in image(s)." + (interactive) + (dwim-shell-command-on-marked-files + "View EXIF" + "exiftool '<<f>>'" + :utils "exiftool")) + + (defun cj/dwim-shell-commands-flip-image-horizontally () + "Horizontally flip image(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Image horizontal flip" + "convert -verbose -flop '<<f>>' '<<fne>>_h_flipped.<<e>>'" + :utils "convert")) + + (defun cj/dwim-shell-commands-flip-image-vertically () + "Horizontally flip image(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Image vertical flip" + "convert -verbose -flip '<<f>>' '<<fne>>_v_flipped.<<e>>'" + :utils "convert")) + + (defun cj/dwim-shell-commands-convert-image-to () + "Convert all marked images to a specified format." + (interactive) + (let ((format (completing-read "Convert to format: " + '("jpg" "png" "webp" "gif" "bmp" "tiff") + nil t))) + (dwim-shell-command-on-marked-files + (format "Convert to %s" format) + (format "convert -verbose '<<f>>' '<<fne>>.%s'" format) + :utils "convert"))) + + (defun cj/dwim-shell-commands-convert-svg-to-png () + "Convert all marked svg(s) to png(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Convert to png" + "rsvg-convert -b white '<<f>>' -f png -o '<<fne>>.png'" + :utils "rsvg-convert")) + + (defun cj/dwim-shell-commands-join-images-into-pdf () + "Join all marked images as a single pdf." + (interactive) + (dwim-shell-command-on-marked-files + "Join as pdf" + (format "convert -verbose '<<*>>' '<<%s(u)>>'" + (dwim-shell-command-read-file-name + "Join as pdf named (default \"joined.pdf\"): " + :extension "pdf" + :default "joined.pdf")) + :utils "convert")) + + (defun cj/dwim-shell-commands-extract-pdf-page-number () + "Keep a page from pdf." + (interactive) + (let ((page-num (read-number "Keep page number: " 1))) + (dwim-shell-command-on-marked-files + "Keep pdf page" + (format "qpdf '<<f>>' --pages . %d -- '<<fne>>_%d.<<e>>'" page-num page-num) + :utils "qpdf"))) + + (defun cj/dwim-shell-commands-ocr-text-from-image-using-tesseract () + "Extract text from image via tesseract." + (interactive) + (dwim-shell-command-on-marked-files + "Extract text from image via tesseract." + "tesseract '<<f>>' -" + :utils "tesseract")) + + (defun cj/dwim-shell-commands-convert-video-to-webp () + "Convert all marked videos to webp(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Convert to webp" + "ffmpeg -i '<<f>>' -vcodec libwebp -filter:v fps=fps=10 -compression_level 3 -loop 0 -preset default -an -vsync 0 '<<fne>>'.webp" + :utils "ffmpeg")) + + (defun cj/dwim-shell-commands-convert-video-to-high-compatibility-mp4 () + "Convert all marked video(s) to MP4 format with H.264/AAC." + (interactive) + (dwim-shell-command-on-marked-files + "Convert to MP4" + "ffmpeg -i '<<f>>' -c:v libx264 -preset slow -crf 23 -profile:v baseline -level 3.0 -c:a aac -b:a 128k '<<fne>>.mp4'" + :utils "ffmpeg")) + + (defun cj/dwim-shell-commands-convert-video-to-hevc-mkv () + "Convert all marked videos to HEVC (H.265) in MKV container." + (interactive) + (dwim-shell-command-on-marked-files + "Convert to HEVC/H.265" + "ffmpeg -i '<<f>>' -c:v libx265 -preset slower -crf 22 -x265-params profile=main10:level=4.0 -c:a copy -c:s copy '<<fne>>.mkv'" + :utils "ffmpeg")) + + (defun cj/dwim-shell-commands-extract-archive-smartly () + "Unzip all marked archives (of any kind) using =atool'. + +If there's only one file, unzip it to current directory. +Otherwise, unzip it to an appropriately named subdirectory " + (interactive) + (dwim-shell-command-on-marked-files + "Unzip" "atool --extract --subdir --explain '<<f>>'" + :utils "atool")) + + (defun cj/dwim-shell-commands-zip-file-or-directory () + "Zip all marked files into archive.zip." + (interactive) + (dwim-shell-command-on-marked-files + "Zip" (if (eq 1 (seq-length (dwim-shell-command--files))) + "zip -r '<<fne>>.<<e>>' '<<f>>'" + "zip -r '<<archive.zip(u)>>' '<<*>>'") + :utils "zip")) + + (defun cj/dwim-shell-commands-tar-gzip-file-or-directory () + "Tar gzip all marked files into archive.tar.gz." + (interactive) + (dwim-shell-command-on-marked-files + "Tar gzip" (if (eq 1 (seq-length (dwim-shell-command--files))) + "tar czf '<<fne>>.tar.gz' '<<f>>'" + "tar czf '<<archive.tar.gz(u)>>' '<<*>>'") + :utils "tar")) + + (defun cj/dwim-shell-commands-epub-to-org () + "Convert epub(s) to org." + (interactive) + (dwim-shell-command-on-marked-files + "epub to org" + "pandoc --from=epub --to=org '<<f>>' > '<<fne>>.org'" + :extensions "epub" + :utils "pandoc")) + + (defun cj/dwim-shell-commands-document-to-pdf () + "Convert document(s) to pdf (via latex). + +Supports docx, odt, and other pandoc-compatible formats." + (interactive) + (dwim-shell-command-on-marked-files + "Document to pdf (via latex)" + "pandoc -t latex '<<f>>' -o '<<fne>>.pdf'" + :extensions '("docx" "odt" "odp" "ods" "rtf" "doc") + :utils "pdflatex")) + + (defun cj/dwim-shell-commands-pdf-to-txt () + "Convert pdf to txt." + (interactive) + (dwim-shell-command-on-marked-files + "pdf to txt" + "pdftotext -layout '<<f>>' '<<fne>>.txt'" + :utils "pdftotext")) + + (defun cj/dwim-shell-commands-resize-image-by-factor () + "Resize marked image(s) by factor." + (interactive) + (dwim-shell-command-on-marked-files + "Resize image" + (let ((factor (read-number "Resize scaling factor: " 0.5))) + (format "convert -resize %%%d '<<f>>' '<<fne>>_x%.2f.<<e>>'" + (* 100 factor) factor)) + :utils "convert")) + + (defun cj/dwim-shell-commands-resize-image-in-pixels () + "Resize marked image(s) in pixels." + (interactive) + (dwim-shell-command-on-marked-files + "Resize image" + (let ((width (read-number "Resize width (pixels): " 500))) + (format "convert -resize %dx '<<f>>' '<<fne>>_x%d.<<e>>'" width width)) + :utils "convert")) + + (defun cj/dwim-shell-commands-pdf-password-protect () + "Add a password to pdf(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Password protect pdf" + (format "qpdf --verbose --encrypt '%s' '%s' 256 -- '<<f>>' '<<fne>>_protected.<<e>>'" + (read-passwd "user-password: ") + (read-passwd "owner-password: ")) + :utils "qpdf" + :extensions "pdf")) + + (defun cj/dwim-shell-commands-pdf-password-unprotect () + "Remove a password from pdf(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Remove protection from pdf" + (format "qpdf --verbose --decrypt --password='%s' -- '<<f>>' '<<fne>>_unprotected.<<e>>'" + (read-passwd "password: ")) + :utils "qpdf" + :extensions "pdf")) + + (defun cj/dwim-shell-commands-video-trim () + "Trim video with options for beginning, end, or both." + (interactive) + (let* ((trim-type (completing-read "Trim from: " + '("Beginning" "End" "Both") + nil t)) + (command (pcase trim-type + ("Beginning" + (let ((seconds (read-number "Seconds to trim from beginning: " 5))) + (format "ffmpeg -i '<<f>>' -y -ss %d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'" + seconds))) + ("End" + (let ((seconds (read-number "Seconds to trim from end: " 5))) + (format "ffmpeg -sseof -%d -i '<<f>>' -y -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'" + seconds))) + ("Both" + (let ((start (read-number "Seconds to trim from beginning: " 5)) + (end (read-number "Seconds to trim from end: " 5))) + (format "ffmpeg -i '<<f>>' -y -ss %d -sseof -%d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'" + start end)))))) + (dwim-shell-command-on-marked-files + (format "Trim video (%s)" trim-type) + command + :silent-success t + :utils "ffmpeg"))) + + (defun cj/dwim-shell-commands-drop-audio-from-video () + "Drop audio from all marked videos." + (interactive) + (dwim-shell-command-on-marked-files + "Drop audio" + "ffmpeg -i '<<f>>' -c copy -an '<<fne>>_no_audio.<<e>>'" + :utils "ffmpeg")) + + (defun cj/dwim-shell-commands-open-externally () + "Open file(s) externally." + (interactive) + (dwim-shell-command-on-marked-files + "Open externally" + (cond ((eq system-type 'darwin) + (if (derived-mode-p 'prog-mode) + (format "open -a Xcode --args --line %d '<<f>>'" + (line-number-at-pos (point))) + "open '<<f>>'")) + ((eq system-type 'windows-nt) + "start '<<f>>'") + (t ;; Linux/Unix + "xdg-open '<<f>>' 2>/dev/null || (echo 'Failed to open with xdg-open' && exit 1)")) + :silent-success t + :utils (cond ((eq system-type 'darwin) "open") + ((eq system-type 'windows-nt) "start") + (t "xdg-open")))) + + + (defun cj/dwim-shell-commands-git-clone-clipboard-url () + "Clone git URL in clipboard to `default-directory'." + (interactive) + (dwim-shell-command-on-marked-files + (format "Clone %s" (file-name-base (current-kill 0))) + "git clone <<cb>>" + :utils "git")) + + (defun cj/dwim-shell-commands-open-file-manager () + "Open the default file manager in the current directory." + (interactive) + (dwim-shell-command-on-marked-files + "Open file manager" + (cond ((eq system-type 'darwin) + "open .") + ((eq system-type 'windows-nt) + "explorer .") + (t ;; Linux/Unix - try multiple options + (cond ((executable-find "thunar") "thunar .") + ((executable-find "nautilus") "nautilus .") + ((executable-find "dolphin") "dolphin .") + ((executable-find "pcmanfm") "pcmanfm .") + (t "xdg-open .")))) + :silent-success t + :no-progress t)) + + (defun cj/dwim-shell-commands-count-words-lines () + "Count words, lines, and characters in text file(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Word count" + "wc -lwc '<<f>>'" + :utils "wc")) + + (defun cj/dwim-shell-commands-checksum () + "Generate checksums for file(s) and save to .checksum file." + (interactive) + (let ((algorithm (completing-read "Algorithm: " + '("md5" "sha1" "sha256" "sha512") + nil t))) + (dwim-shell-command-on-marked-files + (format "Generate %s checksum" algorithm) + (format "%ssum '<<f>>' | tee '<<f>>.%s'" algorithm algorithm) + :utils (format "%ssum" algorithm)))) + + (defun cj/dwim-shell-commands-backup-with-timestamp () + "Create dated backup of file(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Backup with date" + "cp -p '<<f>>' '<<f>>.$(date +%Y%m%d_%H%M%S).bak'" + :utils '("cp" "date"))) + + (defun cj/dwim-shell-commands-optimize-image-for-web () + "Optimize image(s) for web (reduce file size)." + (interactive) + (dwim-shell-command-on-marked-files + "Optimize image" + "convert '<<f>>' -strip -interlace Plane -gaussian-blur 0.05 -quality 85% '<<fne>>_optimized.<<e>>'" + :utils "convert")) + + (defun cj/dwim-shell-commands-csv-to-json () + "Convert CSV to JSON." + (interactive) + (dwim-shell-command-on-marked-files + "CSV to JSON" + "python -c \"import csv,json,sys; print(json.dumps(list(csv.DictReader(open('<<f>>')))))\" > '<<fne>>.json'" + :extensions "csv" + :utils "python")) + + (defun cj/dwim-shell-commands-json-to-yaml () + "Convert JSON to YAML." + (interactive) + (dwim-shell-command-on-marked-files + "JSON to YAML" + "python -c \"import json,yaml,sys; yaml.dump(json.load(open('<<f>>')), open('<<fne>>.yaml', 'w'))\" && echo 'Created <<fne>>.yaml'" + :extensions "json" + :utils "python")) + + (defun cj/dwim-shell-commands-extract-urls-from-file () + "Extract all URLs from file(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Extract URLs" + "grep -Eo 'https?://[^[:space:]]+' '<<f>>'" + :utils "grep")) + + (defun cj/dwim-shell-commands-extract-emails-from-file () + "Extract all email addresses from file(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Extract emails" + "grep -Eo '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}' '<<f>>'" + :utils "grep")) + + (defun cj/dwim-shell-commands-create-gif-from-video () + "Create animated GIF from video." + (interactive) + (let ((fps (read-number "FPS for GIF: " 10)) + (scale (read-number "Scale (pixels width): " 480))) + (dwim-shell-command-on-marked-files + "Create GIF" + (format "ffmpeg -i '<<f>>' -vf 'fps=%d,scale=%d:-1:flags=lanczos' '<<fne>>.gif'" fps scale) + :utils "ffmpeg"))) + + (defun cj/dwim-shell-commands-concatenate-videos () + "Concatenate multiple videos into one." + (interactive) + (dwim-shell-command-on-marked-files + "Concatenate videos" + "echo '<<*>>' | tr ' ' '\n' | sed 's/^/file /' > '<<td>>/filelist.txt' && ffmpeg -f concat -safe 0 -i '<<td>>/filelist.txt' -c copy '<<concatenated.mp4(u)>>'" + :utils "ffmpeg")) + + (defun cj/dwim-shell-commands-create-video-thumbnail () + "Extract thumbnail from video at specific time." + (interactive) + (let ((time (read-string "Time (HH:MM:SS or seconds): " "00:00:05"))) + (dwim-shell-command-on-marked-files + "Extract video thumbnail" + (format "ffmpeg -i '<<f>>' -ss %s -vframes 1 '<<fne>>_thumb.jpg'" time) + :utils "ffmpeg"))) + + (defun cj/dwim-shell-commands-merge-pdfs () + "Merge multiple PDFs into one." + (interactive) + (dwim-shell-command-on-marked-files + "Merge PDFs" + "qpdf --empty --pages '<<*>>' -- '<<merged.pdf(u)>>'" + :extensions "pdf" + :utils "qpdf")) + + (defun cj/dwim-shell-commands-split-pdf-by-pages () + "Split PDF into individual pages." + (interactive) + (dwim-shell-command-on-marked-files + "Split PDF pages" + "qpdf --split-pages '<<f>>' '<<fne>>-page-%d.pdf'" + :extensions "pdf" + :utils "qpdf")) + + (defun cj/dwim-shell-commands-compress-pdf () + "Compress PDF file size." + (interactive) + (let ((quality (completing-read "Quality: " + '("screen" "ebook" "printer" "prepress") + nil t "ebook"))) + (dwim-shell-command-on-marked-files + "Compress PDF" + (format "gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dPDFSETTINGS=/%s -dNOPAUSE -dBATCH -sOutputFile='<<fne>>_compressed.pdf' '<<f>>'" quality) + :extensions "pdf" + :utils "gs"))) + + (defun cj/dwim-shell-commands-ascii-art () + "Convert image to ASCII art." + (interactive) + (dwim-shell-command-on-marked-files + "Create ASCII art" + "jp2a --width=80 '<<f>>'" + :utils "jp2a")) + + (defun cj/dwim-shell-commands-text-to-speech () + "Convert text file to speech (audio file)." + (interactive) + (let ((voice (if (eq system-type 'darwin) + (completing-read "Voice: " '("Alex" "Samantha" "Victoria" "Karen") nil t "Alex") + "en"))) + (dwim-shell-command-on-marked-files + "Text to speech" + (if (eq system-type 'darwin) + (format "say -v %s -o '<<fne>>.aiff' -f '<<f>>'" voice) + "espeak -f '<<f>>' -w '<<fne>>.wav'") + :utils (if (eq system-type 'darwin) "say" "espeak")))) + + (defun cj/dwim-shell-commands-remove-empty-directories () + "Remove all empty directories recursively." + (interactive) + (when (yes-or-no-p "Remove all empty directories? ") + (dwim-shell-command-on-marked-files + "Remove empty dirs" + "find . -type d -empty -delete" + :utils "find"))) + + (defun cj/dwim-shell-commands-create-thumbnail-from-image () + "Create thumbnail(s) from image(s)." + (interactive) + (let ((size (read-number "Thumbnail size (pixels): " 200))) + (dwim-shell-command-on-marked-files + "Create thumbnail" + (format "convert '<<f>>' -thumbnail %dx%d '<<fne>>_thumb.<<e>>'" size size) + :utils "convert"))) + + (defun cj/dwim-shell-commands-extract-audio-from-video () + "Extract audio track from video file(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Extract audio" + "ffmpeg -i '<<f>>' -vn -acodec copy '<<fne>>.m4a'" + :utils "ffmpeg")) + + (defun cj/dwim-shell-commands-normalize-audio-volume () + "Normalize audio volume in file(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Normalize audio" + "ffmpeg -i '<<f>>' -af 'loudnorm=I=-16:LRA=11:TP=-1.5' '<<fne>>_normalized.<<e>>'" + :utils "ffmpeg")) + + (defun cj/dwim-shell-commands-remove-zip-encryption () + "Remove password protection from zip file(s)." + (interactive) + (let ((password (read-passwd "Current password: "))) + (dwim-shell-command-on-marked-files + "Remove zip encryption" + (format "TMPDIR=$(mktemp -d) && unzip -P '%s' '<<f>>' -d \"$TMPDIR\" && cd \"$TMPDIR\" && zip -r archive.zip * && mv archive.zip '<<fne>>_decrypted.zip' && rm -rf \"$TMPDIR\"" + password) + :utils '("unzip" "zip")))) + + (defun cj/dwim-shell-commands-create-encrypted-zip () + "Create password-protected zip of file(s)." + (interactive) + (let ((password (read-passwd "Password: "))) + (dwim-shell-command-on-marked-files + "Create encrypted zip" + (format "zip -r -e -P '%s' '<<archive.zip(u)>>' '<<*>>'" password) + :utils "zip"))) + + + (defun cj/dwim-shell-commands-list-archive-contents () + "List contents of archive without extracting." + (interactive) + (dwim-shell-command-on-marked-files + "List archive contents" + "atool --list '<<f>>'" + :utils "atool")) + + (defun cj/dwim-shell-commands-count-words-lines-in-text-file () + "Count words, lines, and characters in text file(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Word count" + "wc -lwc '<<f>>'" + :utils "wc")) + + (defun cj/dwim-shell-commands-make-executable () + "Make file(s) executable." + (interactive) + (dwim-shell-command-on-marked-files + "Make executable" + "chmod +x '<<f>>'" + :silent-success t + :utils "chmod")) + + (defun cj/dwim-shell-commands-secure-delete () + "Securely delete file(s) by overwriting with random data." + (interactive) + (when (yes-or-no-p "This will permanently destroy files. Continue? ") + (dwim-shell-command-on-marked-files + "Secure delete" + "shred -vfz -n 3 '<<f>>'" + :utils "shred"))) + + (defun cj/dwim-shell-commands-sanitize-filename () + "Sanitize filename(s) - remove spaces and special characters." + (interactive) + (dwim-shell-command-on-marked-files + "Sanitize filename" + "NEW_NAME=$(echo '<<b>>' | tr ' ' '_' | tr -cd '[:alnum:]._-'); mv '<<f>>' \"$(dirname '<<f>>')/${NEW_NAME}\"" + :utils '("tr" "mv"))) + + (defun cj/dwim-shell-commands-number-files-sequentially () + "Rename files with sequential numbers." + (interactive) + (let ((prefix (read-string "Prefix (optional): ")) + (start (read-number "Start number: " 1))) + (dwim-shell-command-on-marked-files + "Number files" + (format "mv '<<f>>' '<<d>>/%s<<n>>.<<e>>'" prefix) + :utils "mv"))) + + (defun cj/dwim-shell-commands-git-history () + "Show git history for file(s)." + (interactive) + (dwim-shell-command-on-marked-files + "Git history" + "git log --oneline --follow '<<f>>'" + :utils "git")) + + (defun cj/dwim-shell-commands-encrypt-with-gpg () + "Encrypt file(s) with GPG." + (interactive) + (let ((recipient (read-string "Recipient email (or leave empty for symmetric): "))) + (dwim-shell-command-on-marked-files + "GPG encrypt" + (if (string-empty-p recipient) + "gpg --symmetric --cipher-algo AES256 '<<f>>'" + (format "gpg --encrypt --recipient '%s' '<<f>>'" recipient)) + :utils "gpg"))) + + (defun cj/dwim-shell-commands-decrypt-with-gpg () + "Decrypt GPG file(s)." + (interactive) + (dwim-shell-command-on-marked-files + "GPG decrypt" + "gpg --decrypt '<<f>>' > '<<fne>>'" + :extensions '("gpg" "asc" "pgp") + :utils "gpg")) + + +(defun cj/dwim-shell-commands-markdown-to-html5-and-open () + "Convert markdown file to HTML in specified directory and open it." + (interactive) + (let ((files (dwim-shell-command--files))) + ;; verify it's a markdown file + (unless (and files + (= 1 (length files)) + (string-match-p "\\.\\(md\\|markdown\\|mkd\\|mdown\\)\\'" (car files))) + (user-error "Please place cursor on a single markdown file")) + (let* ((dest-dir (expand-file-name (read-directory-name "Destination directory: " default-directory))) + (base-name (file-name-sans-extension (file-name-nondirectory (car files)))) + (output-file (expand-file-name (concat base-name ".html") dest-dir))) + (dwim-shell-command-on-marked-files + "Convert markdown to HTML" + (format "pandoc --standalone --from=markdown --to=html5 --metadata title='<<fne>>' '<<f>>' -o '%s'" + output-file) + :utils "pandoc" + :on-completion (lambda (&rest args) + (when (file-exists-p output-file) + (cj/xdg-open output-file) + (message "Opened %s" output-file))))))) + + + + (defun cj/dwim-shell-commands-kill-gpg-agent () + "Kill (thus restart) gpg agent. + +Useful for when you get this error: +gpg: public key decryption failed: No pinentry +gpg: decryption failed: No pinentry" + (interactive) + (dwim-shell-command-on-marked-files + "Kill gpg agent" + "gpgconf --kill gpg-agent" + :utils "gpgconf" + :silent-success t))) + +(provide 'dwim-shell-config) +;;; dwim-shell-config.el ends here. diff --git a/modules/elfeed-config.el b/modules/elfeed-config.el new file mode 100644 index 00000000..46520be2 --- /dev/null +++ b/modules/elfeed-config.el @@ -0,0 +1,290 @@ +;;; elfeed-config --- Settings and Enhancements to the Elfeed RSS Feed Reader -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Launch Elfeed with M-R to update feeds and focus the newest entry right away. +;; Inside the search buffer: +;; - Use v to stream via the default player, d to download, w/b to open via EWW or browser. +;; - Hit V to pick a different player for future launches. +;; - Use R/U to mark all visible stories read or unread before narrowing again. +;; +;; When a video needs streaming credentials the player selection drives yt-dlp format choices; +;; use `cj/select-media-player` to swap profiles, or customize `cj/media-players` for your system. +;; All commands assume yt-dlp and your players live on PATH. +;; +;;; Code: + +(require 'user-constants) +(require 'system-utils) +(require 'media-utils) + +;; ------------------------------- Elfeed Config ------------------------------- + +(use-package elfeed + :bind + ("M-R" . cj/elfeed-open) + (:map elfeed-show-mode-map + ("w" . eww-open-in-new-buffer)) + (:map elfeed-search-mode-map + ("w" . cj/elfeed-eww-open) ;; opens in eww + ("b" . cj/elfeed-browser-open) ;; opens in external browser + ("d" . cj/elfeed-youtube-dl) ;; async download with yt-dlp and tsp + ("v" . cj/play-with-video-player)) ;; async play with mpv + ("V" . cj/select-media-player) ;; Capital V to select player + ("R" . cj/elfeed-mark-all-as-read) ;; capital marks all as read, since upper case marks one as read + ("U" . cj/elfeed-mark-all-as-unread) ;; capital marks all as unread, since lower case marks one as unread + :config + (setq elfeed-db-directory (concat user-emacs-directory ".elfeed-db")) + (setq-default elfeed-search-title-max-width 150) + (setq-default elfeed-search-title-min-width 80) + (setq-default elfeed-search-filter "+unread") + (setq elfeed-feeds + '( + ;; The Daily + ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLdMrbgYfVl-s16D_iT2BJCJ90pWtTO1A4" yt nytdaily) + + ;; The Ezra Klein Show + ("https://www.youtube.com/feeds/videos.xml?channel_id=UCnxuOd8obvLLtf5_-YKFbiQ" yt ezra) + + ;; Pivot with Kara Swisher and Scott Galloway + ("https://www.youtube.com/feeds/videos.xml?channel_id=UCBHGZpDF2fsqPIPi0pNyuTg" yt pivot) + + ;; The Prof G Pod + ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLtQ-jBytlXCasRuBG86m22rOQfrEPcctq" yt profg) + + ;; On with Kara Swisher + ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLKof9YSAshgxI6odrEJFKsJbxamwoQBju" yt) + + ;; Raging Moderates + ("https://www.youtube.com/feeds/videos.xml?channel_id=UCcvDWzvxz6Kn1iPQHMl2teA" yt raging-moderates) + + ;; Prof G Markets + ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLtQ-jBytlXCY28ucRF8P1mNMSG8uC06Aw" yt profg-markets) + + ;; Trae Crowder Porch Rants + ("https://www.youtube.com/feeds/videos.xml?playlist_id=PL45Mc1cDgnsB-u1iLPBYNF1fk-y1cVzTJ" yt trae) + + ;; Tropical Tidbits + ("https://www.youtube.com/feeds/videos.xml?channel_id=UCrFIk7g_riIm2G2Vi90pxDA" yt) + + ;; If You're Listening | ABC News In-depth + ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLDTPrMoGHssAfgMMS3L5LpLNFMNp1U_Nq" yt listening) + ))) + +;; ------------------------------ Elfeed Functions ----------------------------- + +(defun cj/elfeed-open () + "Open Elfeed, update all feeds, then move to the first entry." + (interactive) + (elfeed) + (elfeed-update) + (elfeed-search-update--force)) + +;; -------------------------- Elfeed Filter Functions -------------------------- + +(defun cj/elfeed-mark-all-as-read () + "Remove the \='unread\=' tag from all visible entries in search buffer." + (interactive) + (mark-whole-buffer) + (elfeed-search-untag-all-unread)) + +(defun cj/elfeed-mark-all-as-unread () + "Add the \='unread\=' tag from all visible entries in the search buffer." + (interactive) + (mark-whole-buffer) + (elfeed-search-tag-all 'unread)) + +(defun cj/elfeed-set-filter-and-update (filterstring) + "Set the Elfeed filter to FILTERSTRING and update the buffer." + (interactive "sFilter: ") + (setq elfeed-search-filter filterstring) + (elfeed-search-update--force) + (goto-char (point-min))) + +;; ----------------------------- Extract Stream URL ---------------------------- +;; TASK: Is this method reused anywhere here or in another file? + +(defun cj/extract-stream-url (url format) + "Extract the direct stream URL from URL using yt-dlp with FORMAT. +Returns the stream URL or nil on failure." + (unless (executable-find "yt-dlp") + (error "The program yt-dlp is not installed or not in PATH")) + (let* ((format-args (if format + (list "-f" format) + nil)) + (cmd-args (append '("yt-dlp" "-q" "-g") + format-args + (list url))) + ;; DEBUG: Log the command + (_ (cj/log-silently "DEBUG: Extracting with command: %s" + (mapconcat #'shell-quote-argument cmd-args " "))) + (output (with-temp-buffer + (let ((exit-code (apply #'call-process + (car cmd-args) nil t nil + (cdr cmd-args)))) + (if (zerop exit-code) + (string-trim (buffer-string)) + (progn + ;; DEBUG: Log failure + (cj/log-silently "DEBUG: yt-dlp failed with exit code %d" exit-code) + (cj/log-silently "DEBUG: Error output: %s" (buffer-string)) + nil)))))) + ;; DEBUG: Log the result + (cj/log-silently "DEBUG: Extracted URL: %s" + (if output (truncate-string-to-width output 100) "nil")) + (when (and output (string-match-p "^https?://" output)) + output))) + +;; -------------------------- Elfeed Core Processing --------------------------- + +(defun cj/elfeed-process-entries (action-fn action-name &optional skip-error-handling) + "Process selected Elfeed entries with ACTION-FN. +ACTION-NAME is used for error messages. Marks entries as read and +advances to the next line. If SKIP-ERROR-HANDLING is non-nil, errors +are not caught (useful for actions that handle their own errors)." + (let ((entries (elfeed-search-selected))) + (unless entries + (error "No entries selected")) + (cl-loop for entry in entries + do (elfeed-untag entry 'unread) + for link = (elfeed-entry-link entry) + when link + do (if skip-error-handling + (funcall action-fn link) + (condition-case err + (funcall action-fn link) + (error (message "Failed to %s %s: %s" + action-name + (truncate-string-to-width link 50) + (error-message-string err)))))) + (mapc #'elfeed-search-update-entry entries) + (unless (use-region-p) (forward-line)))) + +;; -------------------------- Elfeed Browser Functions ------------------------- + +(defun cj/elfeed-eww-open () + "Opens the links of the currently selected Elfeed entries with EWW. + +Applies cj/eww-readable-nonce hook after EWW rendering." + (interactive) + (cj/elfeed-process-entries + (lambda (link) + (add-hook 'eww-after-render-hook #'cj/eww-readable-nonce) + (eww-browse-url link)) + "open in EWW" + t)) ; skip error handling since eww handles its own errors + +(defun cj/eww-readable-nonce () + "Once-off call to eww-readable after EWW is done rendering." + (unwind-protect + (progn + (eww-readable) + (goto-char (point-min))) + (remove-hook 'eww-after-render-hook #'cj/eww-readable-nonce))) + +(defun cj/elfeed-browser-open () + "Opens the link of the selected Elfeed entries in the default browser." + (interactive) + (cj/elfeed-process-entries + #'browse-url-default-browser + "open in browser" + t)) ; skip error handling since browser handles its own errors + +;; --------------------- Elfeed Play And Download Functions -------------------- + +(defun cj/elfeed-youtube-dl () + "Downloads the selected Elfeed entries' links with youtube-dl." + (interactive) + (cj/elfeed-process-entries #'cj/yt-dl-it "download")) + +(defun cj/play-with-video-player () + "Plays the selected Elfeed entries' links with the configured media player. + +Note: Function name kept for backwards compatibility." + (interactive) + (cj/elfeed-process-entries #'cj/media-play-it + (format "play with %s" + (plist-get (alist-get cj/default-media-player cj/media-players) :name)))) + +;; --------------------- Youtube Url To Elfeed Feed Format --------------------- + +(defun cj/youtube-to-elfeed-feed-format (url type) + "Convert YouTube URL to elfeed-feeds format. + +TYPE should be either \='channel or \='playlist." + (let ((id nil) + (title nil) + (buffer nil) + (id-pattern (if (eq type 'channel) + "href=\"https://www\\.youtube\\.com/feeds/videos\\.xml\\?channel_id=\\([^\"]+\\)\"" + "/playlist\\?list=\\([^&]+\\)")) + (feed-format (if (eq type 'channel) + "https://www.youtube.com/feeds/videos.xml?channel_id=%s" + "https://www.youtube.com/feeds/videos.xml?playlist_id=%s")) + (error-msg (if (eq type 'channel) + "Could not extract channel information" + "Could not extract playlist information"))) + + ;; Extract ID based on type + (if (eq type 'channel) + ;; For channels, we need to fetch the page to get the channel_id + (progn + (setq buffer (url-retrieve-synchronously url)) + (when buffer + (with-current-buffer buffer + ;; Decode the content as UTF-8 + (set-buffer-multibyte t) + (decode-coding-region (point-min) (point-max) 'utf-8) + (goto-char (point-min)) + ;; Search for the channel_id in the RSS feed link + (when (re-search-forward id-pattern nil t) + (setq id (match-string 1)))))) + ;; For playlists, extract from URL first + (when (string-match id-pattern url) + (setq id (match-string 1 url)) + (setq buffer (url-retrieve-synchronously url)))) + + ;; Get title from the page + (when (and buffer id) + (with-current-buffer buffer + (unless (eq type 'channel) + ;; Decode for playlist (already done for channel above) + (set-buffer-multibyte t) + (decode-coding-region (point-min) (point-max) 'utf-8)) + ;; Search for the title in og:title meta tag + (goto-char (point-min)) + (when (re-search-forward "<meta property=\"og:title\" content=\"\\([^\"]+\\)\"" nil t) + (setq title (match-string 1)) + ;; Simple HTML entity decoding + (setq title (replace-regexp-in-string "&" "&" title)) + (setq title (replace-regexp-in-string "<" "<" title)) + (setq title (replace-regexp-in-string ">" ">" title)) + (setq title (replace-regexp-in-string """ "\"" title)) + (setq title (replace-regexp-in-string "'" "'" title)) + (setq title (replace-regexp-in-string "'" "'" title)))) + (kill-buffer buffer)) + + (if (and id title) + (format ";; %s\n(\"%s\" yt)" + title + (format feed-format id)) + (error error-msg)))) + +(defun cj/youtube-channel-to-elfeed-feed-format (url) + "Convert YouTube channel URL to elfeed-feeds format and insert at point." + (interactive "sYouTube Channel URL: ") + (let ((result (cj/youtube-to-elfeed-feed-format url 'channel))) + (when (called-interactively-p 'interactive) + (insert result)) + result)) + +(defun cj/youtube-playlist-to-elfeed-feed-format (url) + "Convert YouTube playlist URL to elfeed-feeds format and insert at point." + (interactive "sYouTube Playlist URL: ") + (let ((result (cj/youtube-to-elfeed-feed-format url 'playlist))) + (when (called-interactively-p 'interactive) + (insert result)) + result)) + +(provide 'elfeed-config) +;;; elfeed-config.el ends here. diff --git a/modules/eradio-config.el b/modules/eradio-config.el new file mode 100644 index 00000000..63085aa8 --- /dev/null +++ b/modules/eradio-config.el @@ -0,0 +1,36 @@ +;;; eradio-config --- Simple Internet Radio Setup -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;;; Code: + +(use-package eradio + :bind + ("C-c r p" . eradio-play) + ("C-c r s" . eradio-stop) + ("C-c r <SPC>" . eradio-toggle) + :config + (setq eradio-player '("mpv" "--no-video" "--no-terminal")) + (setq eradio-channels + '(("BAGeL Radio (alternative)" . "https://ais-sa3.cdnstream1.com/2606_128.mp3") + ("Blues Music Fan Radio (blues)" . "http://ais-sa2.cdnstream1.com/1992_128.mp3") + ("Blues Radio (blues)" . "http://cast3.radiohost.ovh:8352/") + ("Concertzender Baroque (classical)" . "http://streams.greenhost.nl:8080/barok") + ("Groove Salad (somafm)" . "https://somafm.com/groovesalad130.pls") + ("Indie Pop Rocks (somafm)" . "https://somafm.com/indiepop130.pls") + ("KDFC Classical (classical)" . "http://128.mp3.pls.kdfc.live/") + ("Radio Caprice Classical Lute (classical)" . "http://79.120.12.130:8000/lute") + ("Radio Swiss Classic German (classical)" . "http://stream.srg-ssr.ch/m/rsc_de/mp3_128") + ("Radio Caprice Acoustic Blues (blues)" . "http://79.111.14.76:8000/acousticblues") + ("Radio Caprice Delta Blues (blues)" . "http://79.120.77.11:8002/deltablues") + ("Seven Inch Soul (somafm)" . "https://somafm.com/nossl/7soul.pls") + ("Space Station Soma (somafm)" . "https://somafm.com/spacestation.pls") + ("Suburbs of Goa (somafm)" . "https://somafm.com/suburbsofgoa.pls") + ("Sunday Baroque (classical)" . "http://wshu.streamguys.org/wshu-baroque") + ("Underground 80s (somafm)" . "https://somafm.com/u80s256.pls") + ("Venice Classic Radio (classical)" . "https://www.veniceclassicradio.eu/live1/128.m3u") + ("WWOZ New Orleans (jazz/blues)" . "https://www.wwoz.org/listen/hi")))) + +(provide 'eradio-config) +;;; eradio-config.el ends here diff --git a/modules/erc-config.el b/modules/erc-config.el new file mode 100644 index 00000000..03c89aca --- /dev/null +++ b/modules/erc-config.el @@ -0,0 +1,317 @@ +;;; erc-config --- Preferences for Emacs Relay Chat (IRC Client) -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; Enhanced ERC configuration with multi-server support. +;; +;; Main keybindings: +;; - C-c e C : Select and connect to a specific server +;; - C-c e c : Join a channel on current server +;; - C-c e b : Switch between ERC buffers across all servers +;; - C-c C-q : Quit current channel +;; - C-c C-Q : Quit ERC altogether + +;;; Code: + +;; Keymap for ERC commands +(defvar cj/erc-command-map + (let ((map (make-sparse-keymap))) + (define-key map "C" 'cj/erc-connect-server-with-completion) ;; Connect to server (capital C) + (define-key map "c" 'cj/erc-join-channel-with-completion) ;; join channel (lowercase c) + (define-key map "b" 'cj/erc-switch-to-buffer-with-completion) ;; switch Buffer + (define-key map "l" 'cj/erc-connected-servers) ;; print connected servers in echo area + (define-key map "q" 'erc-part-from-channel) ;; quit channel + (define-key map "Q" 'erc-quit-server) ;; Quit ERC entirely + map) + "Keymap for ERC-related commands.") + +(global-set-key (kbd "C-c e") cj/erc-command-map) + +;; ------------------------------------ ERC ------------------------------------ +;; Server definitions and connection settings + +(defvar cj/erc-server-alist + '(("Libera.Chat" + :host "irc.libera.chat" + :port 6697 + :tls t + :channels ("#erc" "#emacs" "#emacs-social" "#systemcrafters")) + + ("IRCnet" + :host "open.ircnet.net" + :port 6697 + :tls t + :channels ("#english")) + + ("Snoonet" + :host "irc.snoonet.org" + :port 6697 + :tls t + :channels ("#talk")) + + ("IRCNow" + :host "irc.ircnow.org" + :port 6697 + :tls t + :channels ("#general" "#lounge"))) + "Alist of IRC servers and their connection details.") + +(defun cj/erc-connect-server (server-name) + "Connect to a server specified by SERVER-NAME from =cj/erc-server-alist=." + (let* ((server-info (assoc server-name cj/erc-server-alist)) + (host (plist-get (cdr server-info) :host)) + (port (plist-get (cdr server-info) :port)) + (tls (plist-get (cdr server-info) :tls))) + (if tls + (erc-tls :server host + :port port + :nick "craigjennings" + :full-name user-whole-name) + (erc :server host + :port port + :nick "craigjennings" + :full-name user-whole-name)))) + +(defun cj/erc-connect-server-with-completion () + "Connect to a server using completion for server selection." + (interactive) + (let ((server-name (completing-read "Connect to IRC server: " + (mapcar #'car cj/erc-server-alist)))) + (cj/erc-connect-server server-name))) + +(defun cj/erc-connected-servers () + "Return a list of currently connected servers and display them in echo area." + (interactive) + (let ((server-buffers '())) + (dolist (buf (erc-buffer-list)) + (with-current-buffer buf + (when (eq (buffer-local-value 'erc-server-process buf) erc-server-process) + (unless (member (buffer-name) server-buffers) + (push (buffer-name) server-buffers))))) + + ;; Display the server list when called interactively + (when (called-interactively-p 'any) + (if server-buffers + (message "Connected ERC servers: %s" + (mapconcat 'identity server-buffers ", ")) + (message "No active ERC server connections"))) + + server-buffers)) + +(defun cj/erc-switch-to-buffer-with-completion () + "Switch to an ERC buffer using completion." + (interactive) + (let* ((erc-buffers (mapcar 'buffer-name (erc-buffer-list))) + (selected (completing-read "Switch to buffer: " erc-buffers))) + (switch-to-buffer selected))) + +(defun cj/erc-server-buffer-active-p () + "Return t if the current buffer is an active ERC server buffer." + (and (derived-mode-p 'erc-mode) + (erc-server-process-alive) + (erc-server-buffer-p))) + +(defun cj/erc-join-channel-with-completion () + "Join a channel on the current server. + +If not in an active ERC server buffer, reconnect first." + (interactive) + (unless (cj/erc-server-buffer-active-p) + (if (erc-buffer-list) + ;; We have ERC buffers, but current one isn't active + (let ((server-buffers (cl-remove-if-not + (lambda (buf) + (with-current-buffer buf + (and (erc-server-buffer-p) + (erc-server-process-alive)))) + (erc-buffer-list)))) + (if server-buffers + ;; Found active server buffer, switch to it + (switch-to-buffer (car server-buffers)) + ;; No active server buffer, reconnect + (message "No active ERC connection. Reconnecting...") + (call-interactively 'cj/erc-connect-server-with-completion))) + ;; No ERC buffers at all, connect to a server + (message "No active ERC connection. Connecting to server first...") + (call-interactively 'cj/erc-connect-server-with-completion))) + + ;; At this point we should have an active connection + (if (cj/erc-server-buffer-active-p) + (let ((channel (read-string "Join channel: "))) + (when (string-prefix-p "#" channel) + (erc-join-channel channel))) + (message "Failed to establish an active ERC connection"))) + + +;; Main ERC configuration +(use-package erc + :defer 1 + :ensure nil ;; built-in + :commands (erc erc-tls) + :hook + (erc-mode . emojify-mode) + :custom + (erc-modules + '(autojoin + button + completion + fill + irccontrols + list + log + match + move-to-prompt + noncommands + notifications + readonly + services + stamp + track)) ;; Added track module + + (erc-autojoin-channels-alist + (mapcar (lambda (server) + (cons (car server) + (plist-get (cdr server) :channels))) + cj/erc-server-alist)) + + (erc-nick "craigjennings") + (erc-user-full-name user-whole-name) + (erc-use-auth-source-for-nickserv-password t) + (erc-kill-buffer-on-part t) + (erc-kill-queries-on-quit t) + (erc-kill-server-buffer-on-quit t) + (erc-fill-column 120) + (erc-fill-function 'erc-fill-static) + (erc-fill-static-center 20) + + :config + + + ;; use all text mode abbrevs in ercmode + (abbrev-table-put erc-mode-abbrev-table :parents (list text-mode-abbrev-table)) + + ;; create log directory if it doesn't exist + (setq erc-log-channels-directory (concat user-emacs-directory "erc/logs/")) + (if (not (file-exists-p erc-log-channels-directory)) + (mkdir erc-log-channels-directory t)) + + ;; Configure buffer naming to include server name + (setq erc-rename-buffers t) + (setq erc-unique-buffers t) + + ;; Custom buffer naming function + (defun cj/erc-generate-buffer-name (parms) + "Generate buffer name in the format SERVER-CHANNEL." + (let ((network (plist-get parms :server)) + (target (plist-get parms :target))) + (if target + (concat (or network "") "-" (or target "")) + (or network "")))) + + (setq erc-generate-buffer-name-function 'cj/erc-generate-buffer-name) + + ;; Configure erc-track (show channel activity in modeline) + (setq erc-track-exclude-types '("JOIN" "NICK" "PART" "QUIT" "MODE" + "324" "329" "332" "333" "353" "477") + erc-track-exclude-server-buffer t + erc-track-visibility 'selected-visible + erc-track-switch-direction 'importance + erc-track-showcount t)) + +;; -------------------------------- ERC Track --------------------------------- +;; Better tracking of activity across channels (already included in modules above) + +(use-package erc-track + :ensure nil ;; built-in + :after erc + :custom + (erc-track-position-in-mode-line 'before-modes) + (erc-track-shorten-function 'erc-track-shorten-names) + (erc-track-shorten-cutoff 8) + (erc-track-shorten-start 1) + (erc-track-priority-faces-only 'all) + (erc-track-faces-priority-list + '(erc-error-face + erc-current-nick-face + erc-keyword-face + erc-nick-msg-face + erc-direct-msg-face + erc-notice-face + erc-prompt-face))) + +;; ------------------------ ERC Desktop Notifications ------------------------ +;; Implementation for desktop notifications + +(defun cj/erc-notify-on-mention (match-type nick message) + "Display a notification when MATCH-TYPE is 'current-nick. + +NICK is the sender and MESSAGE is the message text." + (when (and (eq match-type 'current-nick) + (not (string= nick (erc-current-nick))) + (display-graphic-p)) + (let ((title (format "ERC: %s mentioned you" nick))) + ;; Use alert.el if available, otherwise fall back to notifications + (if (fboundp 'alert) + (alert message :title title :category 'erc) + (when (fboundp 'notifications-notify) + (notifications-notify + :title title + :body message + :app-name "Emacs ERC" + :sound-name 'message)))))) + +(add-hook 'erc-text-matched-hook 'cj/erc-notify-on-mention) + +;; ------------------------------ ERC Colorize ------------------------------- +;; Better color management with built-in functionality + +(defun cj/erc-colorize-setup () + "Setup ERC colorization for nicknames." + (make-local-variable 'erc-nick-color-alist) + (setq erc-nick-color-alist + (cl-loop for i from 0 to 15 + for color in '("blue" "green" "red" "brown" "purple" + "olive" "dark cyan" "light gray" "dark gray" + "light blue" "light green" "light red" + "light brown" "light purple" "yellow" "white") + collect (cons i color))) + (setq erc-nick-color-function 'erc-get-color-for-nick)) + +(add-hook 'erc-mode-hook 'cj/erc-colorize-setup) + +;; -------------------------------- ERC Image --------------------------------- +;; show inlined images (png/jpg/gif/svg) in erc buffers. + +(use-package erc-image + :defer 1 + :after erc + :config + (setq erc-image-inline-rescale 300) + (add-to-list 'erc-modules 'image) + (erc-update-modules)) + +;; -------------------------------- ERC Hl Nicks ------------------------------- +;; uniquely identify names in ERC + +(use-package erc-hl-nicks + :defer 1 + :after erc + :config + (add-to-list 'erc-modules 'hl-nicks) + (erc-update-modules)) + +;; ------------------------------ ERC Yank To Gist ----------------------------- +;; automatically create a Gist if pasting more than 5 lines +;; this module requires https://github.com/defunkt/gist +;; via ruby: 'gem install gist' via the aur: yay -S gist + +(use-package erc-yank + :defer 1 + :after erc + :bind + (:map erc-mode-map + ("C-y" . erc-yank))) + +(provide 'erc-config) +;;; erc-config.el ends here diff --git a/modules/eshell-vterm-config.el b/modules/eshell-vterm-config.el new file mode 100644 index 00000000..ba0eba9f --- /dev/null +++ b/modules/eshell-vterm-config.el @@ -0,0 +1,229 @@ +;;; eshell-vterm-config --- Settings for the Emacs Shell -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; ESHELL +;; - Eshell is useful as a REPL +;; - Redirect to the kill ring : ls > /dev/kill +;; - Redirect to the clioboard : ls > /dev/clip +;; - Redirect to a buffer : ls > #<ls-output> +;; - Use elisp functions : write your own "detox" command in elisp +;; : then use it in eshell +;; - cd to remote directories : cd /sshx:c@cjennings.net:/home/cjennings +;; : and take all the elisp functionality remotely +;; : including Dired or Magit on a remote server + +;; VTERM +;; At the moment, vterm behaves like a real terminal. For most keys, vterm will +;; just send them to the process that is currently running. So, C-a may be +;; beginning-of-the-line in a shell, or the prefix key in a screen session. + +;; If you enter vterm-copy-mode C-c C-t or <pause>, the buffer will become a normal +;; Emacs buffer. You can then use your navigation keys, select rectangles, etc. +;; When you press RET, the region will be copied and you'll be back in a working +;; terminal session. + +;; ANSI-TERM & TERM +;; I haven't yet found a need for term or ansi-term in my workflows, so I leave +;; them with their default configurations. + +;;; Code: + +(require 'system-utils) + +;; ------------------------------ Eshell ----------------------------- +;; the Emacs shell. + +(use-package eshell + :ensure nil ;; built-in + :defer .5 + :config + (setq eshell-banner-message "") + (setq eshell-scroll-to-bottom-on-input 'all) + (setq eshell-error-if-no-glob t) + (setq eshell-hist-ignoredups t) + (setq eshell-save-history-on-exit t) + (setq eshell-prefer-lisp-functions nil) + (setq eshell-destroy-buffer-when-process-dies t) + + (setq eshell-prompt-function + (lambda () + (concat + (propertize (format-time-string "[%d-%m-%y %T]") 'face '(:foreground "gray")) + " " + (propertize (user-login-name) 'face '(:foreground "gray")) + " " + (propertize (system-name) 'face '(:foreground "gray")) + ":" + (propertize (abbreviate-file-name (eshell/pwd)) 'face '(:foreground "gray")) + "\n" + (propertize "%" 'face '(:foreground "white")) + " "))) + + (add-hook + 'eshell-mode-hook + (lambda () + (setq pcomplete-cycle-completions nil))) + (setq eshell-cmpl-cycle-completions nil) + + (add-to-list 'eshell-modules-list 'eshell-tramp) + + (add-hook 'eshell-hist-mode-hook + (lambda () + (define-key eshell-hist-mode-map (kbd "<up>") 'previous-line) + (define-key eshell-hist-mode-map (kbd "<down>") 'next-line))) + + (add-hook 'eshell-mode-hook + (lambda () + (add-to-list 'eshell-visual-commands '("lf" "ranger" "tail" "htop" "gotop" "mc" "ncdu" "top")) + (add-to-list 'eshell-visual-subcommands '("git" "log" "diff" "show")) + (add-to-list 'eshell-visual-options '("git" "--help" "--paginate")) + + ;; aliases + (eshell/alias "clear" "clear 1") ;; leaves prompt at the top of the window + (eshell/alias "e" "find-file $1") + (eshell/alias "em" "find-file $1") + (eshell/alias "emacs" "find-file $1") + (eshell/alias "open" "cj/xdg-open $1") + (eshell/alias "gocj" "cd /sshx:cjennings@cjennings.net:/var/cjennings/") + (eshell/alias "gosb" "cd /sshx:cjennings@wolf.usbx.me:/home/cjennings/") + (eshell/alias "gowolf" "cd /sshx:cjennings@wolf.usbx.me:/home/cjennings/") + (eshell/alias "v" "eshell-exec-visual $*") + (eshell/alias "ff" "find-file-other-window $1") + (eshell/alias "f" "find-using-dired $1") + (eshell/alias "r" "ranger") + (eshell/alias "ll" "ls -laF")))) + +(defun eshell/find-file-other-window (&rest files) + "Open FILE(s) in other window from eshell." + (if (= 1 (length files)) + ;; Single file - just use it directly + (find-file-other-window (car files)) + ;; Multiple files - open each in other window + (dolist (file files) + (find-file-other-window file)))) + +(defun eshell/find-file (&rest files) + "Open FILE(s) from eshell." + (if (= 1 (length files)) + ;; Single file + (find-file (car files)) + ;; Multiple files + (dolist (file files) + (find-file file)))) + +(defun eshell/find-using-dired (file-pattern) + "Find a file FILE-PATTERN' using 'find-name-dired'." + (let ((escaped-pattern (regexp-quote file-pattern))) + (find-name-dired . escaped-pattern))) + +(defun cj/eshell-delete-window-on-exit () + "Close the eshell window when exiting." + (when (not (one-window-p)) + (delete-window))) +(advice-add 'eshell-life-is-too-much :after 'cj/eshell-delete-window-on-exit) + +(use-package eshell-toggle + :after eshell + :custom + (eshell-toggle-size-fraction 3) + (eshell-toggle-run-command nil) + (eshell-toggle-init-function #'eshell-toggle-init-eshell) + :bind + ("<f12>" . eshell-toggle)) + +(use-package xterm-color + :defer .5 + :after eshell + :hook + (eshell-before-prompt-hook . (lambda () + (setq xterm-color-preserve-properties t))) + :config + (setenv "TERM" "xterm-256color")) + +(use-package eshell-syntax-highlighting + :after esh-mode + :config + (eshell-syntax-highlighting-global-mode +1)) + +(use-package eshell-up + :after eshell + :config + (defalias 'eshell/up 'eshell-up) + (defalias 'eshell/up-peek 'eshell-up-peek)) + +;; Enhance history searching +(defun cj/eshell-history-search () + "Search eshell history with completion." + (interactive) + (insert + (completing-read "Eshell history: " + (delete-dups + (ring-elements eshell-history-ring))))) + +(add-hook 'eshell-mode-hook + (lambda () + (define-key eshell-mode-map (kbd "C-r") 'cj/eshell-history-search))) + +;; Better completion for eshell +(use-package pcmpl-args + :after eshell) + +;; Company mode integration for eshell +(use-package company-shell + :after (eshell company) + :config + (add-to-list 'company-backends 'company-shell) + (add-hook 'eshell-mode-hook + (lambda () + (setq-local company-minimum-prefix-length 2) + (setq-local company-idle-delay 2) + (company-mode 1)))) + + +;; ------------------------------ Vterm ------------------------------ +;; faster and highly dependable, but not extensible + +(use-package vterm + :defer .5 + :commands (vterm vterm-other-window) + :init + (setq vterm-always-compile-module t) + + (defun cj/turn-off-chrome-for-vterm () + (hl-line-mode -1) + (display-line-numbers-mode -1)) + + :hook (vterm-mode . cj/turn-off-chrome-for-vterm) + :bind + (:map vterm-mode-map + ("<f12>" . nil) + ("C-y" . vterm-yank) + ("C-p" . vtermf-copy-mode) + ("<pause>" . vterm-copy-mode)) + :custom + (vterm-kill-buffer-on-exit t) + (vterm-max-scrollback 100000) + :config + (setq vterm-timer-delay nil)) + +(use-package vterm-toggle + :defer .5 + :bind + ("C-<f12>" . vterm-toggle) + :config + (setq vterm-toggle-fullscreen-p nil) + (add-to-list 'display-buffer-alist + '((lambda (buffer-or-name _) + (let ((buffer (get-buffer buffer-or-name))) + (with-current-buffer buffer + (or (equal major-mode 'vterm-mode) + (string-prefix-p vterm-buffer-name (buffer-name buffer)))))) + (display-buffer-reuse-window display-buffer-at-bottom) + (dedicated . t) ;dedicated is supported in Emacs 27+ + (reusable-frames . visible) + (window-height . 0.25)))) + +(provide 'eshell-vterm-config) +;;; eshell-vterm-config.el ends here. diff --git a/modules/eww-config.el b/modules/eww-config.el new file mode 100644 index 00000000..7d990f3a --- /dev/null +++ b/modules/eww-config.el @@ -0,0 +1,153 @@ +;;; eww-config --- EWW Text Browser Settings -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; +;; This module provides a minimal, privacy-focused browsing experience with: +;; - Simplified navigation keybindings (< and > for back/forward) +;; - Quick URL copying to clipboard +;; - Image toggle functionality +;; - Privacy-conscious defaults (no tracking info sent) +;; - Alternative search engine (Frog Find for simplified web pages) +;; +;; Key features: +;; - `M-E' to launch EWW +;; - `u' to copy current URL +;; - `i' to toggle images +;; - `o' to open link in new buffer +;; +;; The configuration tries to prioritize text-based browsing and minimal distractions. +;; +;;; Code: + +;; ----------------------- EWW-Only User-Agent Injection ----------------------- + +(require 'cl-lib) + +(defgroup my-eww-user-agent nil + "EWW-only User-Agent management." + :group 'eww) + +(defcustom my-eww-user-agent + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0" + "User-Agent string to send for EWW requests only." + :type 'string + :group 'my-eww-user-agent) + +(defun my-eww--inject-user-agent (orig-fun &rest args) + "Set a User-Agent only when making requests from an EWW buffer." + (if (derived-mode-p 'eww-mode) + (let* ((ua my-eww-user-agent) + ;; Remove any existing UA header, then add ours. + (url-request-extra-headers + (cons (cons "User-Agent" ua) + (cl-remove-if (lambda (h) + (and (consp h) + (stringp (car h)) + (string-equal (car h) "User-Agent"))) + url-request-extra-headers)))) + (apply orig-fun args)) + (apply orig-fun args))) + +(with-eval-after-load 'url + ;; Cover both async and sync fetches used by eww/shr (pages, images, etc.). + (advice-add 'url-retrieve :around #'my-eww--inject-user-agent) + (advice-add 'url-retrieve-synchronously :around #'my-eww--inject-user-agent)) + +;; --------------------------------- EWW Config -------------------------------- + +(use-package eww + :ensure nil ;; built-in + :bind + (("M-E" . eww) + :map eww-mode-map + ("<" . eww-back-url) + (">" . eww-forward-url) + ("i" . eww-toggle-images) + ("u" . cj/eww-copy-url) + ("b" . cj/eww-bookmark-quick-add) + ("B" . eww-list-bookmarks) + ("/" . cj/eww-switch-search-engine) + ("&" . cj/eww-open-in-external) + ("o" . eww-open-in-new-buffer) + ("r" . eww-readable)) + + :config + ;; Define search engines + (defvar cj/eww-search-engines + '(("frog" . "http://frogfind.com/?q=") + ("ddg" . "https://duckduckgo.com/html?q=") + ("searx" . "https://searx.be/search?q=")) + "List of search engines for EWW.") + + (defvar cj/eww-current-search-engine "frog" + "Currently selected search engine.") + + ;; Function definitions + (defun cj/eww-switch-search-engine () + "Switch between different search engines." + (interactive) + (let* ((engine (completing-read "Search engine: " + (mapcar #'car cj/eww-search-engines) + nil t nil nil cj/eww-current-search-engine)) + (url (cdr (assoc engine cj/eww-search-engines)))) + (when url + (setq eww-search-prefix url) + (setq cj/eww-current-search-engine engine) + (message "Search engine set to: %s" engine)))) + + (defun cj/eww-open-in-external () + "Open current URL in external browser." + (interactive) + (unless (derived-mode-p 'eww-mode) + (user-error "Not in EWW buffer")) + (if-let ((url (plist-get eww-data :url))) + (browse-url-xdg-open url) + (user-error "No URL to open"))) + + (defun cj/eww-bookmark-quick-add () + "Quickly bookmark current page with minimal prompting." + (interactive) + (unless (derived-mode-p 'eww-mode) + (user-error "Not in EWW buffer")) + (when-let ((title (plist-get eww-data :title))) + (let ((eww-bookmarks-directory (expand-file-name "eww-bookmarks" user-emacs-directory))) + (unless (file-exists-p eww-bookmarks-directory) + (make-directory eww-bookmarks-directory t)) + (eww-add-bookmark) + (message "Bookmarked: %s" title)))) + + (defun cj/eww-copy-url () + "Copy the current EWW URL to clipboard." + (interactive) + (unless (derived-mode-p 'eww-mode) + (user-error "Not in EWW buffer")) + (if-let ((current-url (plist-get eww-data :url))) + (progn + (kill-new current-url) + (message "URL copied: %s" current-url)) + (message "No URL to copy"))) + + (defun cj/eww-clear-cookies () + "Clear all cookies." + (interactive) + (setq url-cookie-storage nil) + (when (and url-cookie-file (file-exists-p url-cookie-file)) + (delete-file url-cookie-file)) + (message "Cookies cleared")) + + ;; Configuration settings + (setq shr-use-colors nil) + (setq shr-bullet "• ") + (setq eww-search-prefix (cdr (assoc cj/eww-current-search-engine cj/eww-search-engines))) + (setq url-cookie-file (expand-file-name "~/.local/share/cookies.txt")) + ;; sets the user-agent for everything (e.g., package.el) + ;;(setq url-user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0") + (setq url-privacy-level '(email lastloc)) + (setq shr-inhibit-images t) + (setq shr-use-fonts nil) + (setq shr-max-image-proportion 0.2) + (setq eww-retrieve-command nil)) + +(provide 'eww-config) +;;; eww-config.el ends here diff --git a/modules/external-open.el b/modules/external-open.el new file mode 100644 index 00000000..0fe6be64 --- /dev/null +++ b/modules/external-open.el @@ -0,0 +1,129 @@ +;;; external-open.el --- Open Files Using Default OS Handler -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; +;; This library provides a simple mechanism for opening files with specific +;; extensions using your operating system’s default application rather than +;; visiting them in an Emacs buffer. It offers: +;; +;; • A simple method to run a command on the current buffer's file +;; "C-c x o" bound to cj/open-this-file-with +;; • A customizable list =default-open-extensions= of file‐type suffixes +;; (e.g. “pdf”, “docx”, “png”) that should be handled externally. +;; • A function =default-open-file= (and its helper commands) which will +;; launch the matching file in the OS’s default MIME handler. +;; • Integration with =find-file-hook= so that any file whose extension +;; appears in =default-open-extensions= is automatically opened externally +;; upon visit. +;; • Optional interactive commands for manually invoking an external open on +;; point or on a user-chosen file. +;; +;;; Code: + +(require 'system-utils) ;; for xdg-open and others +(require 'host-environment) ;; environment information functions +(require 'cl-lib) + +(defgroup external-open nil + "Open certain files with the OS default handler." + :group 'files) + +(defcustom default-open-extensions + '( + ;; Video + "\\.3g2\\'" "\\.3gp\\'" "\\.asf\\'" "\\.avi\\'" "\\.divx\\'" "\\.dv\\'" + "\\.f4v\\'" "\\.flv\\'" "\\.m1v\\'" "\\.m2ts\\'" "\\.m2v\\'" "\\.m4v\\'" + "\\.mkv\\'" "\\.mov\\'" "\\.mpe\\'" "\\.mpeg\\'" "\\.mpg\\'" "\\.mp4\\'" + "\\.mts\\'" "\\.ogv\\'" "\\.rm\\'" "\\.rmvb\\'" "\\.ts\\'" "\\.vob\\'" + "\\.webm\\'" "\\.wmv\\'" + + ;; Audio + "\\.aac\\'" "\\.ac3\\'" "\\.aif\\'" "\\.aifc\\'" "\\.aiff\\'" + "\\.alac\\'" "\\.amr\\'" "\\.ape\\'" "\\.caf\\'" + "\\.dff\\'" "\\.dsf\\'" "\\.flac\\'" "\\.m4a\\'" "\\.mka\\'" + "\\.mid\\'" "\\.midi\\'" "\\.mp2\\'" "\\.mp3\\'" "\\.oga\\'" + "\\.ogg\\'" "\\.opus\\'" "\\.ra\\'" "\\.spx\\'" "\\.wav\\'" + "\\.wave\\'" "\\.weba\\'" "\\.wma\\'" + + ;; Microsoft Word + "\\.docx?\\'" "\\.docm\\'" + "\\.dotx?\\'" "\\.dotm\\'" + "\\.rtf\\'" + + ;; Microsoft Excel + "\\.xlsx?\\'" "\\.xlsm\\'" "\\.xlsb\\'" + "\\.xltx?\\'" "\\.xltm\\'" + + ;; Microsoft PowerPoint + "\\.pptx?\\'" "\\.pptm\\'" + "\\.ppsx?\\'" "\\.ppsm\\'" + "\\.potx?\\'" "\\.potm\\'" + + ;; Microsoft OneNote / Visio / Project / Access / Publisher + "\\.one\\'" "\\.onepkg\\'" "\\.onetoc2\\'" + "\\.vsdx?\\'" "\\.vsdm\\'" "\\.vstx?\\'" "\\.vstm\\'" "\\.vssx?\\'" "\\.vssm\\'" + "\\.mpp\\'" "\\.mpt\\'" + "\\.mdb\\'" "\\.accdb\\'" "\\.accde\\'" "\\.accdr\\'" "\\.accdt\\'" + "\\.pub\\'" + + ;; OpenDocument (LibreOffice/OpenOffice) + "\\.odt\\'" "\\.ott\\'" + "\\.ods\\'" "\\.ots\\'" + "\\.odp\\'" "\\.otp\\'" + "\\.odg\\'" "\\.otg\\'" + "\\.odm\\'" "\\.odf\\'" + ;; Flat OpenDocument variants + "\\.fodt\\'" "\\.fods\\'" "\\.fodp\\'" + + ;; Apple iWork + "\\.pages\\'" "\\.numbers\\'" "\\.key\\'" + + ;; Microsoft’s fixed-layout formats + "\\.xps\\'" "\\.oxps\\'" + ) + "Regexps matching file extensions that should be opened externally." + :type '(repeat (regexp :tag "File extension regexp")) + :group 'external-open) + +;; ------------------------------- Open File With ------------------------------ +;; TASK: Add this to buffer custom functions + +(defun cj/open-this-file-with (command) + "Open this buffer's file with COMMAND, detached from Emacs." + (interactive "MOpen with program: ") + (unless buffer-file-name + (user-error "Current buffer is not visiting a file")) + (let ((file (expand-file-name buffer-file-name))) + (cond + ;; Windows: launch via ShellExecute so the child isn't tied to Emacs. + ((env-windows-p) + (w32-shell-execute "open" command (format "\"%s\"" file))) + ;; POSIX: disown with nohup + background. No child remains. + (t + (call-process-shell-command + (format "nohup %s %s >/dev/null 2>&1 &" + command (shell-quote-argument file)) + nil 0))))) + +(global-set-key (kbd "C-c x o") #'cj/open-this-file-with) + +;; -------------------- Open Files With Default File Handler ------------------- + +(defun cj/find-file-auto (orig-fun &rest args) + "If file has an extension in `default-open-extensions', open externally. +Else call ORIG-FUN with ARGS." + (let* ((file (car args)) + (case-fold-search t)) + (if (and (stringp file) + (cl-some (lambda (re) (string-match-p re file)) + default-open-extensions)) + (cj/xdg-open file) + (apply orig-fun args)))) + +;; Make advice idempotent if you reevaluate this form. +(advice-remove 'find-file #'cj/find-file-auto) +(advice-add 'find-file :around #'cj/find-file-auto) + +(provide 'external-open) +;;; external-open.el ends here. diff --git a/modules/flycheck-config.el b/modules/flycheck-config.el new file mode 100644 index 00000000..f14d94ba --- /dev/null +++ b/modules/flycheck-config.el @@ -0,0 +1,99 @@ +;;; flycheck-config --- Syntax/Grammar Check -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; This file configures Flycheck for on-demand syntax and grammar checking. +;; - Flycheck starts automatically only in sh-mode and emacs-lisp-mode + +;; - This binds a custom helper (=cj/flycheck-list-errors=) to “C-; ?” +;; for popping up Flycheck's error list in another window. + +;; - It also customizes Checkdoc to suppress only the “sentence-end-double-space” +;; and “warn-escape” warnings. + +;; - It registers a Proselint checker for prose files +;; (text-mode, markdown-mode, gfm-mode). + +;; Note: I do use proselint quite a bit in emails and org-mode files. However, some +;; org-files can be large and running proselint on them will slow Emacs to a crawl. +;; Therefore, hitting "C-; ?" also runs cj/flycheck-prose-on-demand if in an org buffer. + +;; +;; The cj/flycheck-prose-on-demand function: +;; - Turns on flycheck for the local buffer +;; - ensures proselint is added +;; - triggers an immediate check +;; +;; Since this is called within cj/flycheck-list-errors, flycheck's error list will still +;; display and the focus transferred to that buffer. + +;; OS Dependencies: +;; proselint (in the Arch AUR) + +;;; Code: + +(defun cj/prose-helpers-on () + "Ensure that abbrev, flyspell, and flycheck are all on." + (interactive) + (if (not (abbrev-mode)) + (abbrev-mode)) + ;; (flyspell-on-for-buffer-type) + (if (not (flycheck-mode)) + (flycheck-mode))) + +;; ---------------------------------- Linting ---------------------------------- + +(use-package flycheck + :after custom-functions ;; for keymap binding + :defer t + :commands (flycheck-list-errors + cj/flycheck-list-errors) + :hook ((sh-mode emacs-lisp-mode) . flycheck-mode) + :bind + (:map cj/custom-keymap + ("?" . cj/flycheck-list-errors)) + :custom + ;; Only disable these two Checkdoc warnings; leave all others intact. + (checkdoc-arguments + '(("sentence-end-double-space" nil) + ("warn-escape" nil))) + :config + + ;; use the load-path of the currently running Emacs instance + (setq flycheck-emacs-lisp-load-path 'inherit) + + ;; Define the prose checker (installed separately via OS). + (flycheck-define-checker proselint + "A linter for prose." + :command ("proselint" source-inplace) + :error-patterns + ((warning line-start (file-name) ":" line ":" column ": " + (id (one-or-more (not (any " ")))) + (message) line-end)) + :modes (text-mode markdown-mode gfm-mode org-mode)) + (add-to-list 'flycheck-checkers 'proselint) + + (defun cj/flycheck-list-errors () + "Display flycheck's error list and switch to its buffer. + +Runs flycheck-prose-on-demand if in an org-buffer." + (interactive) + (when (derived-mode-p 'org-mode) + (cj/flycheck-prose-on-demand)) + + (flycheck-list-errors) + (switch-to-buffer-other-window "*Flycheck errors*")) + + (defun cj/flycheck-prose-on-demand () + "Enable Flycheck+Proselint in this buffer, run it, and show errors." + (interactive) + ;; turn on Flycheck locally + (flycheck-mode 1) + ;; ensure proselint is valid for org/text + (flycheck-add-mode 'proselint major-mode) + ;; trigger immediate check + (flycheck-buffer))) + +(provide 'flycheck-config) +;;; flycheck-config.el ends here diff --git a/modules/flyspell-and-abbrev.el b/modules/flyspell-and-abbrev.el new file mode 100644 index 00000000..08b96036 --- /dev/null +++ b/modules/flyspell-and-abbrev.el @@ -0,0 +1,211 @@ +;;; flyspell-and-abbrev.el --- Spell Check Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; WORKFLOW: +;; C-' is now my main interface for all spell checking. +;; +;; The workflow is that it finds the nearest misspelled word above where the +;; cursor is, allows for saving or correcting, then stops. You may proceed to +;; the next misspelling by selecting C-' again. +;; +;; Use M-o to get to 'other options', like saving to your personal dictionary. +;; +;; Flyspell will automatically run in a mode appropriate for the buffer type +;; - if it's a programming mode, it will only check comments +;; - if in text mode, it will check everything +;; - otherwise it will turn off. +;; This check happens on every mode switch. +;; +;; If you want flyspell on in another mode (say fundamental mode), or you want +;; to turn it off, you can toggle flyspell's state with 'C-c f' +;; +;; The nicest thing is that each spell correction creates an abbrev. This +;; essentially is a shortcut that expands that same misspelling to the correct +;; spelling the next time it's typed. That idea comes courtesy Artur Malabarba, +;; and it's increased my overall typing speed. +;; +;; Original idea here: +;; http://endlessparentheses.com/ispell-and-abbrev-the-perfect-auto-correct.html +;; +;; The code below is my refactoring of Artur Malabarba's code, and using +;; flyspell rather than ispell. +;; +;; NOTES: +;; +;; FYI, the keybinding typically taken for the flyspell-mode-map "C-;" has +;; been deliberately hijacked in custom-functions.el for my personal-keymap. +;; This is the code run there: + +;; (eval-after-load "flyspell" +;; '(define-key flyspell-mode-map (kbd "C-;") nil)) + +;;; Code: + +;; ----------------------------------- Abbrev ---------------------------------- + +(use-package abbrev-mode + :ensure nil + :defer 0.5 + :custom + (abbrev-file-name (concat user-emacs-directory "assets/abbrev_defs")) + :config + (abbrev-mode 1)) + +;; ---------------------------- Ispell And Flyspell ---------------------------- + +(use-package ispell + :defer .5 + :ensure nil ;; built-in + :config + ;; (setopt ispell-alternate-dictionary + ;; (concat user-emacs-directory "assets/english-words.txt")) + (setopt text-mode-ispell-word-completion nil) + (setopt ispell-alternate-dictionary nil) + + (setq ispell-dictionary "american") ; better for aspell + ;; use aspell rather than ispell + (setq ispell-program-name "aspell") + ;; aspell is in /usr/local/ on BSD + (cond ((eq system-type 'berkeley-unix) + (setq ispell-program-name "/usr/local/bin/aspell"))) + + ;; in aspell "-l" means --list, not --lang + (setq ispell-list-command "--list") + (setq ispell-extra-args '("--sug-mode=ultra" "-W" "3" "--lang=en_US")) + (setq ispell-local-dictionary "en_US") + (setq ispell-local-dictionary-alist + '(("en_US" "[[:alpha:]]" "[^[:alpha:]]" "['‘’]" + t ;; Many other characters + ("-d" "en_US") nil utf-8))) + ;; personal directory goes with sync'd files + (setq ispell-personal-dictionary + (concat sync-dir "aspell-personal-dictionary")) + ;; skip code blocks in org mode + (add-to-list 'ispell-skip-region-alist '("^#+BEGIN_SRC" . "^#+END_SRC"))) + +(use-package flyspell + :after (ispell abbrev) + :ensure nil ;; built-in + :config + ;; don't print message for every word when checking + (setq flyspell-issue-message-flag nil)) + +(use-package flyspell-correct + :after flyspell + :defer .5) + +;; ------------------------------ Flyspell Toggle ------------------------------ +;; easy toggling flyspell and also leverage the 'for-buffer-type' functionality. + +;; (defun flyspell-toggle () +;; "Turn Flyspell on if it is off, or off if it is on. + +;; When turning on,it uses `flyspell-on-for-buffer-type' so code-vs-text is +;; handled appropriately." +;; (interactive) +;; (if (symbol-value flyspell-mode) +;; (progn ; flyspell is on, turn it off +;; (message "Flyspell off") +;; (flyspell-mode -1)) +;; ;; else - flyspell is off, turn it on +;; (progn +;; (flyspell-on-for-buffer-type) +;; (message "Flyspell on")))) +;; (define-key global-map (kbd "C-c f") 'flyspell-toggle ) + +;; ------------------------ Flyspell On For Buffer Type ------------------------ +;; check strings and comments in prog mode; check everything in text mode + +;; (defun flyspell-on-for-buffer-type () +;; "Enable Flyspell for the major mode and check the current buffer. + +;; If flyspell is already enabled, do nothing. If the mode is derived from +;; `prog-mode', enable `flyspell-prog-mode' so only strings and comments get +;; checked. If the buffer is text based `flyspell-mode' is enabled to check +;; all text." +;; (interactive) +;; (unless flyspell-mode ; if not already on +;; (cond +;; ((derived-mode-p 'prog-mode) +;; (flyspell-prog-mode) +;; (flyspell-buffer) +;; ((derived-mode-p 'text-mode) +;; (flyspell-mode 1) +;; (flyspell-buffer)))))) + +;; (add-hook 'after-change-major-mode-hook 'flyspell-on-for-buffer-type) +;; (add-hook 'find-file-hook 'flyspell-on-for-buffer-type) + +;; ---------------------------- Flyspell Then Abbrev --------------------------- +;; Spell check the buffer and create abbrevs to avoid future misspellings. + +(setq-default abbrev-mode t) + +(defun cj/find-previous-flyspell-overlay (position) + "Locate the Flyspell overlay immediately previous to a given POSITION." + ;; sort the overlays into position order + (let ((overlay-list (sort (overlays-in (point-min) position) + (lambda (a b) + (> (overlay-start a) (overlay-start b)))))) + ;; search for previous flyspell overlay + (while (and overlay-list + (or (not (flyspell-overlay-p (car overlay-list))) + ;; check if its face has changed + (not (eq (get-char-property + (overlay-start (car overlay-list)) 'face) + 'flyspell-incorrect)))) + (setq overlay-list (cdr overlay-list))) + ;; if no previous overlay exists, return nil + (when overlay-list + ;; otherwise, return the overlay start position + (overlay-start (car overlay-list))))) + + +(defun cj/flyspell-goto-previous-misspelling (position) + "Go to the first misspelled word before the given POSITION. +Return the misspelled word if found or nil if not. Leave the point at the +beginning of the misspelled word. Setting the hook on pre-command ensures that +any started Flyspell corrections complete before running other commands in the +buffer." + (interactive "d") + (add-hook 'pre-command-hook + (function flyspell-auto-correct-previous-hook) t t) + (let* ((overlay-position (cj/find-previous-flyspell-overlay position)) + (misspelled-word (when overlay-position + (goto-char overlay-position) + (thing-at-point 'word)))) + (if misspelled-word + (downcase misspelled-word) + nil))) + +(defun cj/flyspell-then-abbrev (p) + "Call \='flyspell-correct-at-point\=' and create abbrev for future corrections. +The abbrev is created in the local dictionary unless the prefix P +argument is provided, when it's created in the global dictionary." + (interactive "P") + (unless (featurep 'files) + (require 'files)) + (setq save-abbrevs 'silently) + (flyspell-buffer) + (save-excursion + (let (misspelled-word corrected-word) + (while (setq misspelled-word + (cj/flyspell-goto-previous-misspelling (point))) + (call-interactively 'flyspell-correct-at-point) + (setq corrected-word (downcase (or (thing-at-point 'word) ""))) + (when (and misspelled-word corrected-word + (not (string= corrected-word misspelled-word))) + (message "\"%s\" now expands to \"%s\" %sally" + misspelled-word corrected-word (if p "loc" "glob")) + (define-abbrev + (if p local-abbrev-table global-abbrev-table) + misspelled-word corrected-word)) + (goto-char (point-min)))) + (message "Spell check complete."))) + +(define-key global-map (kbd "C-'") 'cj/flyspell-then-abbrev) + +(provide 'flyspell-and-abbrev) +;;; flyspell-and-abbrev.el ends here. diff --git a/modules/font-config.el b/modules/font-config.el new file mode 100644 index 00000000..ac67d6e0 --- /dev/null +++ b/modules/font-config.el @@ -0,0 +1,283 @@ +;;; font-config --- Font Defaults and Related Functionality -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; This module provides font configuration, including: +;; +;; 1. Font Management: +;; - Dynamic font preset switching via `fontaine' package +;; - Separate configurations for fixed-pitch and variable-pitch fonts +;; - Multiple size presets for different viewing contexts +;; - Per-frame font configuration tracking for daemon mode compatibility +;; +;; 2. Icon Support: +;; - All-the-icons integration with automatic font installation +;; - Nerd fonts support for enhanced icons in terminals and GUI +;; - Platform-specific emoji font configuration (Noto, Apple, Segoe) +;; - Emojify package for emoji rendering and insertion +;; +;; 3. Typography Enhancements: +;; - Programming ligatures via `ligature' package +;; - Mode-specific ligature rules for markdown and programming +;; - Text scaling keybindings for quick size adjustments +;; +;; 4. Utility Functions: +;; - `cj/font-installed-p': Check font availability +;; - `cj/display-available-fonts': Interactive font browser with samples +;; - Frame-aware font application for client/server setups +;; +;; Configuration Notes: +;; - Default font: FiraCode Nerd Font Mono at 110 height +;; - Variable pitch: Merriweather Light for prose-heavy modes +;; - Handles both standalone and daemon mode Emacs instances +;; - Emoji fonts selected based on OS availability +;; +;; Keybindings: +;; - M-F: Select font preset +;; - C-z F: Display available fonts +;; - C-+/C-=: Increase text scale +;; - C--/C-_: Decrease text scale +;; +;; +;;; Code: + +;; ----------------------- Font Family And Size Selection ---------------------- +;; preset your fixed and variable fonts, then apply them to text as a set + +(use-package fontaine + :demand t + :bind + ("M-F" . fontaine-set-preset) + :config + (setq fontaine-presets + '( + (default + :default-family "FiraCode Nerd Font Mono" + :default-weight regular + :default-height 110 + :fixed-pitch-family nil ;; falls back to :default-family + :fixed-pitch-weight nil ;; falls back to :default-weight + :fixed-pitch-height 1.0 + :variable-pitch-family "Merriweather" + :variable-pitch-weight light + :variable-pitch-height 1.0) + (Hack + :default-family "Hack Nerd Font Mono" + :variable-pitch-family "Hack Nerd Font Mono") + (FiraCode-Literata + :default-family "Fira Code Nerd Font" + :variable-pitch-family "Literata") + (Merriweather + :default-family "Merriweather" + :variable-pitch-family "Merriweather") + (24-point-font + :default-height 240) + (20-point-font + :default-height 200) + (16-point-font + :default-height 160) + (14-point-font + :default-height 140) + (13-point-font + :default-height 130) + (12-point-font + :default-height 120) + (11-point-font + :default-height 110) + (10-point-font + :default-height 100) + (t ;; shared fallback properties go here + :default-family "FiraCode Nerd Font Mono" + :default-weight regular + :default-height 110 + :fixed-pitch-family nil ;; falls back to :default-family + :fixed-pitch-weight nil ;; falls back to :default-weight + :fixed-pitch-height 1.0 + :fixed-pitch-serif-family nil ;; falls back to :default-family + :fixed-pitch-serif-weight nil ;; falls back to :default-weight + :fixed-pitch-serif-height 1.0 + :variable-pitch-family "Merriweather" + :variable-pitch-weight light + :variable-pitch-height 1.0 + :bold-family nil ;; use whatever the underlying face has + :bold-weight bold + :italic-family nil + :italic-slant italic + :line-spacing nil)))) + +(with-eval-after-load 'fontaine + ;; Track which frames have had fonts applied + (defvar cj/fontaine-configured-frames nil + "List of frames that have had fontaine configuration applied.") + + (defun cj/apply-font-settings-to-frame (&optional frame) + "Apply font settings to FRAME if not already configured. + +If FRAME is nil, uses the selected frame." + (let ((target-frame (or frame (selected-frame)))) + (unless (member target-frame cj/fontaine-configured-frames) + (with-selected-frame target-frame + (when (display-graphic-p target-frame) + (fontaine-set-preset 'default) + (push target-frame cj/fontaine-configured-frames)))))) + + (defun cj/cleanup-frame-list (frame) + "Remove FRAME from the configured frames list when deleted." + (setq cj/fontaine-configured-frames + (delq frame cj/fontaine-configured-frames))) + + ;; Handle daemon mode and regular mode + (if (daemonp) + (progn + ;; Apply to each new frame in daemon mode + (add-hook 'server-after-make-frame-hook #'cj/apply-font-settings-to-frame) + ;; Clean up deleted frames from tracking list + (add-hook 'delete-frame-functions #'cj/cleanup-frame-list)) + ;; Apply immediately in non-daemon mode + (when (display-graphic-p) + (cj/apply-font-settings-to-frame)))) + +;; ----------------------------- Font Install Check ---------------------------- +;; convenience function to indicate whether a font is available by name. + +;;;###autoload +(defun cj/font-installed-p (font-name) + "Check if font with FONT-NAME is available." + (if (find-font (font-spec :name font-name)) + t + nil)) + +;; ------------------------------- All The Icons ------------------------------- +;; icons made available through fonts + +(use-package all-the-icons + :demand t + :config + ;; Check for font installation after frame creation + (defun cj/maybe-install-all-the-icons-fonts (&optional _frame) + "Install all-the-icons fonts if needed and we have a GUI." + (when (and (display-graphic-p) + (not (cj/font-installed-p "all-the-icons"))) + (all-the-icons-install-fonts t) + ;; Remove this hook after successful installation + (remove-hook 'server-after-make-frame-hook #'cj/maybe-install-all-the-icons-fonts))) + + ;; Handle both daemon and non-daemon modes + (if (daemonp) + (add-hook 'server-after-make-frame-hook #'cj/maybe-install-all-the-icons-fonts) + (cj/maybe-install-all-the-icons-fonts))) + +(use-package all-the-icons-nerd-fonts + :after all-the-icons + :demand t + :config + (all-the-icons-nerd-fonts-prefer)) + +;; ----------------------------- Emoji Fonts Per OS ---------------------------- +;; Set emoji fonts in priority order (first found wins) + +(cond + ;; Prefer Noto Color Emoji (Linux) + ((member "Noto Color Emoji" (font-family-list)) + (set-fontset-font t 'symbol (font-spec :family "Noto Color Emoji") nil 'prepend)) + ;; Then Apple Color Emoji (macOS) + ((member "Apple Color Emoji" (font-family-list)) + (set-fontset-font t 'symbol (font-spec :family "Apple Color Emoji") nil 'prepend)) + ;; Finally Segoe UI Emoji (Windows) + ((member "Segoe UI Emoji" (font-family-list)) + (set-fontset-font t 'symbol (font-spec :family "Segoe UI Emoji") nil 'prepend))) + +;; ---------------------------------- Emojify ---------------------------------- +;; converts emoji identifiers into emojis; allows for easy emoji entry. + +(use-package emojify + :defer 1 + :hook ((erc-mode . emojify-mode) + (org-mode . emojify-mode)) + :custom + (emojify-download-emojis-p t) ;; don't ask, just download emojis + :bind + ("C-c E i" . emojify-insert-emoji) ;; emoji insert + ("C-c E l" . emojify-list-emojis) ;; emoji list + :config + (setq emojify-show-help nil) + (setq emojify-point-entered-behaviour 'uncover) + (setq emojify-display-style 'image) + (setq emojify-emoji-styles '(ascii unicode github)) + + ;; Disable emojify in programming and gptel modes + (defun cj/disable-emojify-mode () + "Disable emojify-mode in the current buffer." + (emojify-mode -1)) + + (add-hook 'prog-mode-hook #'cj/disable-emojify-mode) + (add-hook 'gptel-mode-hook #'cj/disable-emojify-mode)) + +;; -------------------------- Display Available Fonts -------------------------- +;; display all available fonts on the system in a side panel + +;;;###autoload +(defun cj/display-available-fonts () + "Display a list of all font faces with sample text in another read-only buffer." + (interactive) + (pop-to-buffer "*Available Fonts*" + '(display-buffer-in-side-window . ((side . right)(window-width . fit-window-to-buffer)))) + (let ((font-list (font-family-list))) + (setq font-list (cl-remove-duplicates (cl-sort font-list 'string-lessp :key 'downcase))) + (with-current-buffer "*Available Fonts*" + (erase-buffer) + (dolist (font-family font-list) + (insert (propertize (concat font-family) 'face `((:foreground "Light Blue" :weight bold)))) + (insert (concat "\n"(propertize "Regular: "))) + (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?") + 'face `((:family, font-family)))) + (insert (concat "\n" (propertize "Bold: "))) + (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?") + 'face `((:family, font-family :weight bold)))) + (insert (concat "\n" (propertize "Italic: "))) + (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?") + 'face `((:family, font-family :slant italic)))) + (insert (concat "\n\n")))) + (move-to-window-line 0) + (special-mode))) + +(global-set-key (kbd "C-z F") 'cj/display-available-fonts) + +;; ----------------------- Increase / Decrease Font Size ----------------------- +;; make it easy to enlarge or shrink font sizes with keybindings + +(setq text-scale-mode-step 1.08) +(global-set-key (kbd "C-+") 'text-scale-increase) +(global-set-key (kbd "C-=") 'text-scale-increase) +(global-set-key (kbd "C-_") 'text-scale-decrease) +(global-set-key (kbd "C--") 'text-scale-decrease) + +;; --------------------------------- Ligatures --------------------------------- +;; fancy programming glyphs make code easier to read + +(use-package ligature + :defer 1 + :config + ;; Enable the www ligature in every possible major mode + (ligature-set-ligatures 't '("www")) + ;; Enable traditional ligature support in eww, if `variable-pitch' face supports it + (ligature-set-ligatures 'eww-mode '("ff" "fi" "ffi")) + ;; Enable ligatures in markdown mode + (ligature-set-ligatures 'markdown-mode '(("=" (rx (+ "=") (? (| ">" "<")))) + ("-" (rx (+ "-"))))) + ;; Enable ligatures in programming modes + (ligature-set-ligatures 'prog-mode '("www" "**" "***" "**/" "*>" "*/" "\\\\" "\\\\\\" "{-" "::" + ":::" ":=" "!!" "!=" "!==" "-}" "----" "-->" "->" "->>" + "-<" "-<<" "-~" "#{" "#[" "##" "###" "####" "#(" "#?" "#_" + "#_(" ".-" ".=" ".." "..<" "..." "?=" "??" ";;" "/*" "/**" + "/=" "/==" "/>" "//" "///" "&&" "||" "||=" "|=" "|>" "^=" "$>" + "++" "+++" "+>" "=:=" "==" "===" "==>" "=>" "=>>" "<=" + "=<<" "=/=" ">-" ">=" ">=>" ">>" ">>-" ">>=" ">>>" "<*" + "<*>" "<|" "<|>" "<$" "<$>" "<!--" "<-" "<--" "<->" "<+" + "<+>" "<=" "<==" "<=>" "<=<" "<>" "<<" "<<-" "<<=" "<<<" + "<~" "<~~" "</" "</>" "~@" "~-" "~>" "~~" "~~>" "%%")) + (global-ligature-mode t)) + +(provide 'font-config) +;;; font-config.el ends here diff --git a/modules/games-config.el b/modules/games-config.el new file mode 100644 index 00000000..7e2bebd6 --- /dev/null +++ b/modules/games-config.el @@ -0,0 +1,60 @@ +;;; games-config.el --- emacs games -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; The games menu is the easiest entry. "Shift-Alt-G" will get you there. Enjoy! +;; + +;;; Code: + + +;; ----------------------------------- Malyon ---------------------------------- +;; text based adventure player + +(use-package malyon + :defer 1 + :config + (setq malyon-stories-directory (concat sync-dir "text.games/"))) + +;; ------------------------------------ 2048 ----------------------------------- +;; combine numbered tiles to create the elusive number 2048. +(use-package 2048-game + :defer 1) + +;; ----------------------------------- Chess ----------------------------------- +;; play the 64 squares and checkmate the opponent's king +;; (use-package chess +;; :defer 1 +;; :config +;; (setq chess-default-display 'chess-images) +;; (setq chess-images-directory +;; (concat user-emacs-directory "assets/chess/pieces/xboard/")) +;; (setq chess-images-dark-color "#779556") +;; (setq chess-images-light-color "#EBECD0") +;; (setq chess-images-default-size 100) +;; (setq chess-full-name user-whole-name) +;; (setq chess-default-engine 'chess-fruit)) + + +;; Notes from source code +;; If you'd like to view or edit Portable Game Notation (PGN) files, +;; `chess-pgn-mode' provides a text-mode derived mode which can display the +;; chess position at point. + +;; To improve your chess ability, `M-x chess-tutorial' provides a simple knight +;; movement exercise to get you started, and `M-x chess-puzzle' can be used +;; to solve puzzle collections in EPD or PGN format. +;; The variable `chess-default-display' controls which display modules +;; are tried when a chessboard should be displayed. By default, chess-images +;; is tried first. If Emacs is not running in a graphical environment, +;; chess-ics1 is used instead. To enable the chess-plain display module, +;; customize `chess-default-display' accordingly. + +;; Once this is working, the next thing to do is to customize +;; `chess-default-modules'. This is a list of functionality modules used +;; by chess.el to provide additional functionality. You can enable or +;; disable modules so that Emacs Chess better suites your tastes. +;; Those modules in turn often have configuration variables, and + +(provide 'games-config) +;;; games-config.el ends here. diff --git a/modules/help-config.el b/modules/help-config.el new file mode 100644 index 00000000..6ef9b434 --- /dev/null +++ b/modules/help-config.el @@ -0,0 +1,108 @@ +;;; help-config --- Help Functionality Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; This module enhances Emacs' built-in help system and documentation features. +;; It configures: +;; +;; 1. Helpful - A better help buffer that provides context, examples, and source code +;; 2. Man - Man page viewing integration +;; 3. Info - Enhanced Info mode with custom keybindings and directory configuration +;; +;; The configuration prioritizes discoverability and improves the experience of +;; reading documentation within Emacs. Custom keybindings maintain the C-h prefix +;; convention for help-related commands. + +;;; Code: + + +(setq help-window-select t) ;; Always select the help buffer in a separate window + +(global-set-key (kbd "C-h P") 'list-packages) ;; bring up the package menu + +;; ---------------------------------- Helpful ---------------------------------- + +(use-package helpful + :if (version< emacs-version "30") + :defer .5 + :bind + ("C-h f" . helpful-callable) + ("C-h v" . helpful-variable) + ("C-h k" . helpful-key) + ("C-h F" . helpful-function) + ("C-h C" . helpful-command) + ("C-h ." . helpful-at-point) + ("C-h o" . helpful-symbol)) ;; overrides 'describe-symbol' keybinding + +;; ------------------------------------ Man ------------------------------------ + +(use-package man + :defer 1 + :ensure nil ;; built-in + :bind ("C-h M" . man)) + +;; ------------------------------------ Info ----------------------------------- + + (defun cj/open-with-info-mode () + "Open the current buffer's file in Info mode if it's a valid info file. + +Preserves any unsaved changes and checks if the file exists." + (interactive) + (let ((file-name (buffer-file-name))) + (when file-name + (if (and (file-exists-p file-name) + (string-match-p "\\.info\\'" file-name)) + (progn + (when (buffer-modified-p) + (if (y-or-n-p "Buffer has unsaved changes. Save before opening in Info? ") + (save-buffer) + (message "Operation canceled") + (cl-return-from cj/open-with-info-mode))) + (kill-buffer (current-buffer)) + (info file-name)) + (message "Not a valid info file: %s" file-name))))) + +(defun cj/browse-info-files () + "Browse and open .info or .info.gz files from user-emacs-directory." + (interactive) + (let* ((info-files (directory-files-recursively + user-emacs-directory + "\\.info\\(\\.gz\\)?$")) + (files-alist (mapcar (lambda (f) + (cons (file-name-nondirectory f) f)) + info-files)) + (chosen-name (completing-read + "Select Info file: " + (mapcar #'car files-alist) + nil t)) + (chosen-file (cdr (assoc chosen-name files-alist)))) + (when chosen-file + (info chosen-file)))) + +(global-unset-key (kbd "C-h i")) +(global-set-key (kbd "C-h i") #'cj/browse-info-files) + + +(use-package info + :ensure nil ;; built-in + :bind + (:map Info-mode-map + ("m" . bookmark-set) ;; Rebind 'm' from Info-menu to bookmark-set + ("M" . Info-menu)) ;; Move Info-menu to 'M' instead + :preface + :init + ;; Add personal info files BEFORE Info mode initializes + ;; (let ((personal-info-dir (expand-file-name "assets/info" user-emacs-directory))) + ;; (when (file-directory-p personal-info-dir) + ;; (setq Info-directory-list (list personal-info-dir)))) + ;; the above makes the directory the info list. the below adds it to the default list + ;; (add-to-list 'Info-default-directory-list personal-info-dir))) + :hook + (info-mode . info-persist-history-mode) + :config + ;; Make .info files open with our custom function + (add-to-list 'auto-mode-alist '("\\.info\\'" . cj/open-with-info-mode))) + +(provide 'help-config) +;;; help-config.el ends here. diff --git a/modules/help-utils.el b/modules/help-utils.el new file mode 100644 index 00000000..dcb7a748 --- /dev/null +++ b/modules/help-utils.el @@ -0,0 +1,77 @@ +;;; help-utils --- Help Integrations and Searches -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; +;; This module provides various utilities for accessing documentation and help resources +;; directly within Emacs. +;; DevDocs for programming documentation +;; TLDR command line cheat sheets +;; Wikipedia pages through the `wiki-summary` package. +;; ArchWiki pages are also browsable via EWW. +;; +;;; Keybindings: +;; - C-h D s: Search DevDocs for documentation. +;; - C-h D b: Peruse DevDocs browsing through documentation. +;; - C-h D l: Lookup specific documentation in DevDocs. +;; - C-h D i: Install documentation for a chosen library in DevDocs. +;; - C-h D d: Delete documentation from DevDocs. +;; - C-h D u: Update all installed DevDocs documentation. +;; - C-h T: Access TLDR (cheat sheets for command-line tools). +;; - C-h W: Summarize wiki pages with the wiki-summary package. +;; - C-h A: Search and browse local Arch Wiki topics in EWW. +;; +;;; Code: + +;; ---------------------------------- Devdocs ---------------------------------- + +(use-package devdocs + :defer 1 + :config + (global-set-key (kbd "C-h D s") 'devdocs-search) + (global-set-key (kbd "C-h D b") 'devdocs-peruse) + (global-set-key (kbd "C-h D l") 'devdocs-lookup) + (global-set-key (kbd "C-h D i") 'devdocs-install) + (global-set-key (kbd "C-h D d") 'devdocs-delete) + (global-set-key (kbd "C-h D u") 'devdocs-update-all) + (define-key devdocs-mode-map "b" 'devdocs-go-back) + (define-key devdocs-mode-map "f" 'devdocs-go-forward)) + +;; ------------------------------------ TLDR ----------------------------------- + +(use-package tldr + :defer 1 + :bind ("C-h T" . tldr)) + +;; -------------------------------- Wiki Summary ------------------------------- + +(use-package wiki-summary + :defer 1 + :bind ("C-h W" . wiki-summary)) + +;; --------------------------- Browse Local Arch Wiki -------------------------- +;; on Arch: yay (or whatever your AUR package manager is) -S arch-wiki-docs +;; browse the arch wiki topics offline + +(defun cj/local-arch-wiki-search () + "Prompt for an ArchWiki topic and open its local HTML copy in EWW. + +Looks for “*.html” files under \"/usr/share/doc/arch-wiki/html/en\", +lets you complete on their basenames, and displays the chosen file +with `eww-browse-url'. If no file is found, reminds you to install +arch-wiki-docs." + (interactive) + (let* ((dir "/usr/share/doc/arch-wiki/html/en") + (full-filenames (directory-files dir t "\\.html\\'")) + (basenames (mapcar 'file-name-base full-filenames)) + (chosen (completing-read "Choose an ArchWiki Topic: " basenames))) + (if (member chosen basenames) + (let* ((idx (cl-position chosen basenames :test 'equal)) + (fullname (nth idx full-filenames)) + (url (concat "file://" fullname))) + (eww-browse-url url)) + (message "File not found! Is arch-wiki-docs installed?")))) +(global-set-key (kbd "C-h A") 'cj/local-arch-wiki-search) + +(provide 'help-utils) +;;; help-utils.el ends here diff --git a/modules/host-environment.el b/modules/host-environment.el new file mode 100644 index 00000000..98caff28 --- /dev/null +++ b/modules/host-environment.el @@ -0,0 +1,116 @@ +;;; host-environment.el --- Host Environment Convenience Functions -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; Convenience functions to report about the host environment + +;;; Code: + +(require 'battery) + +(defun env-laptop-p () + "Return t if host is a laptop (i.e., has a battery), nil if not." + (when (and battery-status-function + (not (string-match-p "N/A" + (battery-format "%B" + (funcall battery-status-function))))) + t)) + +(defun env-desktop-p () + "Return t if host is a laptop (has a battery), nil if not." + (when (not (env-laptop-p)) + t)) + +(defun env-linux-p () + "Return t if host system is GNU/Linux." + (eq system-type "gnu/linux")) + +(defun env-bsd-p () + "Return t if host system is FreeBSD." + (eq system-type 'berkeley-unix)) + +(defun env-macos-p () + "Return t if host system is Mac OS (darwin-based)." + (eq system-type "darwin")) + +(defun env-windows-p () + "Return t if host system is Windows." + (memq system-type '(cygwin windows-nt ms-dos))) + +(defun env-x-p () + "Return t if host system is running the X Window System." + (string= (window-system) "x")) + +(defun env-terminal-p () + "Return t if running in a terminal." + (not (display-graphic-p))) + +(defun env-gui-p () + "Return t if running in graphical environment." + (display-graphic-p)) + +;; ------------------------------- Timezone Info ------------------------------- + +(defun cj/match-localtime-to-zoneinfo () + "Detect system timezone by comparing /etc/localtime with zoneinfo files. +This replicates the shell command: +find /usr/share/zoneinfo -type f ! -name 'posixrules' \\ + -exec cmp -s {} /etc/localtime \\; -print | sed -e 's@.*/zoneinfo/@@' | head -n1" + (when (and (file-exists-p "/etc/localtime") + (file-directory-p "/usr/share/zoneinfo")) + (let ((localtime-content + (with-temp-buffer + (insert-file-contents-literally "/etc/localtime") + (buffer-string))) + (result nil)) + (catch 'found + (dolist (file (directory-files-recursively + "/usr/share/zoneinfo" + ".*" + nil + (lambda (name) + (not (string-match-p "posixrules" name))))) + (when (and (file-regular-p file) + (not (string-match-p "/posixrules$" file))) + (let ((file-content + (with-temp-buffer + (insert-file-contents-literally file) + (buffer-string)))) + (when (string= localtime-content file-content) + (setq result (replace-regexp-in-string + "^.*/zoneinfo/" "" file)) + (throw 'found result))))) + result)))) + +(defun cj/detect-system-timezone () + "Detect the system timezone in IANA format (e.g., 'America/Los_Angeles'). +Tries multiple methods in order of reliability: +1. Environment variable TZ +2. File comparison of /etc/localtime with zoneinfo database +3. /etc/timezone file contents +4. /etc/localtime symlink target" + (or + ;; Compare file contents (reliable on Arch/modern systems) + (cj/match-localtime-to-zoneinfo) + + ;; Environment variable (most explicit if set) + (getenv "TZ") + + ;; Read /etc/timezone (Debian/Ubuntu) + (when (file-exists-p "/etc/timezone") + (with-temp-buffer + (insert-file-contents "/etc/timezone") + (string-trim (buffer-string)))) + + ;; Method 4: Parse symlink (fallback for older systems) + (when (file-symlink-p "/etc/localtime") + (let ((target (file-truename "/etc/localtime"))) + (when (string-match ".*/zoneinfo/\\(.+\\)" target) + (match-string 1 target)))) + + ;; Default to nil - let org-gcal use its default + nil)) + + +(provide 'host-environment) +;;; host-environment.el ends here. diff --git a/modules/httpd-config.el b/modules/httpd-config.el new file mode 100644 index 00000000..ac1f7a0f --- /dev/null +++ b/modules/httpd-config.el @@ -0,0 +1,26 @@ +;;; httpd-config --- Setup for a Simple HTTP Server -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;;; Code: + + +;;;; -------------------------- Simple-Httpd ------------------------- + +(use-package simple-httpd + :defer 1 + :preface + (defconst wwwdir (concat user-emacs-directory "www")) + (defun check-or-create-wwwdir () + (unless (file-exists-p wwwdir) + (make-directory wwwdir))) + :init (check-or-create-wwwdir) + :config + (setq httpd-root wwwdir) + (setq httpd-show-backtrace-when-error t) + (setq httpd-serve-files t)) + + +(provide 'httpd-config) +;;; httpd-config.el ends here diff --git a/modules/jumper.el b/modules/jumper.el new file mode 100644 index 00000000..787a4973 --- /dev/null +++ b/modules/jumper.el @@ -0,0 +1,177 @@ +;;; jumper.el --- Quick jump between locations using registers -*- lexical-binding: t -*- + +;; Author: Craig Jennings +;; Version: 0.1 +;; Package-Requires: ((emacs "25.1")) +;; Keywords: convenience +;; URL: https://github.com/cjennings/jumper + +;;; Commentary: + +;; Jumper provides a simple way to store and jump between locations +;; in your codebase without needing to remember register assignments. + +;;; Code: + +(defgroup jumper nil + "Quick navigation between stored locations." + :group 'convenience) + +(defcustom jumper-prefix-key "M-SPC" + "Prefix key for jumper commands. + +Note that using M-SPC will override the default binding to just-one-space." + :type 'string + :group 'jumper) + +(defcustom jumper-max-locations 10 + "Maximum number of locations to store." + :type 'integer + :group 'jumper) + +;; Internal variables +(defvar jumper--registers (make-vector jumper-max-locations nil) + "Vector of used registers.") + +(defvar jumper--next-index 0 + "Next available index in the jumper--registers vector.") + +(defvar jumper--last-location-register ?z + "Register used to store the last location.") + +(defun jumper--location-key () + "Generate a key to identify the current location." + (format "%s:%d:%d" + (or (buffer-file-name) (buffer-name)) + (line-number-at-pos) + (current-column))) + +(defun jumper--location-exists-p () + "Check if current location is already stored." + (let ((key (jumper--location-key)) + (found nil)) + (dotimes (i + jumper--next-index found) + (let* ((reg (aref jumper--registers i)) + (pos (get-register reg)) + (marker (and pos (registerv-data pos)))) + (when marker + (save-current-buffer + (set-buffer (marker-buffer marker)) + (save-excursion + (goto-char marker) + (when (string= key (jumper--location-key)) + (setq found t))))))))) + +(defun jumper--register-available-p () + "Check if there are registers available." + (< jumper--next-index jumper-max-locations)) + +(defun jumper--format-location (index) + "Format location at INDEX for display." + (let* ((reg (aref jumper--registers index)) + (pos (get-register reg)) + (marker (and pos (registerv-data pos)))) + (when marker + (save-current-buffer + (set-buffer (marker-buffer marker)) + (save-excursion + (goto-char marker) + (format "[%d] %s:%d - %s" + index + (buffer-name) + (line-number-at-pos) + (buffer-substring-no-properties + (line-beginning-position) + (min (+ (line-beginning-position) 40) + (line-end-position))))))))) + +(defun jumper-store-location () + "Store current location in the next free register." + (interactive) + (if (jumper--location-exists-p) + (message "Location already stored") + (if (jumper--register-available-p) + (let ((reg (+ ?0 jumper--next-index))) + (point-to-register reg) + (aset jumper--registers jumper--next-index reg) + (setq jumper--next-index (1+ jumper--next-index)) + (message "Location stored in register %c" reg)) + (message "Sorry - all jump locations are filled!")))) + +(defun jumper-jump-to-location () + "Jump to a stored location." + (interactive) + (if (= jumper--next-index 0) + (message "No locations stored") + (if (= jumper--next-index 1) + ;; Special case for one location - toggle behavior + (let ((reg (aref jumper--registers 0))) + (if (jumper--location-exists-p) + (message "You're already at the stored location") + (point-to-register jumper--last-location-register) + (jump-to-register reg) + (message "Jumped to location"))) + ;; Multiple locations - use completing-read + (let* ((locations + (cl-loop for i from 0 below jumper--next-index + for fmt = (jumper--format-location i) + when fmt collect (cons fmt i))) + ;; Add last location if available + (last-pos (get-register jumper--last-location-register)) + (locations (if last-pos + (cons (cons "[z] Last location" -1) locations) + locations)) + (choice (completing-read "Jump to: " locations nil t)) + (idx (cdr (assoc choice locations)))) + (point-to-register jumper--last-location-register) + (if (= idx -1) + (jump-to-register jumper--last-location-register) + (jump-to-register (aref jumper--registers idx))) + (message "Jumped to location"))))) + +(defun jumper--reorder-registers (removed-idx) + "Reorder registers after removing the one at REMOVED-IDX." + (when (< removed-idx (1- jumper--next-index)) + ;; Shift all higher registers down + (cl-loop for i from removed-idx below (1- jumper--next-index) + do (let ((next-reg (aref jumper--registers (1+ i)))) + (aset jumper--registers i next-reg)))) + (setq jumper--next-index (1- jumper--next-index))) + +(defun jumper-remove-location () + "Remove a stored location." + (interactive) + (if (= jumper--next-index 0) + (message "No locations stored") + (let* ((locations + (cl-loop for i from 0 below jumper--next-index + for fmt = (jumper--format-location i) + when fmt collect (cons fmt i))) + (locations (cons (cons "Cancel" -1) locations)) + (choice (completing-read "Remove location: " locations nil t)) + (idx (cdr (assoc choice locations)))) + (if (= idx -1) + (message "Operation cancelled") + (jumper--reorder-registers idx) + (message "Location removed"))))) + +(defvar jumper-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "SPC") #'jumper-store-location) + (define-key map (kbd "j") #'jumper-jump-to-location) + (define-key map (kbd "d") #'jumper-remove-location) + map) + "Keymap for jumper commands.") + +;;;###autoload +(defun jumper-setup-keys () + "Setup default keybindings for jumper." + (interactive) + (global-set-key (kbd jumper-prefix-key) jumper-map)) + +;; Call jumper-setup-keys when the package is loaded +(jumper-setup-keys) + +(provide 'jumper) +;;; jumper.el ends here. diff --git a/modules/keybindings.el b/modules/keybindings.el new file mode 100644 index 00000000..3d817013 --- /dev/null +++ b/modules/keybindings.el @@ -0,0 +1,100 @@ +;;; keybindings --- General Keyboard Shortcuts -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: + + + +;; Commonly used files should be easy to jump to. The "jump-to" keymap has the +;; "C-c j" prefix and immediately opens files defined in user-constants.el. + +;; "Hostile Keybindings" are those that are close to keybindings I use commonly +;; so they're easy to hit by accident, but they have painful results. I'd +;; rather avoid the pain by unsetting they keybindings and view the error '<key> +;; is undefined' message. Finally, I'm providing messages to train me to use +;; faster keybindings and provide feedback when evaluating buffers. + +;;; Code: + +(require 'user-constants) + +;; make org-store-link binding global +(global-set-key (kbd "C-c l") 'org-store-link) + +;; remap Shift Backspace to Delete +(global-set-key (kbd "S-<backspace>") 'delete-forward-char) + +;; ------------------------------ Jump To Commands ----------------------------- +;; quick access for commonly used files + +(defvar jump-to-keymap (make-sparse-keymap) + "Jump-to commonly used files/directories/commands.") +(global-set-key (kbd "C-c j") jump-to-keymap) + +(define-key jump-to-keymap (kbd "r") + #'(lambda () (interactive) (find-file reference-file))) +(define-key jump-to-keymap (kbd "s") + #'(lambda () (interactive) (find-file schedule-file))) +(define-key jump-to-keymap (kbd "i") + #'(lambda () (interactive) (find-file inbox-file))) +(define-key jump-to-keymap (kbd "c") + #'(lambda () (interactive) (find-file contacts-file))) +(define-key jump-to-keymap (kbd "m") + #'(lambda () (interactive) (find-file macros-file))) +(define-key jump-to-keymap (kbd "n") + #'(lambda () (interactive) (find-file reading-notes-file))) +(define-key jump-to-keymap (kbd "w") + #'(lambda () (interactive) (find-file webclipped-file))) +(define-key jump-to-keymap (kbd "g") + #'(lambda () (interactive) (find-file gcal-file))) +(define-key jump-to-keymap (kbd "I") + #'(lambda () (interactive) (find-file emacs-init-file))) + + +;; ---------------------------- Keybinding Discovery --------------------------- + +(use-package free-keys + :defer 1 + :bind ("C-h C-k" . free-keys)) + +(use-package which-key + :defer 1 + :config + ;; never show keybindings that have been 'cj/disabled' + (push '((nil . "cj/disabled") . t) which-key-replacement-alist) + (setq which-key-idle-delay 2.0 + which-key-popup-type 'side-window) + (which-key-setup-side-window-bottom) + ;; (which-key-setup-side-window-right-bottom) + (which-key-mode 1)) + +;; ---------------------------- General Keybindings ---------------------------- + +;; Avoid hostile bindings +(global-unset-key (kbd "C-x C-f")) ;; find-file-read-only +(global-unset-key (kbd "C-z")) ;; suspend-frame is accidentally hit often +(global-unset-key (kbd "M-o")) ;; facemenu-mode + +;; Add commonly-used general keybindings +(global-set-key (kbd "C-x C-f") 'find-file) +(global-set-key (kbd "C-c f") 'link-hint-open-link-at-point) +(global-set-key (kbd "M-*") 'calculator) +(global-set-key (kbd "M-Y") 'yank-media) + +;; Normally bound to ESC ESC ESC, hit ESC once to get out of unpleasant situations. +(global-set-key (kbd "<escape>") 'keyboard-escape-quit) + +;; remap C-x \ to sort-lines (from remap activate-transient-input-method) +(global-unset-key (kbd "C-x \\")) +(global-set-key (kbd "C-x \\") 'sort-lines) + +;; training myself to use C-/ for undo (bound internally) as it's faster. +(global-unset-key (kbd "C-x u")) +(define-key global-map (kbd "C-x u") + #'(lambda () (interactive) + (message (concat "Seriously, " user-name + "? Use 'C-/'. It's faster.")))) + + +(provide 'keybindings) +;;; keybindings.el ends here diff --git a/modules/keyboard-macros.el b/modules/keyboard-macros.el new file mode 100644 index 00000000..0fbf3d02 --- /dev/null +++ b/modules/keyboard-macros.el @@ -0,0 +1,97 @@ +;;; keyboard-macros.el --- Keyboard Macro Management -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; This library provides a simple, end-user–focused interface for +;; creating, naming, saving, and replaying keyboard macros in Emacs. +;; All commands are built on top of the built-in =kmacro= machinery, but +;; add a lightweight workflow and persistence across sessions. +;; +;; Workflow: +;; +;; 1. Start recording with C-F3 (or M-x cj/kbd-macro-start-or-end) +;; This toggles macro recording on. +;; Now you can perform all the edits you want recorded in the macro. +;; +;; 2. Stop recording with C-F3 (or M-x cj/kbd-macro-start-or-end) +;; This stops recording and the macro becomes the "last keyboard macro." +;; +;; 3. Replay your macro <f3> (or M-x call-last-kbd-macro) +;; +;; 4. Name your macro with M-<F3> +;; You will be prompted for a short name (e.g. =align-comments=, +;; =cleanup-trail-spaces=). This name is how you'll refer to it later. +;; +;; 5. Recall that macro later with M-x [the name you gave the macro] +;; +;; 6. View all your saved macros with s-<f3> (super-f3) +;; +;; 7. All macros reload at startup automatically. +;; When this library is loaded, it will look for the save file and +;; re-establish all your named macros in your current session. +;; +;;; Code: + +(require 'user-constants) ;; definitions of sync-dir and macros-file + +(defun ensure-macros-file (file) + "Ensure FILE exists and its first line enables lexical-binding." + (unless (file-exists-p file) + (with-temp-file file + (insert ";;; -*- lexical-binding: t -*-\n")))) + +(defun cj/kbd-macro-start-or-end () + "Toggle start/end of keyboard macro definition." + (interactive) + (if defining-kbd-macro + (end-kbd-macro) + (start-kbd-macro nil))) + +(defun cj/save-maybe-edit-macro (name) + "Save last macro as NAME in `macros-file'; edit if prefix arg." + (interactive "SName of macro: ") + (kmacro-name-last-macro name) + (ensure-macros-file macros-file) + (find-file macros-file) + (goto-char (point-max)) + (newline) + (insert-kbd-macro name) + (newline) + (save-buffer) + (switch-to-buffer (other-buffer (current-buffer) 1)) + (when current-prefix-arg + (find-file macros-file) + (goto-char (point-max))) + name) + +(defun cj/open-macros-file () + "Open the keyboard macros file." + (interactive) + (ensure-macros-file macros-file) + (find-file macros-file)) + +;; Set up key bindings +(global-set-key (kbd "C-<f3>") #'cj/kbd-macro-start-or-end) +(global-set-key (kbd "<f3>") #'call-last-kbd-macro) +(global-set-key (kbd "M-<f3>") #'cj/save-maybe-edit-macro) +(global-set-key (kbd "s-<f3>") #'cj/open-macros-file) + +;; Add hook to save any unnamed macros on exit if desired +(defun cj/save-last-kbd-macro-on-exit () + "Save the last keyboard macro before exiting Emacs if it's not saved." + (when (and last-kbd-macro (not (kmacro-name-last-macro))) + (when (y-or-n-p "Save last keyboard macro before exiting? ") + (call-interactively #'cj/save-maybe-edit-macro)))) + +(add-hook 'kill-emacs-hook #'cj/save-last-kbd-macro-on-exit) + +;; Load existing macros file with proper error handling +(when (file-exists-p macros-file) + (condition-case err + (load macros-file) + (error + (message "Error loading keyboard macros file: %s" (error-message-string err))))) + +(provide 'keyboard-macros) +;;; keyboard-macros.el ends here diff --git a/modules/latex-config.el b/modules/latex-config.el new file mode 100644 index 00000000..bb4cf510 --- /dev/null +++ b/modules/latex-config.el @@ -0,0 +1,56 @@ +;;; latex-config --- Setup for LaTeX and Related Software -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; WORKFLOW: +;; +;; Opening any tex file will put you into LaTeX mode. +;; +;; C-c C-m to enter macros +;; C-c C-e to enter environment +;; +;; C-c C-c to compile a tex document using latexmk +;; C-c C-v to view the resulting pdf +;; +;;; Code: + +;; ----------------------------- Auctex And Related ---------------------------- + +(use-package tex + :ensure auctex + :defer t + :hook + (TeX-mode-hook . (lambda () (setq TeX-command-default "latexmk"))) ; use latexmk by default + (LaTeX-mode . (lambda () (TeX-fold-mode 1))) ; automatically activate TeX-fold-mode. + (LaTeX-mode . flyspell-mode) ; turn on flyspell-mode by default + (LaTeX-mode . TeX-PDF-mode) + (LaTeX-mode . (lambda () (push (list 'output-pdf "Zathura") TeX-view-program-selection))) + :mode + ("\\.tex\\'" . latex-mode) + :config + (setq TeX-auto-save t) ; auto save style info when saving buffer + (setq TeX-parse-self t) ; parse file after loading if it has no style hook + (setq TeX-save-query nil) ; don't ask to save files before starting TeX + (setq TeX-PDF-mode t) ; compile to PDF mode, rather than DVI + (setq-default TeX-master t)) ; Assume the file is the master file itself + +(use-package auctex-latexmk + :defer t + :config + (auctex-latexmk-setup) + (setq auctex-latexmk-inherit-TeX-PDF-mode t)) + +(use-package company-auctex + :after tex + :init (company-auctex-init)) + +;; ----------------------------- Graphviz Dot Mode ----------------------------- + +(use-package graphviz-dot-mode + :defer t + :config + (setq graphviz-dot-indent-width 4)) + +(provide 'latex-config) +;;; latex-config.el ends here diff --git a/modules/ledger-config.el b/modules/ledger-config.el new file mode 100644 index 00000000..c268fa36 --- /dev/null +++ b/modules/ledger-config.el @@ -0,0 +1,50 @@ +;;; ledger-config.el --- Ledger Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; -------------------------------- Ledger Mode -------------------------------- +;; edit files in ledger format + +(use-package ledger-mode + :mode ("\\.dat\\'" + "\\.ledger\\'" + "\\.journal\\'") + :preface + (defun cj/ledger-save () + "Automatically clean the ledger buffer at each save." + (interactive) + (save-excursion + (when (buffer-modified-p) + (with-demoted-errors (ledger-mode-clean-buffer)) + (save-buffer)))) + :bind + (:map ledger-mode-map + ("C-x C-s" . cj/ledger-save)) + :custom + (ledger-clear-whole-transactions t) + (ledger-reconcile-default-commodity "$") + (ledger-report-use-header-line nil) + (ledger-reports + '(("bal" "%(binary) --strict -f %(ledger-file) bal") + ("bal this month" "%(binary) --strict -f %(ledger-file) bal -p %(month) -S amount") + ("bal this year" "%(binary) --strict -f %(ledger-file) bal -p 'this year'") + ("net worth" "%(binary) --strict -f %(ledger-file) bal Assets Liabilities") + ("account" "%(binary) --strict -f %(ledger-file) reg %(account)")))) + +;; ------------------------------ Flycheck Ledger ------------------------------ +;; syntax and unbalanced transaction linting + +(use-package flycheck-ledger + :after ledger-mode) + +;; ------------------------------- Company Ledger ------------------------------ +;; autocompletion for ledger + +(use-package company-ledger + :after (company ledger-mode) + :config + (add-to-list 'company-backends 'company-ledger)) + +(provide 'ledger-config) +;;; ledger-config.el ends here. diff --git a/modules/lipsum-generator.el b/modules/lipsum-generator.el new file mode 100644 index 00000000..b328b989 --- /dev/null +++ b/modules/lipsum-generator.el @@ -0,0 +1,239 @@ +;;; lipsum-generator.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings +;; Version: 0.5 + +;;; Commentary: +;; Generate pseudo-Latin placeholder text using a simple order-two +;; Markov chain. You can train the chain on region, buffer, or file. +;; By default, it learns from a bundled Latin wordlist, which you can +;; change via customization. +;; +;; Interactive commands: +;; M-x cj/lipsum – Return N words +;; M-x cj/lipsum-insert – Insert N words at point +;; M-x cj/lipsum-title – Generate a pseudo-Latin heading +;; M-x cj/lipsum-paragraphs – Insert multiple paragraphs +;; M-x cj/lipsum-learn-* – Train the chain +;; M-x cj/lipsum-reset – Clear chain + +;;; Code: + +(require 'cl-lib) + +(defcustom cj/lipsum-training-file + (expand-file-name "liber-primus.txt" + (expand-file-name "assets" user-emacs-directory)) + "Training file for the cj-lipsum Markov chain. +Defaults to 'liber-primus.txt' in the modules directory." + :type 'file + :group 'cj-lipsum) + +(cl-defstruct (cj/markov-chain + (:constructor cj/markov-chain-create)) + "An order-two Markov chain." + (map (make-hash-table :test 'equal)) + (keys nil)) + +(defun cj/markov-tokenize (text) + "Split TEXT into tokens: words and punctuation separately." + (let ((case-fold-search nil)) + (split-string text "\\b" t "[[:space:]\n]+"))) + +(defun cj/markov-learn (chain text) + "Add TEXT into the Markov CHAIN with tokenized input." + (let* ((words (cj/markov-tokenize text)) + (len (length words))) + (cl-loop for i from 0 to (- len 3) + for a = (nth i words) + for b = (nth (1+ i) words) + for c = (nth (+ i 2) words) + do (let* ((bigram (list a b)) + (nexts (gethash bigram (cj/markov-chain-map chain)))) + (puthash bigram (cons c nexts) + (cj/markov-chain-map chain))))) + (setf (cj/markov-chain-keys chain) + (cl-loop for k being the hash-keys of (cj/markov-chain-map chain) + collect k))) + +(defun cj/markov-fix-capitalization (sentence) + "Capitalize the first word and the first word after .!? in SENTENCE." + (let* ((tokens (split-string sentence "\\b" t))) + (cl-loop with capitalize-next = t + for i from 0 below (length tokens) + for tok = (nth i tokens) + do (when (and capitalize-next (string-match-p "^[[:alpha:]]" tok)) + (setf (nth i tokens) + (concat (upcase (substring tok 0 1)) + (substring tok 1))) + (setq capitalize-next nil)) + do (when (string-match-p "[.!?]" tok) ; <-- Changed: removed $ anchor + (setq capitalize-next t))) + (mapconcat #'identity tokens ""))) + +(defun cj/markov-join-tokens (tokens) + "Join TOKENS into a sentence with proper spacing/punctuation." + (let ((sentence "") (need-space nil)) + (dolist (tok tokens) + (cond + ;; punctuation attaches directly + ((string-match-p "^[[:punct:]]+$" tok) + (setq sentence (concat sentence tok)) + (setq need-space t)) + ;; word + (t + (when need-space + (setq sentence (concat sentence " "))) + (setq sentence (concat sentence tok)) + (setq need-space t)))) + ;; fix capitalization of first word only + (when (string-match "\\`\\([[:alpha:]]\\)" sentence) + (setq sentence + (replace-match (upcase (match-string 1 sentence)) + nil nil sentence))) + ;; ensure it ends with .!? + (unless (string-match-p "[.!?]$" sentence) + (setq sentence (concat (replace-regexp-in-string "[[:punct:]]+$" "" sentence) "."))) + (setq sentence (cj/markov-fix-capitalization sentence)) + sentence)) + +(defun cj/markov-generate (chain n &optional start) + "Generate a sentence of N tokens from CHAIN." + (when (cj/markov-chain-keys chain) + (let* ((state (or (and start + (gethash start (cj/markov-chain-map chain)) + start) + (cj/markov-random-key chain))) + (w1 (car state)) + (w2 (cadr state)) + (tokens (list w1 w2))) + (dotimes (_ (- n 2)) + (let ((next (cj/markov-next-word chain state))) + (if next + (setq tokens (append tokens (list next)) + state (list w2 next) + w1 w2 + w2 next) + (setq state (cj/markov-random-key chain) + w1 (car state) + w2 (cadr state) + tokens (append tokens (list w1 w2)))))) + (cj/markov-join-tokens tokens)))) + +(defun cj/markov-random-key (chain) + (nth (random (length (cj/markov-chain-keys chain))) + (cj/markov-chain-keys chain))) + +(defun cj/markov-next-word (chain bigram) + (let ((candidates (gethash bigram (cj/markov-chain-map chain)))) + (when candidates + (nth (random (length candidates)) candidates)))) + +;;;###autoload +(defvar cj/lipsum-chain (cj/markov-chain-create) + "Global Markov chain for lipsum generation.") + +;;;###autoload +(defun cj/lipsum-reset () + "Reset the global lipsum Markov chain." + (interactive) + (setq cj/lipsum-chain (cj/markov-chain-create)) + (message "cj/lipsum-chain reset.")) + +;;;###autoload +(defun cj/lipsum-learn-region (beg end) + "Learn text from region." + (interactive "r") + (cj/markov-learn cj/lipsum-chain (buffer-substring-no-properties beg end)) + (message "Learned from region.")) + +;;;###autoload +(defun cj/lipsum-learn-buffer () + "Learn from entire buffer." + (interactive) + (cj/markov-learn cj/lipsum-chain + (buffer-substring-no-properties (point-min) (point-max))) + (message "Learned from buffer.")) + +;;;###autoload +(defun cj/lipsum-learn-file (file) + "Learn from FILE containing plain text." + (interactive "fTrain from file: ") + (with-temp-buffer + (insert-file-contents file) + (cj/markov-learn cj/lipsum-chain (buffer-string))) + (message "Learned from file: %s" file)) + +;;;###autoload +(defun cj/lipsum (n) + "Return N words of lorem ipsum." + (cj/markov-generate cj/lipsum-chain n '("Lorem" "ipsum"))) + +;;;###autoload +(defun cj/lipsum-insert (n) + "Insert N words of lorem ipsum at point." + (interactive "nNumber of words: ") + (insert (cj/lipsum n))) + +;;; Title generation + +(defconst cj/lipsum-title-min 3) +(defconst cj/lipsum-title-max 8) +(defconst cj/lipsum-title-small 3) + +;;;###autoload +(defun cj/lipsum-title () + "Generate a pseudo-Latin title." + (interactive) + (let* ((n (+ cj/lipsum-title-min + (random (1+ (- cj/lipsum-title-max cj/lipsum-title-min))))) + (words + (cl-loop with state = (cj/markov-random-key cj/lipsum-chain) + for i from 0 below n + for w = (car state) + do (setq state (list (cadr state) + (or (cj/markov-next-word cj/lipsum-chain state) + (cadr (cj/markov-random-key cj/lipsum-chain)))))) + collect (replace-regexp-in-string "^[[:punct:]]+\\|[[:punct:]]+$" "" w)))) + (setq words (cl-remove-if #'string-empty-p words)) + (mapconcat + (lambda (word idx) + (if (or (zerop idx) (> (length word) cj/lipsum-title-small)) + (capitalize word) + word)) + words " ")) + +;;; Paragraphs + +;;;###autoload +(defun cj/lipsum-paragraphs (count &optional min max) + "Insert COUNT paragraphs of lipsum. +Each paragraph has a random length between MIN and MAX words. +Defaults: MIN=30, MAX=80." + (interactive "nNumber of paragraphs: ") + (let ((min (or min 30)) + (max (or max 80))) + (dotimes (_ count) + (let ((len (+ min (random (1+ (- max min)))))) + (insert (cj/lipsum len) "\n\n"))))) + +;;; Customization + +(defgroup cj-lipsum nil + "Pseudo-Latin lorem ipsum text generator." + :prefix "cj/lipsum-" + :group 'text) + +;;; Initialization: train on default file +(defun cj/lipsum--init () + "Initialize cj-lipsum by learning from `cj/lipsum-training-file`." + (when (and cj/lipsum-training-file + (file-readable-p cj/lipsum-training-file)) + (cj/lipsum-reset) + (cj/lipsum-learn-file cj/lipsum-training-file) + (message "cj-lipsum trained on %s" cj/lipsum-training-file))) + +(cj/lipsum--init) + +(provide 'lipsum-generator) +;;; lipsum-generator.el ends here. diff --git a/modules/local-repository.el b/modules/local-repository.el new file mode 100644 index 00000000..c390ffbb --- /dev/null +++ b/modules/local-repository.el @@ -0,0 +1,53 @@ +;;; local-repository.el --- local repository functionality -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;;; Code: + +(require 'elpa-mirror) + +;; ------------------------------ Utility Function ----------------------------- + + +(defun car-member (value list) + "Check if VALUE exists as the car of any cons cell in LIST." + (member value (mapcar #'car list))) + +;; ------------------------------- Customizations ------------------------------ + +(defcustom localrepo-repository-id "localrepo" + "The name used to identify the local repository internally. + +Used for the package-archive and package-archive-priorities lists.") + +(defcustom localrepo-repository-priority 100 + "The value for the local repository in the package-archive-priority list. + +A higher value means higher priority. If you want your local packages to be +preferred, this must be a higher number than any other repositories.") + +(defcustom localrepo-repository-location + (concat user-emacs-directory "/.localrepo") + "The location of the local repository. + +It's a good idea to keep this with the rest of your configuration files and +keep them in source control.") + +(defun cj/update-localrepo-repository () + "Update the local repository with currently installed packages." + (interactive) + (elpamr-create-mirror-for-installed localrepo-repository-location t)) + +(defun localrepo-initialize () +"Add the repository to the package archives, then gives it a high priority." + (unless (car-member localrepo-repository-id package-archives) + (add-to-list 'package-archives + (localrepo-repository-id . localrepo-repository-location))) + + (unless (car-member localrepo-repository-id package-archive-priorities) + (add-to-list 'package-archive-priorities + (localrepo-repository-id . localrepo-repository-priority)))) + +(provide 'local-repository) +;;; local-repository.el ends here. diff --git a/modules/lorem-generator.el b/modules/lorem-generator.el new file mode 100644 index 00000000..6148dfdc --- /dev/null +++ b/modules/lorem-generator.el @@ -0,0 +1,244 @@ +;;; lorem-generator.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings +;; Version: 0.5 +;; Package-Requires: ((emacs "26.1") (cl-lib "0.6")) +;; Keywords: text, lorem-ipsum, dummy, filler, markov +;; URL: https://github.com/yourname/cj-lipsum + +;;; Commentary: +;; Generate pseudo-Latin placeholder text using a simple order-two +;; Markov chain. You can train the chain on region, buffer, or file. +;; By default, it learns from a bundled Latin wordlist, which you can +;; change via customization. +;; +;; Interactive commands: +;; M-x cj/lipsum – Return N words +;; M-x cj/lipsum-insert – Insert N words at point +;; M-x cj/lipsum-title – Generate a pseudo-Latin heading +;; M-x cj/lipsum-paragraphs – Insert multiple paragraphs +;; M-x cj/lipsum-learn-* – Train the chain +;; M-x cj/lipsum-reset – Clear chain + +;;; Code: + +(require 'cl-lib) + +(cl-defstruct (cj/markov-chain + (:constructor cj/markov-chain-create)) + "An order-two Markov chain." + (map (make-hash-table :test 'equal)) + (keys nil)) + +(defun cj/markov-tokenize (text) + "Split TEXT into tokens: words and punctuation separately." + (let ((case-fold-search nil)) + (split-string text "\\b" t "[[:space:]\n]+"))) + +(defun cj/markov-learn (chain text) + "Add TEXT into the Markov CHAIN with tokenized input." + (let* ((words (cj/markov-tokenize text)) + (len (length words))) + (cl-loop for i from 0 to (- len 3) + for a = (nth i words) + for b = (nth (1+ i) words) + for c = (nth (+ i 2) words) + do (let* ((bigram (list a b)) + (nexts (gethash bigram (cj/markov-chain-map chain)))) + (puthash bigram (cons c nexts) + (cj/markov-chain-map chain))))) + (setf (cj/markov-chain-keys chain) + (cl-loop for k being the hash-keys of (cj/markov-chain-map chain) + collect k))) + +(defun cj/markov-fix-capitalization (sentence) + "Capitalize the first word and the first word after .!? in SENTENCE." + (let* ((tokens (split-string sentence "\\b" t))) + (cl-loop with capitalize-next = t + for i from 0 below (length tokens) + for tok = (nth i tokens) + do (when (and capitalize-next (string-match-p "^[[:alpha:]]" tok)) + (setf (nth i tokens) + (concat (upcase (substring tok 0 1)) + (substring tok 1))) + (setq capitalize-next nil)) + do (when (string-match-p "[.!?]" tok) ; <-- Changed: removed $ anchor + (setq capitalize-next t))) + (mapconcat #'identity tokens ""))) + +(defun cj/markov-join-tokens (tokens) + "Join TOKENS into a sentence with proper spacing/punctuation." + (let ((sentence "") (need-space nil)) + (dolist (tok tokens) + (cond + ;; punctuation attaches directly + ((string-match-p "^[[:punct:]]+$" tok) + (setq sentence (concat sentence tok)) + (setq need-space t)) + ;; word + (t + (when need-space + (setq sentence (concat sentence " "))) + (setq sentence (concat sentence tok)) + (setq need-space t)))) + ;; fix capitalization of first word only + (when (string-match "\\`\\([[:alpha:]]\\)" sentence) + (setq sentence + (replace-match (upcase (match-string 1 sentence)) + nil nil sentence))) + ;; ensure it ends with .!? + (unless (string-match-p "[.!?]$" sentence) + (setq sentence (concat (replace-regexp-in-string "[[:punct:]]+$" "" sentence) "."))) + (setq sentence (cj/markov-fix-capitalization sentence)) + sentence)) + +(defun cj/markov-generate (chain n &optional start) + "Generate a sentence of N tokens from CHAIN." + (when (cj/markov-chain-keys chain) + (let* ((state (or (and start + (gethash start (cj/markov-chain-map chain)) + start) + (cj/markov-random-key chain))) + (w1 (car state)) + (w2 (cadr state)) + (tokens (list w1 w2))) + (dotimes (_ (- n 2)) + (let ((next (cj/markov-next-word chain state))) + (if next + (setq tokens (append tokens (list next)) + state (list w2 next) + w1 w2 + w2 next) + (setq state (cj/markov-random-key chain) + w1 (car state) + w2 (cadr state) + tokens (append tokens (list w1 w2)))))) + (cj/markov-join-tokens tokens)))) + +(defun cj/markov-random-key (chain) + (nth (random (length (cj/markov-chain-keys chain))) + (cj/markov-chain-keys chain))) + +(defun cj/markov-next-word (chain bigram) + (let ((candidates (gethash bigram (cj/markov-chain-map chain)))) + (when candidates + (nth (random (length candidates)) candidates)))) + +;;;###autoload +(defvar cj/lipsum-chain (cj/markov-chain-create) + "Global Markov chain for lipsum generation.") + +;;;###autoload +(defun cj/lipsum-reset () + "Reset the global lipsum Markov chain." + (interactive) + (setq cj/lipsum-chain (cj/markov-chain-create)) + (message "cj/lipsum-chain reset.")) + +;;;###autoload +(defun cj/lipsum-learn-region (beg end) + "Learn text from region." + (interactive "r") + (cj/markov-learn cj/lipsum-chain (buffer-substring-no-properties beg end)) + (message "Learned from region.")) + +;;;###autoload +(defun cj/lipsum-learn-buffer () + "Learn from entire buffer." + (interactive) + (cj/markov-learn cj/lipsum-chain + (buffer-substring-no-properties (point-min) (point-max))) + (message "Learned from buffer.")) + +;;;###autoload +(defun cj/lipsum-learn-file (file) + "Learn from FILE containing plain text." + (interactive "fTrain from file: ") + (with-temp-buffer + (insert-file-contents file) + (cj/markov-learn cj/lipsum-chain (buffer-string))) + (message "Learned from file: %s" file)) + +;;;###autoload +(defun cj/lipsum (n) + "Return N words of lorem ipsum." + (cj/markov-generate cj/lipsum-chain n '("Lorem" "ipsum"))) + +;;;###autoload +(defun cj/lipsum-insert (n) + "Insert N words of lorem ipsum at point." + (interactive "nNumber of words: ") + (insert (cj/lipsum n))) + +;;; Title generation + +(defconst cj/lipsum-title-min 3) +(defconst cj/lipsum-title-max 8) +(defconst cj/lipsum-title-small 3) + +;;;###autoload +(defun cj/lipsum-title () + "Generate a pseudo-Latin title." + (interactive) + (let* ((n (+ cj/lipsum-title-min + (random (1+ (- cj/lipsum-title-max cj/lipsum-title-min))))) + (words + (cl-loop with state = (cj/markov-random-key cj/lipsum-chain) + for i from 0 below n + for w = (car state) + do (setq state (list (cadr state) + (or (cj/markov-next-word cj/lipsum-chain state) + (cadr (cj/markov-random-key cj/lipsum-chain)))))) + collect (replace-regexp-in-string "^[[:punct:]]+\\|[[:punct:]]+$" "" w)))) + (setq words (cl-remove-if #'string-empty-p words)) + (mapconcat + (lambda (word idx) + (if (or (zerop idx) (> (length word) cj/lipsum-title-small)) + (capitalize word) + word)) + words " ")) + +;;; Paragraphs + +;;;###autoload +(defun cj/lipsum-paragraphs (count &optional min max) + "Insert COUNT paragraphs of lipsum. + +Each paragraph has a random length between MIN and MAX words. +Defaults: MIN=30, MAX=80." + (interactive "nNumber of paragraphs: ") + (let ((min (or min 30)) + (max (or max 80))) + (dotimes (_ count) + (let ((len (+ min (random (1+ (- max min)))))) + (insert (cj/lipsum len) "\n\n"))))) + +;;; Customization + +(defgroup cj-lipsum nil + "Pseudo-Latin lorem ipsum text generator." + :prefix "cj/lipsum-" + :group 'text) + +(defcustom cj/lipsum-default-file + (expand-file-name "latin.txt" + (file-name-directory (or load-file-name buffer-file-name))) + "Default training file for cj-lipsum. + +This should be a plain UTF-8 text file with hundreds of Latin words +or sentences. By default it points to the bundled `latin.txt`." + :type 'file + :group 'cj-lipsum) + +;;; Initialization: train on default file +(defun cj/lipsum--init () + "Initialize cj-lipsum by learning from `cj/lipsum-default-file`." + (when (and cj/lipsum-default-file + (file-readable-p cj/lipsum-default-file)) + (cj/lipsum-reset) + (cj/lipsum-learn-file cj/lipsum-default-file))) + +(cj/lipsum--init) + +(provide 'lorem-generator) +;;; lorem-generator.el ends here. diff --git a/modules/mail-config.el b/modules/mail-config.el new file mode 100644 index 00000000..76235ff1 --- /dev/null +++ b/modules/mail-config.el @@ -0,0 +1,341 @@ +;;; mail-config --- Settings for Mu4e Email -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; I found Aime Bertrand's blog post to be an excellent walkthrough of how to +;; setup a Mu4e config. +;; +;; https://macowners.club/posts/email-emacs-mu4e-macos/ +;; +;; on saving attachments: +;; After running mu4e-view-save-attachments, +;; - invoke embark-act-all in the completion menu +;; - followed by RET (mu4e-view-save-attachments) to save all attachments +;; +;; - or TAB (vertico-insert) +;; - followed by , (comma) next to each file you want to save, +;; - then RET (vertico-exit), to save selected attachments. +;; +;;; Code: + +(require 'user-constants) + +;; ------------------------------ Mark All Headers ----------------------------- +;; convenience function to mark all headers for an action + +(defun cj/mu4e-mark-all-headers () + "Mark all headers for a later action. + +Prompts user for the action when executing." + (interactive) + (mu4e-headers-mark-for-each-if + (cons 'something nil) + (lambda (_msg _param) t))) + +;; ---------------------------------- SMTPmail --------------------------------- +;; send mail to smtp host from smtpmail temp buffer. + +(use-package smtpmail + :ensure nil ;; built-in + :defer .5 + :config + (setq sendmail-program (executable-find "msmtp")) + (setq send-mail-function 'message-send-mail-with-sendmail + message-send-mail-function 'message-send-mail-with-sendmail) + (setq message-sendmail-envelope-from 'header) + (setq smtpmail-debug-info t)) + +;; --------------------------------- Mu4e Email -------------------------------- + +(use-package mu4e + :ensure nil ;; mu4e gets installed by installing 'mu' via the system package manager + :load-path "/usr/share/emacs/site-lisp/mu4e/" + :defer .5 + :bind + ("C-c m". mu4e) + (:map mu4e-headers-mode-map + ("M" . cj/mu4e-mark-all-headers) + ("D" . mu4e-headers-mark-for-trash) + ("d" . mu4e-headers-mark-for-delete)) + (:map mu4e-view-mode-map + ("r" . mu4e-compose-wide-reply) + ("R" . mu4e-compose-reply)) + :hook + (mu4e-view-mode . turn-on-visual-line-mode) + :config + (setq gnus-blocked-images "http") ;; block external images (i.e., 1 px trackers) + (setq mail-user-agent 'mu4e-user-agent) ;; default to mu4e for email + (setq message-citation-line-format "On %a %d %b %Y at %R, %f wrote:\n") ;; helps show up properly in Outlook/Gmail threads + (setq message-citation-line-function 'message-insert-formatted-citation-line) + (setq message-kill-buffer-on-exit t) ;; don't keep message buffers around + (setq mu4e-change-filenames-when-moving t) ;; avoid gmail dup UID issues: https://goo.gl/RTCgVa + (setq mu4e-completing-read-function 'completing-read) ;; use generic completing read, rather than ido + (setq mu4e-compose-context-policy 'ask) ;; ask for context if no context matches + + ;; (setq mu4e-compose-format-flowed t) ;; plain text mails must flow correctly for recipients + (setq mu4e-compose-keep-self-cc t) ;; keep me in the cc list + (setq mu4e-compose-signature-auto-include nil) ;; don't include signature by default + (setq mu4e-confirm-quit nil) ;; don't ask when quitting + (setq mu4e-context-policy 'pick-first) ;; start with the first (default) context + (setq mu4e-headers-auto-update nil) ;; updating headers buffer on email is too jarring + (setq mu4e-root-maildir mail-dir) ;; root directory for all email accounts + (setq mu4e-maildir mail-dir) ;; same as above (for newer mu4e) + (setq mu4e-sent-messages-behavior 'delete) ;; don't save to "Sent", IMAP does this already + (setq mu4e-show-images t) ;; show embedded images + (setq mu4e-update-interval nil) ;; disallow automatic checking for new emails + + ;; Format=flowed for better plain text email handling + ;; This will be automatically disabled when org-msg is active + (setq mu4e-compose-format-flowed t) + + (setq mu4e-html2text-command 'mu4e-shr2text) ;; email conversion to html via shr2text + (setq mu4e-mu-binary (executable-find "mu")) + (setq mu4e-get-mail-command (concat (executable-find "mbsync") " -a")) ;; command to sync mail + (setq mu4e-user-mail-address-list '("c@cjennings.net" "craigmartinjennings@gmail.com")) + (setq mu4e-index-update-error-warning nil) ;; don't warn me about spurious sync issues + + ;; ------------------------------ Mu4e Contexts ------------------------------ + + (setq mu4e-contexts + (list + (make-mu4e-context + :name "gmail.com" + :match-func + (lambda (msg) + (when msg + (string-prefix-p "/gmail" (mu4e-message-field msg :maildir)))) + :vars '((user-mail-address . "craigmartinjennings@gmail.com") + (user-full-name . "Craig Jennings") + (mu4e-drafts-folder . "/gmail/Drafts") + (mu4e-sent-folder . "/gmail/Sent") + (mu4e-starred-folder . "/gmail/Starred") + (mu4e-trash-folder . "/gmail/Trash"))) + + (make-mu4e-context + :name "cjennings.net" + :match-func + (lambda (msg) + (when msg + (string-prefix-p "/cmail" (mu4e-message-field msg :maildir)))) + :vars '((user-mail-address . "c@cjennings.net") + (user-full-name . "Craig Jennings") + (mu4e-drafts-folder . "/cmail/Drafts") + (mu4e-sent-folder . "/cmail/Sent"))))) + + (setq mu4e-maildir-shortcuts + '(("/cmail/Inbox" . ?i) + ("/cmail/Sent" . ?s) + ("/gmail/Inbox" . ?I) + ("/gmail/Sent" . ?S))) + + ;; ------------------------------ Mu4e Bookmarks ----------------------------- + + (setq mu4e-bookmarks + `((:name "cjennings inbox" + :query "maildir:/cmail/INBOX" + :key ?i) + (:name "cjennings unread" + :query "maildir:/cmail/INBOX AND flag:unread AND NOT flag:trashed" + :key ?u) + (:name "cjennings starred" + :query "maildir:/cmail/INBOX AND flag:flagged" + :key ?s) + (:name "cjennings large" + :query "maildir:/cmail/INBOX AND size:5M..999M" + :key ?l) + (:name "gmail.com inbox" + :query "maildir:/gmail/INBOX" + :key ?I) + (:name "gmail.com unread" + :query "maildir:/gmail/INBOX AND flag:unread AND NOT flag:trashed" + :key ?U) + (:name "gmail.com starred" + :query "maildir:/gmail/INBOX AND flag:flagged" + :key ?S) + (:name "gmail.com large" + :query "maildir:/gmail/INBOX AND size:5M..999M" + :key ?L))) + + (defun no-auto-fill () + "Turn off \'auto-fill-mode\'." + (auto-fill-mode -1)) + (add-hook 'mu4e-compose-mode-hook #'no-auto-fill) + + ;; Always BCC myself + ;; http://www.djcbsoftware.nl/code/mu/mu4e/Compose-hooks.html + (defun cj/add-cc-bcc-header () + "Add CC: and BCC: to myself header." + (save-excursion (message-add-header + (concat "CC: " "\n") + ;; pre hook above changes user-mail-address. + (concat "Bcc: " user-mail-address "\n")))) + (add-hook 'mu4e-compose-mode-hook 'cj/add-cc-bcc-header) + + ;; remap the awkward mml-attach-file to the quicker mail-add-attachment + (define-key mu4e-compose-mode-map [remap mml-attach-file] 'mail-add-attachment) + + ;; don't allow openwith to mess with your attachments + (add-to-list 'mm-inhibit-file-name-handlers 'openwith-file-handler) + + ;; use imagemagick to render images, if available + (when (fboundp 'imagemagick-register-types) + (imagemagick-register-types)) + + ;; ------------------------------ HTML Settings ------------------------------ + ;; also see org-msg below + + ;; Prefer HTML over plain text when both are available + (setq mu4e-view-prefer-html t) + + ;; Use a better HTML renderer with more control + (setq mu4e-html2text-command + (cond + ;; Best option: pandoc (if available) + ((executable-find "pandoc") + "pandoc -f html -t plain --reference-links") + ;; Good option: w3m (better tables/formatting) + ((executable-find "w3m") + "w3m -dump -T text/html -cols 72 -o display_link_number=true") + ;; Fallback: built-in shr + (t 'mu4e-shr2text))) + + ;; Configure shr (built-in HTML renderer) for better display + (setq shr-use-colors nil) ; Don't use colors in terminal + (setq shr-use-fonts nil) ; Don't use variable fonts + (setq shr-max-image-proportion 0.7) ; Limit image size + (setq shr-width 72) ; Set width for HTML rendering + (setq shr-bullet "• ") ; Nice bullet points + + ;; Block remote images by default (privacy/security) + (setq mu4e-view-show-images t) + (setq mu4e-view-image-max-width 800) + + ;; ------------------------------- View Actions ------------------------------ + ;; define view and article menus + + (defun cj/search-for-sender (msg) + "Search for messages sent by the sender of the message at point." + (mu4e-search + (concat "from:" + (mu4e-contact-email (car (mu4e-message-field msg :from)))))) + + ;; Custom function to toggle remote content and bind it in view mode + (defun cj/mu4e-toggle-remote-images () + "Toggle display of remote images in current message." + (interactive) + (require 'mu4e-view) + (setq-local gnus-blocked-images + (if (equal gnus-blocked-images "http") + nil + "http")) + (mu4e-view-refresh)) + + ;; first letter is the keybinding + (setq mu4e-headers-actions + '(("bview in browser" . mu4e-action-view-in-browser) + ("asave attachment" . mu4e-view-mime-part-action) + ("oorg-contact-add" . mu4e-action-add-org-contact) + ("xsearch for sender" . cj/search-for-sender) + ("tshow this thread" . mu4e-action-show-thread) + )) + + ;; first letter is the keybinding + (setq mu4e-view-actions + '(("bview in browser" . mu4e-action-view-in-browser) + ("asave attachments" . mu4e-view-mime-part-action) + ("esave attachments" . mu4e-view-save-attachments) + ("oorg-contact-add" . mu4e-action-add-org-contact) + ("Itoggle remote images" . cj/mu4e-toggle-remote-images) + )) + ;; ("ssave message to attach later" . mu4e-action-capture-message) + (setq mu4e-compose-complete-addresses nil) + + ;; ---------------------------- Address Completion --------------------------- + + ;; Disable company-mode in compose buffers + (defun cj/disable-company-in-mu4e-compose () + "Disable company mode in mu4e compose buffers." + (company-mode -1)) + + (add-hook 'mu4e-compose-mode-hook #'cj/disable-company-in-mu4e-compose) + + ;; NOTE: Key bindings for TAB and comma are now handled by + ;; mu4e-org-contacts-integration module which provides: + ;; - Smart TAB completion in email headers + ;; - Comma-triggered completion + ;; - Integration with org-contacts database + + ;; Also disable company in org-msg-edit-mode + (with-eval-after-load 'org-msg + (add-hook 'org-msg-edit-mode-hook #'cj/disable-company-in-mu4e-compose)) + + ;; Don't spell-check email addresses and headers + (defun cj/disable-ispell-in-email-headers () + "Disable ispell in email header fields." + (make-local-variable 'ispell-skip-region-alist) + (add-to-list 'ispell-skip-region-alist + '("^To:\\|^Cc:\\|^Bcc:" . "^[^:]*$")) + (add-to-list 'ispell-skip-region-alist + '("^From:" . "^[^:]*$")) + (add-to-list 'ispell-skip-region-alist + '("^Subject:" . "^[^:]*$"))) + + (add-hook 'mu4e-compose-mode-hook #'cj/disable-ispell-in-email-headers) + (add-hook 'message-mode-hook #'cj/disable-ispell-in-email-headers) + (add-hook 'org-msg-edit-mode-hook #'cj/disable-ispell-in-email-headers) + + (require 'mu4e-org-contacts-integration) + (cj/activate-mu4e-org-contacts-integration)) ;; end use-package mu4e + + +;; ---------------------------------- Org-Msg ---------------------------------- +;; user composes org mode; recipient receives html + +(use-package org-msg + :after (org mu4e) + :load-path "~/code/org-msg/" + :config + + ;; inline CSS, no postamble, no TOC, no stars or footers + (setq org-msg-options "html-postamble:nil H:5 num:nil ^:{} toc:nil author:nil email:nil") + + ;; hide org markup, show inline images + (setq org-msg-startup "hidestars inlineimages") + + ;; new and html emails get the option for both text and html, + ;; text emails get text only replies + (setq org-msg-default-alternatives + '((new . (text html)) + (reply-to-html . (text html)) + (reply-to-text . (text)))) + + ;; Convert Org Citations to Blockquote + (setq org-msg-convert-citation t) + + ;; enforce css usage; default renders too small + (setq org-msg-enforce-css t) + + ;; always kill buffers on exit + (setq message-kill-buffer-on-exit nil) + + ;; Override just the problematic styles with important tags + (setq org-msg-extra-css + (concat + "<style type=\"text/css\">\n" + "body { font-size: 14px !important; line-height: 1.6 !important; }\n" + "p { font-size: 14px !important; margin: 10px 0 !important; }\n" + "li { font-size: 14px !important; }\n" + "pre { font-size: 13px !important; }\n" + "code { font-size: 13px !important; }\n" + "</style>")) + + ;; turn on org-msg in all compose buffers + (org-msg-mode +1)) + +(advice-add #'mu4e-compose-reply + :after (lambda (&rest _) (org-msg-edit-mode))) +(advice-add #'mu4e-compose-wide-reply + :after (lambda (&rest _) (org-msg-edit-mode))) + +(provide 'mail-config) +;;; mail-config.el ends here diff --git a/modules/markdown-config.el b/modules/markdown-config.el new file mode 100644 index 00000000..438aea7e --- /dev/null +++ b/modules/markdown-config.el @@ -0,0 +1,47 @@ +;;; markdown-config --- Settings for Editing Markdown -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;;; Code: + +;;;; ------------------------- Markdown-Mode ------------------------- + +(use-package markdown-mode + :mode (("README\\.md\\'" . gfm-mode) + ("\\.md\\'" . markdown-mode) + ("\\.markdown\\'" . markdown-mode)) + :bind (:map markdown-mode-map + ("<f2>" . markdown-preview)) ;; use same key as compile for consistency + :init (setq markdown-command "multimarkdown")) + +;;;; ------------------------- Impatient-Mode ------------------------ + +;; allows for live previews of your html +;; see: https://github.com/skeeto/impatient-mode +(use-package impatient-mode + :defer t + :config + (setq imp-set-user-filter 'markdown-html)) + +;;;; --------------------- WIP: Markdown-Preview --------------------- + +;; the filter to apply to markdown before impatient-mode pushes it to the server +(defun markdown-preview () + (interactive) + (httpd-start) + (impatient-mode 1) + (setq imp-user-filter #'cj/markdown-html) + (cl-incf imp-last-state) + (imp--notify-clients) + ;; (browse-url-generic-function 'browse-url-xdg-open) + (browse-url-generic "https://localhost:8080/imp" 1)) + +(defun cj/markdown-html (buffer) + (princ (with-current-buffer buffer + (format "<!DOCTYPE html><html><title>Impatient Markdown</title><xmp theme=\"united\" style=\"display:none;\"> %s </xmp><script src=\"http://ndossougbe.github.io/strapdown/dist/strapdown.js\"></script></html>" + (buffer-substring-no-properties (point-min) (point-max)))) + (current-buffer))) + +(provide 'markdown-config) +;;; markdown-config.el ends here diff --git a/modules/media-utils.el b/modules/media-utils.el new file mode 100644 index 00000000..ebbd57f4 --- /dev/null +++ b/modules/media-utils.el @@ -0,0 +1,187 @@ +;;; media-utils.el --- Utilities for Downloading and Viewing Media -*- coding: utf-8; lexical-binding: t; -*- +;; TASK: Update commentary to include default media selection +;; +;;; Commentary: +;; +;; This library provides reusable Emacs methods for working with online and +;; local media, to support media download playback from Emacs. +;; +;; Main features: +;; +;; - Asynchronously download videos (e.g., from YouTube and similar sites) via +;; yt-dlp, with queueing and background management handled by the +;; task-spooler (tsp) utility. +;; +;; - Asynchronously play media URLs using a user-defined choice of external +;; media players (mpv, VLC, etc.), with automatic stream resolution via +;; yt-dlp when required, and dynamic configuration of playback options. +;; +;;; Code: + +;; ------------------------ Default Media Configurations ----------------------- +;; Common yt-dlp format codes: +;; 18 - 360p MP4 (good for low bandwidth) +;; 22 - 720p MP4 (good balance of quality and size) +;; best - best single file format +;; best[height<=720] - best format up to 720p +;; best[height<=1080] - best format up to 1080p +;; bestvideo+bestaudio - best video and audio (may require ffmpeg) +;; For more formats, run: yt-dlp -F <youtube-url> + +(defcustom cj/media-players + '((mpv . (:command "mpv" + :args nil + :name "MPV" + :needs-stream-url nil + :yt-dlp-formats nil)) + (vlc . (:command "vlc" + :args nil + :name "VLC" + :needs-stream-url t + :yt-dlp-formats ("22" "18" "best"))) ; Try formats in order + (cvlc . (:command "cvlc" + :args "-vvv" + :name "cvlc" + :needs-stream-url t + :yt-dlp-formats ("22" "18" "best"))) + (mplayer . (:command "mplayer" + :args nil + :name "MPlayer" + :needs-stream-url t + :yt-dlp-formats ("18" "22" "best"))) + (iina . (:command "iina" + :args nil + :name "IINA" + :needs-stream-url t + :yt-dlp-formats ("best[height<=1080]" "22" "best")))) + "Define media players and their configurations for yt-dlp and playing. +Each entry is (SYMBOL . PLIST). The PLIST accepts the keys :command for the +executable name, :args for optional arguments, :name for a human-readable +label, :needs-stream-url for a boolean flag indicating whether to extract a +stream URL with `yt-dlp', and :yt-dlp-formats for a prioritized list of format +strings." + :type '(alist :key-type symbol + :value-type (plist :key-type keyword + :value-type sexp)) + :group 'elfeed) + +(defcustom cj/default-media-player 'mpv + "The default media player to use for videos. + +Should be a key from `cj/media-players'." + :type 'symbol + :group 'elfeed) + +(defun cj/get-available-media-players () + "Return a list of available media players from `cj/media-players'." + (cl-loop for (player . config) in cj/media-players + when (executable-find (plist-get config :command)) + collect player)) + +(defun cj/select-media-player () + "Interactively select a media player from available options." + (interactive) + (let* ((available (cj/get-available-media-players)) + (choices (mapcar (lambda (player) + (let ((config (alist-get player cj/media-players))) + (cons (plist-get config :name) player))) + available)) + (selection (completing-read + (format "Select media player (current: %s): " + (plist-get (alist-get cj/default-media-player cj/media-players) :name)) + choices nil t)) + (player (alist-get selection choices nil nil #'string=))) + (when player + (setq cj/default-media-player player) + (message "Media player set to: %s" selection)))) + +;; ---------------------- Playing Via Default Media Player --------------------- + +(defun cj/media-play-it (url) + "Play the URL with the configured media player in an async process." + (let* ((player-config (alist-get cj/default-media-player cj/media-players)) + (command (plist-get player-config :command)) + (args (plist-get player-config :args)) + (player-name (plist-get player-config :name)) + (needs-stream-url (plist-get player-config :needs-stream-url)) + (yt-dlp-formats (plist-get player-config :yt-dlp-formats)) + (url-display (truncate-string-to-width url 50))) + + (unless (executable-find command) + (error "%s is not installed or not in PATH" player-name)) + + (let* ((buffer-name (format "*%s: %s*" player-name url-display)) + (shell-command + (if needs-stream-url + ;; Use shell substitution with yt-dlp + (let ((format-string (if yt-dlp-formats + (format "-f %s" + (mapconcat #'shell-quote-argument + yt-dlp-formats + "/")) + ""))) + (format "%s %s $(%s %s -g %s)" + command + (or args "") + "yt-dlp" + format-string + (shell-quote-argument url))) + ;; Direct playback without yt-dlp + (format "%s %s %s" + command + (or args "") + (shell-quote-argument url))))) + + (message "Playing with %s: %s" player-name url-display) + (cj/log-silently "DEBUG: Executing: %s" shell-command) + + (let ((process (start-process-shell-command + player-name + buffer-name + shell-command))) + (set-process-sentinel + process + (lambda (proc event) + (cond + ((string-match-p "finished" event) + (message "✓ Finished playing: %s" url-display)) + ((string-match-p "exited abnormally" event) + (message "✗ Playback failed: %s" url-display) + (with-current-buffer (process-buffer proc) + (goto-char (point-min)) + (when (re-search-forward "ERROR:" nil t) + (cj/log-silently "DEBUG: yt-dlp error: %s" + (buffer-substring-no-properties + (line-beginning-position) + (line-end-position))))))) + (when (string-match-p "finished\\|exited" event) + (kill-buffer (process-buffer proc))))))))) + +;; ------------------------- Media-Download Via yt-dlp ------------------------- + +(defun cj/yt-dl-it (url) + "Downloads the URL in an async shell." + (unless (executable-find "yt-dlp") + (error "The program yt-dlp is not installed or not in PATH")) + (unless (executable-find "tsp") + (error "The tsp (task-spooler) program is not installed or not in PATH")) + (let* ((default-directory videos-dir) + (buffer-name (format "*yt-dlp: %s*" (truncate-string-to-width url 50))) + (output-template (format "%s/%%(channel)s-%%(title)s.%%(ext)s" videos-dir)) + (url-display (truncate-string-to-width url 50)) + (process (start-process "yt-dlp" buffer-name + "tsp" "yt-dlp" "--add-metadata" "-ic" + "-o" output-template url))) + (message "Started download: %s" url-display) + (set-process-sentinel process + (lambda (proc event) + (cond + ((string-match-p "finished" event) + (message "✓ Finished downloading: %s" url-display)) + ((string-match-p "exited abnormally" event) + (message "✗ Download failed: %s" url-display))) + (when (string-match-p "finished\\|exited" event) + (kill-buffer (process-buffer proc))))))) + +(provide 'media-utils) +;;; media-utils.el ends here. diff --git a/modules/modeline-config.el b/modules/modeline-config.el new file mode 100644 index 00000000..2e6ed6e6 --- /dev/null +++ b/modules/modeline-config.el @@ -0,0 +1,55 @@ +;;; modeline-config --- Modeline Settings -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: + + +;;; Code: + +;; ------------------------------- Doom Modeline ------------------------------- + +(use-package doom-modeline + :hook (after-init . doom-modeline-mode) + :custom + ;; Performance optimizations + (doom-modeline-buffer-file-name-style 'relative-from-project) ;; Faster than 'file-name + (doom-modeline-icon t) + (doom-modeline-major-mode-icon t) + (doom-modeline-major-mode-color-icon t) + (doom-modeline-buffer-state-icon t) + (doom-modeline-buffer-modification-icon t) + (doom-modeline-unicode-fallback nil) + (doom-modeline-minor-modes nil) ;; Hide minor modes as requested + (doom-modeline-enable-word-count nil) ;; Faster without word count + (doom-modeline-continuous-word-count-modes nil) + (doom-modeline-buffer-encoding nil) ;; Hide encoding for speed + (doom-modeline-indent-info nil) ;; Hide indent info for speed + (doom-modeline-checker-simple-format t) ;; Simpler checker format for speed + (doom-modeline-number-limit 99) ;; Lower number limit for better performance + (doom-modeline-vcs-max-length 12) ;; Limit VCS info length for speed + (doom-modeline-persp-name nil) ;; Disable perspective name for speed + (doom-modeline-display-default-persp-name nil) + (doom-modeline-persp-icon nil) + (doom-modeline-lsp nil) ;; Disable LSP info for speed + + ;; UI Preferences + (doom-modeline-height 25) + (doom-modeline-bar-width 3) + (doom-modeline-window-width-limit 0.25) + (doom-modeline-project-detection 'projectile) ;; Use projectile if available, nil is faster + + ;; Use nerd-icons instead of all-the-icons + (doom-modeline-icon-preference 'nerd-icons) + + ;; Enable elements you specifically requested + (doom-modeline-column-number t) ;; Show column number + (doom-modeline-percent-position t) ;; Show percentage position + (doom-modeline-buffer-name t) ;; Show buffer name + (doom-modeline-buffer-file-name t) ;; Show file name + :config + (setq doom-modeline-refresh-rate 0.75)) ;; Update rate in seconds + +;; (setq read-process-output-max (* 1024 1024)) ;; 1MB process read size for better performance + +(provide 'modeline-config) +;;; modeline-config.el ends here diff --git a/modules/mu4e-org-contacts-integration.el b/modules/mu4e-org-contacts-integration.el new file mode 100644 index 00000000..7fe89389 --- /dev/null +++ b/modules/mu4e-org-contacts-integration.el @@ -0,0 +1,167 @@ +;;; mu4e-org-contacts-integration.el --- Integrate org-contacts with mu4e completion -*- lexical-binding: t; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; This module provides seamless integration between org-contacts and mu4e's +;; email composition, enabling automatic contact completion in email fields. + +;;; Code: + +(require 'mu4e) +(require 'org-contacts) + +;; ---------------------- Completion at Point Function ------------------------- + +(defun cj/org-contacts-completion-at-point () + "Provide completion-at-point function for org-contacts emails. +This function is designed to work with mu4e's compose buffers." + (when (and (derived-mode-p 'mu4e-compose-mode 'org-msg-edit-mode) + (mail-abbrev-in-expansion-header-p)) + (let* ((end (point)) + (start (save-excursion + (re-search-backward "\\(\\`\\|[\n:,]\\)[ \t]*" nil t) + (goto-char (match-end 0)) + (point))) + (initial (buffer-substring-no-properties start end)) + (contacts (cj/get-all-contact-emails))) + (when contacts + (list start end + (completion-table-dynamic + (lambda (_) + contacts)) + :exclusive 'no ; Allow other completion sources + :annotation-function + (lambda (s) + (when-let* ((email-match (string-match "<\\([^>]+\\)>" s)) + (email (match-string 1 s))) + (propertize (format " [%s]" (file-name-nondirectory contacts-file)) + 'face 'completions-annotations)))))))) + +;; ---------------------- Smart TAB Completion ------------------------- + +(defun cj/mu4e-org-contacts-tab-complete () + "Smart TAB completion for mu4e compose buffers. +In email header fields (To, Cc, Bcc), complete using org-contacts. +Elsewhere, perform the default TAB action." + (interactive) + (cond + ;; In email header fields, use completion-at-point + ((mail-abbrev-in-expansion-header-p) + (if (and (boundp 'completion-in-region-mode) completion-in-region-mode) + ;; If we're already in completion mode, cycle through candidates + (completion-at-point) + ;; Start new completion + (completion-at-point))) + ;; In org-msg-edit-mode body, use org-cycle + ((and (eq major-mode 'org-msg-edit-mode) + (not (mail-abbrev-in-expansion-header-p))) + (org-cycle)) + ;; Default: indent + (t (indent-for-tab-command)))) + +;; ---------------------- Comma-triggered Completion ------------------------- + +(defun cj/mu4e-org-contacts-comma-complete () + "Insert comma and optionally trigger contact completion. +In email header fields, insert comma with space and offer completion. +Elsewhere, just insert comma." + (interactive) + (if (mail-abbrev-in-expansion-header-p) + (progn + (insert ", ") ; Insert comma with space + ;; Trigger completion immediately if there's no text after the comma + (when (looking-at-p "\\s-*$") + (completion-at-point))) + (insert ","))) + +;; ---------------------- Direct Insertion (Alternative) ------------------------- + +(defun cj/mu4e-org-contacts-insert-email () + "Directly insert a contact email using completing-read. +This bypasses the completion-at-point system for direct selection." + (interactive) + (when (mail-abbrev-in-expansion-header-p) + (let* ((contacts (cj/get-all-contact-emails)) + (selected (completing-read "Contact: " contacts nil t))) + ;; If we're not at the beginning of a field, check if we need a comma + (when (and (not (save-excursion + (skip-chars-backward " \t") + (or (bolp) (looking-back "[:,]" 1)))) + (not (looking-at-p "\\s-*$"))) + (insert ", ")) + (insert selected)))) + +;; ---------------------- Setup Functions ------------------------- + +(defun cj/mu4e-org-contacts-setup-completion () + "Setup org-contacts completion for mu4e compose buffers." + ;; Add our completion function with high priority + (add-hook 'completion-at-point-functions + #'cj/org-contacts-completion-at-point + -10 t) ; High priority, buffer-local + + ;; Setup completion behavior + (setq-local completion-ignore-case t) + (setq-local completion-cycle-threshold 7) + + ;; Add substring matching if not already present + (unless (member 'substring completion-styles) + (setq-local completion-styles + (append '(substring) completion-styles)))) + +(defun cj/mu4e-org-contacts-setup-keybindings () + "Setup keybindings for org-contacts completion in compose buffers." + ;; TAB for smart completion + (local-set-key (kbd "TAB") #'cj/mu4e-org-contacts-tab-complete) + (local-set-key (kbd "<tab>") #'cj/mu4e-org-contacts-tab-complete) + + ;; Comma for comma-triggered completion + (local-set-key (kbd ",") #'cj/mu4e-org-contacts-comma-complete) + + ;; Optional: Direct insertion binding + (local-set-key (kbd "C-c e") #'cj/mu4e-org-contacts-insert-email)) + +;; ---------------------- Mode Hooks ------------------------- + +(defun cj/mu4e-org-contacts-compose-setup () + "Setup function to be called in mu4e compose mode hooks." + (cj/mu4e-org-contacts-setup-completion) + (cj/mu4e-org-contacts-setup-keybindings)) + +;; ---------------------- Activation ------------------------- + +(defun cj/activate-mu4e-org-contacts-integration () + "Activate org-contacts integration with mu4e email composition." + (interactive) + + ;; Ensure mu4e's built-in completion is disabled + (setq mu4e-compose-complete-addresses nil) + + ;; Setup hooks for mu4e-compose-mode + (add-hook 'mu4e-compose-mode-hook #'cj/mu4e-org-contacts-compose-setup) + + ;; Setup hooks for org-msg-edit-mode (HTML email composition) + (with-eval-after-load 'org-msg + (add-hook 'org-msg-edit-mode-hook #'cj/mu4e-org-contacts-compose-setup)) + + ;; Remove any existing mu4e completion setup + (remove-hook 'mu4e-compose-mode-hook #'mu4e--compose-setup-completion) + + (message "mu4e org-contacts integration activated")) + +(defun cj/deactivate-mu4e-org-contacts-integration () + "Deactivate org-contacts integration with mu4e email composition." + (interactive) + + ;; Remove our hooks + (remove-hook 'mu4e-compose-mode-hook #'cj/mu4e-org-contacts-compose-setup) + (remove-hook 'org-msg-edit-mode-hook #'cj/mu4e-org-contacts-compose-setup) + + ;; Re-enable mu4e's built-in completion if desired + (setq mu4e-compose-complete-addresses t) + (add-hook 'mu4e-compose-mode-hook #'mu4e--compose-setup-completion) + + (message "mu4e org-contacts integration deactivated")) + +(provide 'mu4e-org-contacts-integration) +;;; mu4e-org-contacts-integration.el ends here
\ No newline at end of file diff --git a/modules/mu4e-org-contacts-setup.el b/modules/mu4e-org-contacts-setup.el new file mode 100644 index 00000000..9936de95 --- /dev/null +++ b/modules/mu4e-org-contacts-setup.el @@ -0,0 +1,24 @@ +;;; mu4e-org-contacts-setup.el --- Setup mu4e with org-contacts -*- lexical-binding: t; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; Simple setup file to enable org-contacts integration with mu4e. +;; Add this to your mail-config.el or load it after both mu4e and org-contacts. + +;;; Code: + +;; Load the integration module +(require 'mu4e-org-contacts-integration) + +;; Activate the integration +(cj/activate-mu4e-org-contacts-integration) + +;; Optional: If you want to use org-contacts as the primary source, +;; you might want to disable mu4e's contact caching to save memory +(with-eval-after-load 'mu4e + ;; Disable mu4e's internal contact collection + (setq mu4e-compose-complete-only-personal nil) + (setq mu4e-compose-complete-only-after nil)) + +(provide 'mu4e-org-contacts-setup) +;;; mu4e-org-contacts-setup.el ends here
\ No newline at end of file diff --git a/modules/music-config.el b/modules/music-config.el new file mode 100644 index 00000000..2ee2788b --- /dev/null +++ b/modules/music-config.el @@ -0,0 +1,597 @@ +;;; music-config.el --- EMMS configuration with MPD integration -*- coding: utf-8; lexical-binding: t; -*- +;; +;;; Commentary: +;; +;; This module provides a comprehensive music management system for Emacs using +;; EMMS (Emacs MultiMedia System) with MPD (Music Player Daemon) as the backend. +;; It streamlines music library navigation, playlist management, and playback +;; control within Emacs. +;; +;; Features: +;; - Fuzzy file/directory selection with case-insensitive, alphanumeric ordering +;; - Recursive directory addition to playlists +;; - Integration with Dired/Dirvish for adding files from file managers +;; - M3U playlist saving and loading for playlist portability +;; - Radio station URL to m3u playlist creation +;; - Track reordering within playlists +;; - MPD for playback control +;; +;; Setup: +;; 1. Ensure MPD is installed and running on your system +;; 2. Set `cj/music-root' to your music library directory (default: ~/music) +;; 3. Set `cj/music-m3u-root' to your playlist directory (default: ~/music) +;; 4. Customize `cj/music-file-extensions' if you use formats beyond the defaults +;; +;; Usage: +;; All music commands are accessed through the prefix `C-; m' by default. +;; - `C-; m m' - Show EMMS playlist buffer +;; - `C-; m a' - Add music via fuzzy search (files or directories) +;; - `C-; m l' - Load an existing M3U playlist +;; - `C-; m s' - Save current playlist as M3U +;; - `C-; m c' - Clear current playlist +;; - `C-; m r' - Create a radio station playlist +;; - `C-; m SPC' - Pause/resume playback +;; +;; When the playlist is active, omit the prefix and enter the keys directly. +;; Control + up and down arrows will reorder the playlist files. +;; +;; +;; The fuzzy search interface (`C-; m a') presents your music library in a +;; hierarchical view with directories marked by trailing slashes. Selection +;; maintains strict alphanumeric ordering even during narrowing, and matching +;; is case-insensitive. Selecting a directory adds all music files within it +;; recursively. +;; +;; Custom functions use the "cj/" namespace to avoid conflicts with built-in +;; EMMS functions. The configuration is designed to be testable, with core +;; functions defined separately from the use-package declaration. +;; +;; Requirements: +;; - MPD (Music Player Daemon) running on localhost:6600 +;; +;; Custom functions are defined separately from the use-package declaration to facilitate unit testing. + +;;; Code: + +(require 'cl-lib) +(require 'subr-x) + +;;; Custom Variables + +(defgroup cj/music nil + "Music configuration settings." + :group 'multimedia) + +(defcustom cj/music-root (expand-file-name "~/music") + "Root directory of your music collection." + :type 'directory + :group 'cj/music) + +(defcustom cj/music-m3u-root cj/music-root + "Directory where M3U playlists are saved and loaded." + :type 'directory + :group 'cj/music) + +(defcustom cj/music-keymap-prefix (kbd "C-; m") + "Prefix keybinding for all music commands." + :type 'key-sequence + :group 'cj/music) + +(defcustom cj/music-file-extensions '("flac" "mp3" "opus" "wav" "m4a" "aac" "ogg") + "List of valid music file extensions." + :type '(repeat string) + :group 'cj/music) + +;;; Local Variables + +(defvar-local cj/music-playlist-file nil + "The M3U file associated with the current playlist buffer. +Set when loading or saving a playlist.") + +;;; Helper Functions + +(defun cj/music--valid-file-p (file) + "Return t if FILE is a music file with accepted extensions. +The check is case-insensitive." + (when-let ((ext (file-name-extension file))) + (member (downcase ext) cj/music-file-extensions))) + +(defun cj/music--valid-directory-p (dir) + "Return t if DIR is a directory and is not hidden. +Hidden directories are those starting with a dot." + (and (file-directory-p dir) + (not (string-prefix-p "." (file-name-nondirectory (directory-file-name dir)))))) + +(defun cj/music--collect-entries-recursive (root) + "Recursively collect all non-hidden directories and music files under ROOT. +Return a list of relative paths (from ROOT) sorted alphanumerically. +Directories and files are mixed and sorted together." + (let ((base (file-name-as-directory root)) + (candidates '())) + (cl-labels ((collect (dir) + (when (cj/music--valid-directory-p dir) + (let ((entries (directory-files dir t "^[^.]" t))) + (dolist (entry (sort entries #'string-lessp)) + (cond + ((cj/music--valid-directory-p entry) + (push (string-remove-prefix base entry) candidates) + (collect entry)) + ((and (file-regular-p entry) + (cj/music--valid-file-p entry)) + (push (string-remove-prefix base entry) candidates)))))))) + (collect base)) + (nreverse candidates))) + +(defun cj/music--ensure-playlist-buffer () + "Ensure EMMS playlist buffer exists and is in playlist mode. +Returns the buffer or signals an error if it cannot be created." + (let ((buffer (get-buffer-create "*EMMS Playlist*"))) + (with-current-buffer buffer + (unless (eq major-mode 'emms-playlist-mode) + (emms-playlist-mode))) + buffer)) + +;; Helper function to extract tracks from M3U file +(defun cj/music--m3u-file-tracks (m3u-file) + "Extract a list of track filenames from M3U-FILE. +Returns a list of absolute paths." + (when (file-exists-p m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (let ((dir (file-name-directory m3u-file)) + (tracks '())) + (goto-char (point-min)) + (while (re-search-forward "^[^#].*$" nil t) + (let ((track (match-string 0))) + (unless (string-empty-p (string-trim track)) + (push (if (or (file-name-absolute-p track) + (string-match "\\=\\(https?\\|mms\\)://" track)) + track + (expand-file-name track dir)) + tracks)))) + (nreverse tracks))))) + +;; Helper function to get current playlist tracks +(defun cj/music--playlist-tracks () + "Get list of track names from the current EMMS playlist buffer." + (let ((tracks '())) + (with-current-buffer (cj/music--ensure-playlist-buffer) + (save-excursion + (goto-char (point-min)) + (while (not (eobp)) + (when-let ((track (emms-playlist-track-at (point)))) + (push (emms-track-name track) tracks)) + (forward-line 1)))) + (nreverse tracks))) + +;;; Interactive Commands + +;;;###autoload +(defun cj/music-add-directory-recursive (directory) + "Add all music files under DIRECTORY recursively to the EMMS playlist. +DIRECTORY defaults to `cj/music-root' if called non-interactively." + (interactive + (list (read-directory-name "Add directory recursively: " + cj/music-root nil t))) + (unless (file-directory-p directory) + (user-error "Not a directory: %s" directory)) + (emms-add-directory-tree directory) + (message "Added recursively: %s" directory)) + +(defun cj/music--collect-entries-recursive (root) + "Recursively collect all non-hidden directories and music files under ROOT. +Return a list of relative paths (from ROOT) sorted alphanumerically. +Directories have trailing '/' and everything is sorted together." + (let ((base (file-name-as-directory root)) + (candidates '())) + (cl-labels ((collect (dir) + (when (cj/music--valid-directory-p dir) + (let ((entries (directory-files dir t "^[^.]" t))) + (dolist (entry entries) + (cond + ;; If it's a directory, add it with trailing / and recurse + ((cj/music--valid-directory-p entry) + (let ((rel-path (string-remove-prefix base entry))) + (push (concat rel-path "/") candidates)) + (collect entry)) + ;; If it's a music file, add it + ((and (file-regular-p entry) + (cj/music--valid-file-p entry)) + (push (string-remove-prefix base entry) candidates)))))))) + (collect base)) + ;; Sort all candidates together alphanumerically + (sort candidates #'string-lessp))) + +(defun cj/music--completion-table (candidates) + "Create a completion table that maintains the order of CANDIDATES. +Provides case-insensitive matching while preserving sort order." + (lambda (string pred action) + (if (eq action 'metadata) + '(metadata + (display-sort-function . identity) + (cycle-sort-function . identity) + (completion-ignore-case . t)) + (complete-with-action action candidates string pred)))) + +;;;###autoload +(defun cj/music-fuzzy-select-and-add () + "Select a music file or directory using fuzzy completion and add to playlist. +Shows relative paths from =cj/music-root' with directories having trailing slashes. +Selecting a directory adds it recursively, selecting a file adds that single file. +Matching is case-insensitive." + (interactive) + ;; Ensure case-insensitive completion locally + (let* ((completion-ignore-case t) + (candidates (cj/music--collect-entries-recursive cj/music-root)) + (choice-rel (completing-read "Choose music file or directory: " + (cj/music--completion-table candidates) + nil t)) + (cleaned-choice (if (string-suffix-p "/" choice-rel) + (substring choice-rel 0 -1) + choice-rel)) + (choice-abs (expand-file-name cleaned-choice cj/music-root))) + (if (file-directory-p choice-abs) + (cj/music-add-directory-recursive choice-abs) + (emms-add-file choice-abs)) + (message "Added %s to EMMS playlist" choice-rel))) + +;;;###autoload +(defun cj/music-playlist-load () + "Select and load an M3U playlist file from =cj/music-m3u-root'. +Clears the current playlist before loading and tracks the source file." + (interactive) + (let* ((m3u-files (directory-files cj/music-m3u-root t "\\.m3u\\'" t)) + (m3u-names (mapcar #'file-name-nondirectory m3u-files))) + (when (null m3u-files) + (user-error "No M3U files found in %s" cj/music-m3u-root)) + (let* ((choice-name (completing-read "Select playlist: " m3u-names nil t)) + (choice-file (expand-file-name choice-name cj/music-m3u-root))) + (unless (file-exists-p choice-file) + (user-error "Playlist file does not exist: %s" choice-file)) + (emms-playlist-clear) + (emms-play-playlist choice-file) + ;; Track the loaded file + (with-current-buffer (cj/music--ensure-playlist-buffer) + (setq cj/music-playlist-file choice-file)) + (message "Loaded playlist: %s" choice-name)))) + +;;;###autoload + +(defun cj/music-playlist-save () + "Save the current EMMS playlist to a file in =cj/music-m3u-root'. +Offers existing playlist names for completion but allows entering new names. +Automatically adds .m3u extension if not present. +Tracks the saved file for future reference." + (interactive) + (let* ((m3u-files (directory-files cj/music-m3u-root nil "\\.m3u\\'" t)) + (m3u-names-no-ext (mapcar (lambda (f) + (file-name-sans-extension f)) + m3u-files)) + (chosen-name (completing-read "Save playlist as: " + m3u-names-no-ext + nil nil nil nil + (format-time-string "playlist-%Y%m%d-%H%M%S"))) + (filename (if (string-suffix-p ".m3u" chosen-name) + chosen-name + (concat chosen-name ".m3u"))) + (full-path (expand-file-name filename cj/music-m3u-root))) + + (when (and (file-exists-p full-path) + (not (yes-or-no-p (format "Overwrite %s? " filename)))) + (user-error "Aborted saving playlist")) + (let ((buffer (cj/music--ensure-playlist-buffer))) + (with-current-buffer buffer + ;; Use 'never to never prompt for overwrite since we already asked + (let ((emms-source-playlist-ask-before-overwrite nil)) + (emms-playlist-save 'm3u full-path)) + (setq cj/music-playlist-file full-path))) + + (message "Saved playlist to %s" filename))) + +;;;###autoload +(defun cj/music-move-track-up () + "Move the current track one line up in the EMMS playlist buffer." + (interactive) + (with-current-buffer (cj/music--ensure-playlist-buffer) + (emms-playlist-mode-move-up))) + +;;;###autoload +(defun cj/music-move-track-down () + "Move the current track one line down in the EMMS playlist buffer." + (interactive) + (with-current-buffer (cj/music--ensure-playlist-buffer) + (emms-playlist-mode-move-down))) + +;;;###autoload +(defun cj/music-create-radio-station (name url) + "Create a radio station M3U playlist file with NAME and URL. +The file is saved in `cj/music-m3u-root' as NAME.m3u. +Prompts before overwriting an existing file." + (interactive + (list (read-string "Radio station name: ") + (read-string "Stream URL: "))) + (when (string-empty-p name) + (user-error "Radio station name cannot be empty")) + (when (string-empty-p url) + (user-error "Stream URL cannot be empty")) + (let* ((safe-name (replace-regexp-in-string "[^a-zA-Z0-9_-]" "_" name)) + (file-path (expand-file-name (concat safe-name "_Radio.m3u") cj/music-m3u-root)) + (content (format "#EXTM3U\n#EXTINF:-1,%s\n%s\n" name url))) + (when (and (file-exists-p file-path) + (not (yes-or-no-p (format "File %s exists. Overwrite? " + (file-name-nondirectory file-path))))) + (user-error "Aborted creating radio station file")) + (with-temp-file file-path + (insert content)) + (message "Created radio station playlist: %s" (file-name-nondirectory file-path)))) + +;;;###autoload +(defun cj/music-playlist-clear () + "Stops playing then empties the playlist." + (interactive) + (emms-stop) + (emms-playlist-clear) + (setq cj/music-playlist-file nil) + (message "EMMS playlist cleared.")) + +(defun cj/music-playlist-reload () + "Reload the current playlist from its associated M3U file. +Clears the current playlist and reloads from disk without confirmation. +Errors if no file is associated or if the file doesn't exist." + (interactive) + (with-current-buffer (cj/music--ensure-playlist-buffer) + (cond + ;; No file associated + ((not cj/music-playlist-file) + (user-error "No playlist file to reload - playlist exists only in memory")) + + ;; File doesn't exist + ((not (file-exists-p cj/music-playlist-file)) + (user-error "Playlist file no longer exists: %s" + (file-name-nondirectory cj/music-playlist-file))) + + ;; Reload the playlist + (t + (let ((file-name (file-name-nondirectory cj/music-playlist-file))) + (emms-playlist-clear) + (emms-play-playlist cj/music-playlist-file) + ;; Restore the file association (emms-playlist-clear might have cleared it) + (setq cj/music-playlist-file cj/music-playlist-file) + (message "Reloaded playlist: %s" file-name)))))) + +;;;###autoload +(defun cj/music-playlist-edit () + "Open the playlist's M3U file in other window. +If the playlist has been modified, prompt to save first. +If no file is associated with the playlist, show an error." + (interactive) + (with-current-buffer (cj/music--ensure-playlist-buffer) + (cond + ;; No file associated + ((not cj/music-playlist-file) + (message "Playlist not yet saved.")) + + ;; File doesn't exist (was deleted?) + ((not (file-exists-p cj/music-playlist-file)) + (message "Playlist file no longer exists: %s" + (file-name-nondirectory cj/music-playlist-file))) + + ;; Check for modifications + (t + (let ((file-tracks (cj/music--m3u-file-tracks cj/music-playlist-file)) + (current-tracks (cj/music--playlist-tracks))) + (if (equal file-tracks current-tracks) + ;; No changes, open directly + (find-file-other-window cj/music-playlist-file) + ;; Changes detected, prompt + (when (yes-or-no-p "Playlist has been modified. Save before editing? ") + (emms-playlist-save 'm3u cj/music-playlist-file) + (find-file-other-window cj/music-playlist-file)))))))) + +;;;###autoload +(defun cj/music-playlist-toggle () + "Toggle the visibility of the EMMS playlist buffer in a side window. +Opens the playlist in a right side window if not visible, or closes it if visible." + (interactive) + (let* ((buf-name "*EMMS Playlist*") + (buffer (get-buffer buf-name)) + (win (and buffer (get-buffer-window buffer)))) + (if win + ;; Window exists, close it + (progn + (delete-window win) + (message "EMMS Playlist window closed.")) + ;; Window doesn't exist, create/show it + (progn + ;; Ensure EMMS is loaded + (unless (featurep 'emms) + (require 'emms) + (require 'emms-setup) + (require 'emms-playlist-mode) + (emms-all) + (emms-default-players)) + + ;; Ensure playlist buffer exists + (setq buffer (cj/music--ensure-playlist-buffer)) + + ;; Display in side window + (setq win + (display-buffer-in-side-window + buffer + '((side . right) + (window-width . 0.35)))) ; Slightly narrower than AI window + + ;; Select the window and move to appropriate position + (select-window win) + (with-current-buffer buffer + ;; If there's a current track, go to it; otherwise go to beginning + (if (and (fboundp 'emms-playlist-current-selected-track) + (emms-playlist-current-selected-track)) + (emms-playlist-mode-center-current) + (goto-char (point-min)))) + + ;; Provide feedback + (let ((track-count (with-current-buffer buffer + (count-lines (point-min) (point-max))))) + (if (> track-count 0) + (message "EMMS Playlist displayed (%d tracks)." track-count) + (message "EMMS Playlist displayed (empty)."))))))) + +;;;###autoload +(defun cj/music-playlist-show () + "Show the EMMS playlist buffer, initializing EMMS if necessary. +If EMMS is not loaded, loads it first. Switches to the playlist buffer +in the current window and provides appropriate feedback." + (interactive) + (let ((emms-was-loaded (featurep 'emms)) + (playlist-buffer-exists nil) + (playlist-has-content nil)) + + ;; Load EMMS if not already loaded + (unless emms-was-loaded + (require 'emms) + (require 'emms-setup) + (require 'emms-playlist-mode) + (emms-all) + (emms-default-players)) + + ;; Check if playlist buffer exists + (when (get-buffer "*EMMS Playlist*") + (setq playlist-buffer-exists t) + (with-current-buffer "*EMMS Playlist*" + (setq playlist-has-content (> (point-max) (point-min))))) + + ;; Ensure playlist buffer exists and switch to it + (switch-to-buffer (cj/music--ensure-playlist-buffer)) + + ;; Provide appropriate feedback + (cond + ((not emms-was-loaded) + (message "EMMS started. Current Playlist empty.")) + ((and playlist-buffer-exists playlist-has-content) + (message "EMMS running. Displaying Current Playlist.")) + (t + (message "EMMS running. Current Playlist empty."))))) + +;; ------------------------------- EMMS Settings ------------------------------- + +(use-package emms + :defer t + :init + ;; Create music keymap before package loads + (defvar cj/music-map (make-sparse-keymap) + "Keymap for music commands.") + + :commands (emms-mode-line-mode) + :config + ;; Basic EMMS setup + (require 'emms-setup) + (require 'emms-player-mpd) + (require 'emms-playlist-mode) + (require 'emms-source-file) + (require 'emms-source-playlist) + + (emms-all) + (emms-default-players) + + ;; MPD configuration + (add-to-list 'emms-player-list 'emms-player-mpd) + (setq emms-player-mpd-server-name "localhost" + emms-player-mpd-server-port "6600" + emms-player-mpd-music-directory cj/music-root) + + ;; EMMS settings + (setq emms-source-file-default-directory cj/music-root + emms-playlist-buffer-name "*EMMS Playlist*" + emms-playlist-default-major-mode 'emms-playlist-mode) + + ;; modeline shows nothing + (emms-playing-time-disable-display) + (emms-mode-line-mode -1) + + ;; if mpv, don't display album art (interruptive) + (add-to-list 'emms-player-mpv-parameters "--no-audio-display") + + ;; Start MPD connection + (condition-case err + (emms-player-mpd-connect) + (error (message "Failed to connect to MPD: %s" err))) + + :bind-keymap + ("C-; m" . cj/music-map) + + :bind + (:map emms-playlist-mode-map + + ;; playlist playing + ("p" . emms-playlist-mode-go) ;; start playing the playlist + ("SPC" . emms-pause) ;; pause playing the playlist + ("s" . emms-stop) ;; stop playing the playlist + ("x" . emms-shuffle) ;; shuffle the playlist + ("q" . emms-playlist-mode-bury-buffer) ;; quit the playlist + + ;; playlist maniuplation + ("a" . cj/music-fuzzy-select-and-add) ;; add to playlist + ("C" . cj/music-playlist-clear) ;; clear playlist + ("L" . cj/music-playlist-load) ;; load an existing playlist + ("e" . cj/music-playlist-edit) ;; edit an existing playlist + ("R" . cj/music-playlist-reload) ;; reload an existing playlist + ("S" . cj/music-playlist-save) ;; save current playlist + + ;; playlist track reordering + ("C-<up>" . emms-playlist-mode-shift-track-up) ;; move track earlier + ("C-<down>" . emms-playlist-mode-shift-track-down) ;; move track later + + ;; Create Radio station m3u + ("r" . cj/music-create-radio-station) + + ;; Volume controls (MPD) + ("-" . emms-volume-lower) + ("=" . emms-volume-raise)) + + (:map cj/music-map + ;; EMMS show playlist. + ("m" . cj/music-playlist-toggle) + ("M" . cj/music-playlist-show) + + ;; Add artists and albums (directories) and songs (files) in fuzzy search + ("a" . cj/music-fuzzy-select-and-add) + + ;; Create Radio station m3u + ("r" . cj/music-create-radio-station) + + ;; Playback controls + ("SPC" . emms-pause) + ("s" . emms-stop) + ("p" . emms-playlist-mode-go) + ("x" . emms-shuffle))) + +(global-unset-key (kbd "<f10>")) +(global-set-key (kbd "<f10>") #'cj/music-playlist-toggle) + +;; TASK: Complete the Dired / EMMS integration +;; (defun cj/music-add-dired-selection () +;; "Add selected files or directories in Dired/Dirvish to the EMMS playlist. +;; If region is active, add marked files. Otherwise, add file at point. +;; Directories are added recursively." +;; (interactive) +;; (unless (derived-mode-p 'dired-mode) +;; (user-error "This command must be run in a Dired buffer")) +;; (let ((files (if (use-region-p) +;; (dired-get-marked-files) +;; (list (dired-get-file-for-visit))))) +;; (when (null files) +;; (user-error "No files selected")) +;; (dolist (file files) +;; (if (file-directory-p file) +;; (cj/music-add-directory-recursive file) +;; (if (cj/music--valid-file-p file) +;; (emms-add-file file) +;; (message "Skipping non-music file: %s" file)))) +;; (message "Added %d item(s) to EMMS playlist" (length files)))) +;; +;; TASK: add the dired keymapping to the above function +;; ("^" . cj/music-add-dired-selection) + +(provide 'music-config) +;;; music-config.el ends here diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el new file mode 100644 index 00000000..b2cb1c10 --- /dev/null +++ b/modules/org-agenda-config.el @@ -0,0 +1,290 @@ +;;; org-agenda-config --- Org-Agenda/Todo Config -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; +;; Agenda views are tied to the F8 (fate) key. +;; +;; "We are what we repeatedly do. +;; Excellence, then, is not an act, but a habit" +;; -- Aristotle +;; +;; "...watch your actions, they become habits; +;; watch your habits, they become character; +;; watch your character, for it becomes your destiny." +;; -- Lao Tzu +;; +;; +;; f8 - MAIN AGENDA which organizes all tasks and events into: +;; - all unfinished priority A tasks +;; - the weekly schedule, including the habit consistency graph +;; - all priority B tasks +;; +;; C-f8 - TASK LIST containing all tasks from all agenda targets. +;; +;; M-f8 - TASK LIST containing all tasks from just the current org-mode buffer. +;; +;; NOTE: +;; Files that contain information relevant to the agenda will be found in the +;; following places: the schedule-file, org-roam notes tagged as 'Projects' and +;; project todo.org files found in project-dir and code-dir. + +;;; Code: +(require 'user-constants) + +(use-package org-agenda + :ensure nil ;; built-in + :after (org org-roam) + :config + (setq org-agenda-prefix-format '((agenda . " %i %-25:c%?-12t% s") + (timeline . " % s") + (todo . " %i %-25:c") + (tags . " %i %-12:c") + (search . " %i %-12:c"))) + (setq org-agenda-dim-blocked-tasks 'invisible) + (setq org-agenda-skip-scheduled-if-done nil) + (setq org-agenda-remove-tags t) + (setq org-agenda-compact-blocks t) + + ;; display the agenda from the bottom + (add-to-list 'display-buffer-alist + '("\\*Org Agenda\\*" + (display-buffer-reuse-mode-window display-buffer-below-selected) + (dedicated . t) + (window-height . fit-window-to-buffer))) + + ;; reset s-left/right each time org-agenda is enabled + (add-hook 'org-agenda-mode-hook (lambda () + (local-set-key (kbd "s-<right>") #'org-agenda-todo-nextset) + (local-set-key (kbd "s-<left>") + #'org-agenda-todo-previousset))) + + ;; build org-agenda-list for the first time after emacs init completes. + (add-hook 'emacs-startup-hook #'cj/build-org-agenda-list)) + +;; ------------------------ Add Files To Org Agenda List ----------------------- +;; finds files named 'todo.org' (case insensitive) and adds them to +;; org-agenda-files list. + +(defun cj/add-files-to-org-agenda-files-list (directory) + "Search for files named \\='todo.org\\=' add them to org-project-files. + +DIRECTORY is a string of the path to begin the search." + (interactive "D") + (setq org-agenda-files + (append (directory-files-recursively directory + "^[Tt][Oo][Dd][Oo]\\.[Oo][Rr][Gg]$" t) + org-agenda-files))) + +;; ---------------------------- Rebuild Org Agenda --------------------------- +;; builds the org agenda list from all agenda targets. +;; agenda targets is the schedule, contacts, project todos, +;; inbox, and org roam projects. +(defun cj/build-org-agenda-list () + "Rebuilds the org agenda list without checking org-roam for projects. + +Begins with the inbox-file, schedule-file, and contacts-file. +Then adds all todo.org files from projects-dir and code-dir. +Reports elapsed time in the messages buffer." + (interactive) + (let ((start-time (current-time))) + ;; reset org-agenda-files to inbox-file, schedule-file, contacts-file + (setq org-agenda-files (list inbox-file schedule-file gcal-file contacts-file)) + + (cj/add-files-to-org-agenda-files-list projects-dir) + (cj/add-files-to-org-agenda-files-list code-dir) + + (message "Rebuilt org-agenda-files in %.3f sec" + (float-time (time-subtract (current-time) start-time))))) + +;; Run the above once after Emacs startup when idle for 1 second +;; makes regenerating the list much faster +(add-hook 'emacs-startup-hook + (lambda () + (run-with-idle-timer 1 nil #'cj/build-org-agenda-list))) + +(defun cj/todo-list-all-agenda-files () + "Displays an \\='org-agenda\\=' todo list. + +The contents of the agenda will be built from org-project-files and org-roam +files that have project in their filetag." + (interactive) + (cj/build-org-agenda-list) + (org-agenda "a" "t")) +(global-set-key (kbd "C-<f8>") #'cj/todo-list-all-agenda-files) + +;; ------------------------- Agenda List Current Buffer ------------------------ +;; an agenda listing tasks from just the current buffer. + +(defun cj/todo-list-from-this-buffer () + "Displays an \\='org-agenda\\=' todo list built from the current buffer. + +If the current buffer isn't an org buffer, inform the user." + (interactive) + (if (eq major-mode 'org-mode) + (let ((org-agenda-files (list buffer-file-name))) + (org-agenda "a" "t")) + (message (concat "Your org agenda request based on '" (buffer-name (current-buffer)) + "' failed because it's not an org buffer.")))) +(global-set-key (kbd "M-<f8>") #'cj/todo-list-from-this-buffer) + +;; -------------------------------- Main Agenda -------------------------------- +;; my custom agenda command from all available agenda targets. adapted from: +;; https://blog.aaronbieber.com/2016/09/24/an-agenda-for-life-with-org-mode.html + +(defvar cj/main-agenda-hipri-title "HIGH PRIORITY UNRESOLVED TASKS" + "String to announce the high priority section of the main agenda.") + +(defvar cj/main-agenda-overdue-title "OVERDUE" + "String to announce the overdue section of the main agenda.") + +(defvar cj/main-agenda-schedule-title "SCHEDULE" + "String to announce the schedule section of the main agenda.") + +(defvar cj/main-agenda-tasks-title "PRIORITY B" + "String to announce the schedule section of the main agenda.") + +(defun cj/org-skip-subtree-if-habit () + "Skip an agenda entry if it has a STYLE property equal to \"habit\"." + (let ((subtree-end (save-excursion (org-end-of-subtree t)))) + (if (string= (org-entry-get nil "STYLE") "habit") + subtree-end + nil))) + +(defun cj/org-agenda-skip-subtree-if-not-overdue () + "Skip an agenda subtree if it is not an overdue deadline or scheduled task. + +An entry is considered overdue if it has a scheduled or deadline date strictly +before today, is not marked as done, and is not a habit." + (let* ((subtree-end (save-excursion (org-end-of-subtree t))) + (todo-state (org-get-todo-state)) + (style (org-entry-get nil "STYLE")) + (deadline (org-entry-get nil "DEADLINE")) + (scheduled (org-entry-get nil "SCHEDULED")) + (today (org-time-string-to-absolute (format-time-string "%Y-%m-%d"))) + (deadline-day (and deadline (org-time-string-to-absolute deadline))) + (scheduled-day (and scheduled (org-time-string-to-absolute scheduled)))) + (if (or (not todo-state) ; no todo keyword + (member todo-state org-done-keywords) ; done/completed tasks + (string= style "habit")) + subtree-end ; skip if done or habit + (let ((overdue (or (and deadline-day (< deadline-day today)) + (and scheduled-day (< scheduled-day today))))) + (if overdue + nil ; do not skip, keep this entry + subtree-end))))) ; skip if not overdue + +(defun cj/org-skip-subtree-if-priority (priority) + "Skip an agenda subtree if it has a priority of PRIORITY. + +PRIORITY may be one of the characters ?A, ?B, or ?C." + (let ((subtree-end (save-excursion (org-end-of-subtree t))) + (pri-value (* 1000 (- org-lowest-priority priority))) + (pri-current (org-get-priority (thing-at-point 'line t)))) + (if (= pri-value pri-current) + subtree-end + nil))) + +(defun cj/org-skip-subtree-if-keyword (keywords) + "Skip an agenda subtree if it has a TODO keyword in KEYWORDS. + +KEYWORDS must be a list of strings." + (let ((subtree-end (save-excursion (org-end-of-subtree t)))) + (if (member (org-get-todo-state) keywords) + subtree-end + nil))) + +(setq org-agenda-custom-commands + '(("d" "Daily Agenda with Tasks" + ((alltodo "" + ((org-agenda-skip-function #'cj/org-agenda-skip-subtree-if-not-overdue) + (org-agenda-overriding-header cj/main-agenda-overdue-title) + (org-agenda-prefix-format " %i %-15:c%?-15t% s"))) + (tags "PRIORITY=\"A\"" + ((org-agenda-skip-function '(org-agenda-skip-entry-if 'todo 'done)) + (org-agenda-overriding-header cj/main-agenda-hipri-title) + (org-agenda-prefix-format " %i %-15:c%?-15t% s"))) + (agenda "" + ((org-agenda-start-day "0d") + (org-agenda-span 8) + (org-agenda-start-on-weekday nil) + (org-agenda-overriding-header cj/main-agenda-schedule-title) + (org-agenda-prefix-format " %i %-15:c%?-15t% s"))) + (alltodo "" + ((org-agenda-skip-function '(or (cj/org-skip-subtree-if-habit) + (cj/org-skip-subtree-if-priority ?A) + (cj/org-skip-subtree-if-priority ?C) + (cj/org-skip-subtree-if-priority ?D) + (cj/org-skip-subtree-if-keyword '("PROJECT")) + (org-agenda-skip-if nil '(scheduled deadline)))) + (org-agenda-overriding-header cj/main-agenda-tasks-title) + (org-agenda-prefix-format " %i %-15:c%?-15t% s")))) + ((org-agenda-compact-blocks nil))))) + + +(defun cj/main-agenda-display () + "Display the main daily org-agenda view. + +This uses all org-agenda targets and presents three sections: +- All unfinished priority A tasks +- Today's schedule, including habits with consistency graphs +- All priority B and C unscheduled/undeadlined tasks + +The agenda is rebuilt from all sources before display, including: +- inbox-file and schedule-file +- Org-roam nodes tagged as \"Project\" +- All todo.org files in projects-dir and code-dir" + (interactive) + (cj/build-org-agenda-list) + (org-agenda "a" "d")) +(global-set-key (kbd "<f8>") #'cj/main-agenda-display) + +;; ------------------------- Add Timestamp To Org Entry ------------------------ +;; simply adds a timestamp to put the org entry on an agenda + +(defun cj/add-timestamp-to-org-entry (s) + "Add an event with time S to appear underneath the line-at-point. + +This allows a line to show in an agenda without being scheduled or a deadline." + (interactive "sTime: ") + (defvar cj/timeformat "%Y-%m-%d %a") + (org-end-of-line) + (save-excursion + (open-line 1) + (forward-line 1) + (insert (concat "<" (format-time-string cj/timeformat (current-time)) " " s ">" )))) +;;(global-set-key (kbd "M-t") #'cj/add-timestamp-to-org-entry) + +;; --------------------------- Notifications / Alerts -------------------------- +;; send libnotify notifications for agenda items + +(use-package alert + :config + (setq alert-fade-time 10) ;; seconds to vanish alert + (setq alert-default-style 'libnotify)) ;; works well with dunst + +(use-package org-alert + :after alert org-agenda + :commands (org-alert-enable org-alert-check) + :bind + ("C-c A" . org-alert-check) + :config + ;; Set org-alert settings + (setq org-alert-interval 300) ;; seconds between agenda checks (5 minutes) + (setq org-alert-notify-cutoff 10) ;; minutes before a deadline to notify + (setq org-alert-notify-after-event-cutoff 5) ;; stop alerts 5 mins after deadline + (setq org-alert-notification-title "Reminder")) + +;; Enable org-alert timer with message +(defun cj/org-alert-enable-with-message () + (org-alert-enable) + (message "org-alert timer enabled with interval %d seconds" org-alert-interval)) + +;; Alert when idle post Emacs startup +(add-hook 'emacs-startup-hook + (lambda () + (run-with-idle-timer 1 nil #'cj/org-alert-enable-with-message))) + + +(provide 'org-agenda-config) +;;; org-agenda-config.el ends here diff --git a/modules/org-babel-config.el b/modules/org-babel-config.el new file mode 100644 index 00000000..3ed9dabc --- /dev/null +++ b/modules/org-babel-config.el @@ -0,0 +1,151 @@ +;;; org-babel-config.el --- Org Babel/Tempo Config -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; All Org-Babel and Org-Tempo Packages, Settings, and Languages. + +;;; Code: + +;; ------------------------------- Org Babel Core ------------------------------ +;; general org babel settings + +(use-package ob-core + :ensure nil ;; built-in + :after org + :defer .5 + :hook + (org-babel-after-execute-hook . org-redisplay-inline-images) ;; for seeing inline dotgraph images + :config + (setq org-src-fontify-natively t) ;; fontify the code in blocks + (setq org-src-tab-acts-natively t) ;; tabs act like in language major mode buffer + (setq org-src-window-setup 'current-window) ;; don't split window when source editing wih C-c ' + (setq org-confirm-babel-evaluate nil) ;; just evaluate the source code + (setq org-babel-default-header-args + (cons '(:tangle . "yes") + (assq-delete-all :tangle org-babel-default-header-args)))) ;; default header args for babel + + +;; ------------------- Babel Execution Confirmation Toggle ------------------- +;; org-babel verifies before each execution + +(defun babel-confirm (flag) + "Report the setting of `org-confirm-babel-evaluate'. + +If invoked with \[universal-argument], toggle the setting based on FLAG. +FLAG is the raw prefix argument passed interactively." + (interactive "P") + (if (equal flag '(4)) + (setq org-confirm-babel-evaluate (not org-confirm-babel-evaluate))) + (message "Babel evaluation confirmation is %s" + (if org-confirm-babel-evaluate "on" "off"))) + + +;; ---------------------------- Org Babel Languages ---------------------------- +;; create executable code blocks in a language within org-mode + +(use-package ob-awk + :ensure nil ;; built-in + :defer .5 + :after ob-core + :commands + (org-babel-execute:awk + org-babel-expand-body:awk)) + +(use-package ob-dot + :ensure nil ;; built-in + :defer .5 + :after ob-core + :commands + (org-babel-execute:dot + org-babel-expand-body:dot) + :config + ;; https://stackoverflow.com/questions/16770868/org-babel-doesnt-load-graphviz-editing-mode-for-dot-sources + (add-to-list 'org-src-lang-modes (quote ("dot" . graphviz-dot)))) + +(use-package ob-emacs-lisp + :ensure nil ;; built-in + :defer .5 + :after ob-core + :commands + (org-babel-execute:emacs-lisp + org-babel-expand-body:emacs-lisp)) + +(use-package ob-latex + :ensure nil ;; built-in + :defer .5 + :after ob-core + :commands + (org-babel-execute:latex + org-babel-expand-body:latex)) + +(use-package ob-python + :ensure nil ;; built-in + :defer .5 + :after ob-core + :commands + (org-babel-execute:python + org-babel-variable-assignments:python + org-babel-load-session:python + org-babel-prep-session:python)) + +(use-package ob-scheme + :ensure nil ;; built-in + :defer .5 + :after ob-core + :commands + (org-babel-execute:scheme + org-babel-expand-body:scheme)) + +;; allows for shell, bash, and fish +(use-package ob-shell + :ensure nil ;; built-in + :defer .5 + :after ob-core + :commands + (org-babel-execute:shell + org-babel-prep-session:shell + org-babel-load-session:shell + org-babel-variable-assignments:shell)) + +(use-package ob-sed + :ensure nil ;; built-in + :defer .5 + :after ob-core + :commands (org-babel-execute:sed)) + +;; --------------------------------- Org-Tempo --------------------------------- +;; expands snippets to babel code blocks using templates + +(use-package org-tempo + :defer .5 + :ensure nil ;; built-in + :after ob-core + :config + (add-to-list 'org-structure-template-alist '("awk" . "src awk")) + (add-to-list 'org-structure-template-alist '("sed" . "src sed")) + (add-to-list 'org-structure-template-alist '("bash" . "src bash")) + (add-to-list 'org-structure-template-alist '("zsh" . "src zsh")) + (add-to-list 'org-structure-template-alist '("dot" . "src dot :file temp.png :cmdline -Kdot -Tpng")) + (add-to-list 'org-structure-template-alist '("el" . "src emacs-lisp")) + (add-to-list 'org-structure-template-alist '("js" . "src javascript")) + (add-to-list 'org-structure-template-alist '("java" . "src javas")) + (add-to-list 'org-structure-template-alist '("json" . "src json")) + (add-to-list 'org-structure-template-alist '("latex" . "src latex")) + (add-to-list 'org-structure-template-alist '("py" . "src python")) + (add-to-list 'org-structure-template-alist '("scheme" . "src scheme")) + (add-to-list 'org-structure-template-alist '("shell" . "src shell")) + (add-to-list 'org-structure-template-alist '("yaml" . "src yaml")) + ;; miscellaneous + (add-to-list 'org-structure-template-alist '("example" . "example")) + (add-to-list 'org-structure-template-alist '("quote" . "quote")) + (add-to-list 'org-structure-template-alist '("response" . "response")) + (add-to-list 'org-structure-template-alist '("output" . "output"))) + +;; requires ob-racket, not yet in repositories +;; (add-to-list 'org-structure-template-alist '("sicp" . "src racket :lang sicp")) + +;; drop Org’s default footnote list at the end +(setq org-html-footnote-separator "") + +(provide 'org-babel-config) +;;; org-babel-config.el ends here. diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el new file mode 100644 index 00000000..c9ee89a0 --- /dev/null +++ b/modules/org-capture-config.el @@ -0,0 +1,143 @@ +;;; org-capture-config.el --- Org Capture Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; Customizations related to org-capture and org-refile is here. +;; This includes 'cj/org-webpage-clipper' functionality. + +;; To ensure the code below is only loaded after org-mode, all code is wrapped in an +;; eval-after-load function. + +;; bookmarklet code: +;; text +;; javascript:location.href='org-protocol://capture?template=L&url=%27+encodeURIComponent(location.href)+%27&title=%27+encodeURIComponent(document.title)+%27&body=%27+encodeURIComponent(window.getSelection()) + +;; text + selection +;; javascript:location.href='org-protocol://capture?template=p&url=%27+encodeURIComponent(location.href)+%27&title=%27+encodeURIComponent(document.title)+%27&body=%27+encodeURIComponent(window.getSelection()) + +;;; Code: +(require 'user-constants) +;; -------------------------- Event Capture Formatting ------------------------- + +;; Formats event headlines with YY-MM-DD prefix extracted from the scheduled date + +(defun cj/org-capture-format-event-headline () + "Format the event headline with YY-MM-DD prefix from the WHEN timestamp. +This function is called during org-capture finalization to prepend the date +to the event title for better organization in the schedule file." + (when (string= (plist-get org-capture-plist :key) "e") + (save-excursion + (goto-char (point-min)) + ;; Find the WHEN: line with timestamp + (when (re-search-forward "^WHEN: \\(<[^>]+>\\)" nil t) + (let* ((timestamp (match-string 1)) + ;; Parse the timestamp to extract date components + (parsed (org-parse-time-string timestamp)) + (year (nth 5 parsed)) + (month (nth 4 parsed)) + (day (nth 3 parsed)) + ;; Format as YY-MM-DD + (date-prefix (format "%02d-%02d-%02d: " + (mod year 100) month day))) + ;; Go back to the headline + (goto-char (point-min)) + ;; Insert date prefix after the asterisks + (when (looking-at "^\\(\\*+ \\)\\(.*\\)$") + (replace-match (concat "\\1" date-prefix "\\2")))))))) + +(defun cj/org-capture-event-content () + "Get the appropriate content for event capture based on context. +Returns the selected text from either Emacs or browser (via org-protocol) +formatted appropriately for insertion into the capture template." + (cond + ;; If called from org-protocol (browser), get the initial from org-store-link-plist + ((and (boundp 'org-store-link-plist) + org-store-link-plist + (plist-get org-store-link-plist :initial)) + (concat "\n" (plist-get org-store-link-plist :initial))) + ;; If there's a selected region in Emacs, use it from capture plist + ((and (stringp (plist-get org-capture-plist :initial)) + (not (string= (plist-get org-capture-plist :initial) ""))) + (concat "\n" (plist-get org-capture-plist :initial))) + ;; Otherwise, return empty string + (t ""))) + +;; ----------------------- Org Capture PDF Active Region ----------------------- +;; allows capturing the selected region from within a PDF file. + +(defun cj/org-capture-pdf-active-region () + "Capture the active region of the pdf-view buffer. + +Intended to be called within an org capture template." + (let* ((pdf-buf-name (plist-get org-capture-plist :original-buffer)) + (pdf-buf (get-buffer pdf-buf-name))) + (if (buffer-live-p pdf-buf) + (with-current-buffer pdf-buf + (car (pdf-view-active-region-text))) + (user-error "Buffer %S not alive" pdf-buf-name)))) + +;; --------------------------- Org-Capture Templates --------------------------- +;; you can bring up the org capture menu with C-c c + +(use-package org-protocol + :ensure nil ;; built-in + :defer .5 + :after org + :config + ;; ORG-CAPTURE TEMPLATES + (setq org-protocol-default-template-key "L") + (setq org-capture-templates + '(("t" "Task" entry (file+headline inbox-file "Inbox") + "* TODO %?" :prepend t) + + ("a" "Appointment" entry (file gcal-file) + "* %?\n:PROPERTIES:\n:calendar-id:craigmartinjennings@gmail.com\n:END:\n:org-gcal:\n%^T--%^T\n:END:\n\n" + :jump-to-captured t) + + ("e" "Event" entry (file+headline schedule-file "Scheduled Events") + "* %?%:description +SCHEDULED: %^t%(cj/org-capture-event-content) +Captured On: %U" + :prepend t + :prepare-finalize cj/org-capture-format-event-headline) + + ("E" "Epub Text" entry (file+headline inbox-file "Inbox") + "* %? +#+BEGIN_QUOTE\n %i\n#+END_QUOTE +Source: [[%:link][%(buffer-name (org-capture-get :original-buffer))]] +Captured On: %U" :prepend t) + + ;; requires cj/org-capture-pdf-active-region function defined above + ("P" "PDF Text" entry (file+headline inbox-file "Inbox") + "* %? +#+BEGIN_QUOTE\n%(cj/org-capture-pdf-active-region)\n#+END_QUOTE +Source:[[%L][%(buffer-name (org-capture-get :original-buffer))]] +Captured On: %U" :prepend t) + + ("p" "Link with Selection" entry (file+headline inbox-file "Inbox") + "* %?%:description +#+BEGIN_QUOTE\n%i\n#+END_QUOTE +[[%:link][%:description]] +Captured On: %U\n" :prepend t) + + ("L" "Link" entry (file+headline inbox-file "Inbox") + "* %?%:description +[[%:link][%:description]]\nCaptured On: %U" :prepend t :immediate-finish t) + + ("m" "Mu4e Email" entry (file+headline inbox-file "Inbox") + "* TODO %? +%(if (string= \"%i\" \"\") \"\" \"\n#+BEGIN_QUOTE\n%i\n#+END_QUOTE\") +[[%:link][%:description]] +Captured On: %U" + :prepend t) + + )) ;; end setq + ) ;; end use-package org-protocol + +;; ---------------------------- Simple Task Capture ---------------------------- +;; the simplest way to capture a task. Also a simple way to write this function. + +(define-key global-map (kbd "C-S-t") (kbd "C-c c t")) + +(provide 'org-capture-config) +;;; org-capture-config.el ends here. diff --git a/modules/org-config.el b/modules/org-config.el new file mode 100644 index 00000000..524ff290 --- /dev/null +++ b/modules/org-config.el @@ -0,0 +1,267 @@ +;;; org-config --- Settings and Enhancements to Org Mode -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> +;;; Commentary: + +;; Setting org-modules to org-protocol, ol-eww, ol-w3m, and ol-info removes +;; several modules that org would otherwise load automatically. + +;;; Code: + +;; ------------------------------- Org Constants ------------------------------- + +;; note: some constants used here are defined in init.el +(defvar org-archive-location + (concat sync-dir "/archives/archive.org::datetree/") + "Location of the archive file. + +The archive file is where org entries that are archived via +org-archive-subtree-default are placed.") + +;; ---------------------------- Org General Settings --------------------------- + +(defun cj/org-general-settings () + "All general \='org-mode\=' settings are grouped and set in this function." + + ;; Unbind org-cycle-agenda-files keys for use elsewhere + (unbind-key "C-'" org-mode-map) + (unbind-key "C-," org-mode-map) + + ;; ORG-MODULES + ;; enable recognition of org-protocol:// as a parameter + ;; add org-habits + (require 'org-protocol) + (setq org-modules '(org-protocol ol-eww ol-w3m ol-info org-habit)) + + ;; GENERAL + (setq org-startup-folded t) ;; all org files should start in the folded state + (setq org-cycle-open-archived-trees t) ;; re-enable opening headings with archive tags with TAB + (setq org-outline-path-complete-in-steps nil) + (setq org-return-follows-link t) ;; hit return to follow an org-link + (setq org-list-allow-alphabetical t) ;; allow alpha ordered lists (i.e., a), A), a., etc.) + + ;; INDENTATION + (setq org-startup-indented t) ;; load org files indented + (setq org-adapt-indentation t) ;; adapt indentation to outline node level + (setq org-indent-indentation-per-level 2) ;; indent two character-widths per level + (setq tab-width 8) ;; org-mode complains when tabs aren't @ 8 + + ;; IMAGES / MEDIA + (setq org-startup-with-inline-images t) ;; preview images by default + (setq org-image-actual-width '(500)) ;; keep image sizes in check + (setq org-yank-image-save-method 'attach) ;; attach images; save to data directory + + + (setq org-bookmark-names-plist nil) ;; don't set org-capture bookmarks + + ;; force pdfs exported from org to open in emacs + (add-to-list 'org-file-apps '("\\.pdf\\'" . emacs))) + +;; -------------------------- Org Appearance Settings -------------------------- + +(defun cj/org-appearance-settings() + "Set foreground, background, and font styles for org mode." + (interactive) + ;; org-hide should use fix-pitch to align indents for proportional fonts + (set-face-attribute 'org-hide nil :inherit 'fixed-pitch) + (set-face-attribute 'org-meta-line nil :inherit 'shadow) + + ;; Remove foreground and background from block faces + (set-face-attribute 'org-block nil :foreground 'unspecified :background 'unspecified) + (set-face-attribute 'org-block-begin-line nil :foreground 'unspecified :background 'unspecified) + (set-face-attribute 'org-block-end-line nil :foreground 'unspecified :background 'unspecified) + + ;; Get rid of the background on column views + (set-face-attribute 'org-column nil :background 'unspecified) + (set-face-attribute 'org-column-title nil :background 'unspecified) + + ;; make sure org-links are underlined + (set-face-attribute 'org-link nil :underline t) + + (setq org-ellipsis " ▾") ;; change ellipses to down arrow + (setq org-hide-emphasis-markers t) ;; remove emphasis markers to keep the screen clean + (setq org-hide-leading-stars t) ;; hide leading stars, just show one per line + (setq org-pretty-entities t) ;; render special symbols + (setq org-pretty-entities-include-sub-superscripts nil) ;; ...except superscripts and subscripts + (setq org-fontify-emphasized-text nil) ;; ...and don't render bold and italic markup + (setq org-fontify-whole-heading-line t) ;; fontify the whole line for headings (for face-backgrounds) + (add-hook 'org-mode-hook 'prettify-symbols-mode)) + +;; ----------------------------- Org TODO Settings --------------------------- + +(defun cj/org-todo-settings () + "All org-todo related settings are grouped and set in this function." + + ;; logging task creation, task start, and task resolved states + (setq org-todo-keywords '((sequence "TODO(t)" "PROJECT(p)" "DOING(i)" + "WAITING(w)" "VERIFY(v)" "STALLED(s)" + "DELEGATED(x)" "|" + "FAILED(f!)" "DONE(d!)" "CANCELLED(c!)"))) + + (setq org-todo-keyword-faces + '(("TODO" . "green") + ("PROJECT" . "blue") + ("DOING" . "yellow") + ("WAITING" . "white") + ("VERIFY" . "orange") + ("STALLED" . "light blue") + ("DELEGATED" . "green") + ("FAILED" . "red") + ("DONE" . "dark grey") + ("CANCELLED" . "dark grey"))) + + (setq org-highest-priority ?A) + (setq org-lowest-priority ?D) + (setq org-default-priority ?D) + (setq org-priority-faces '((?A . (:foreground "Cyan" :weight bold)) + (?B . (:foreground "Yellow")) + (?C . (:foreground "Green")) + (?D . (:foreground "Grey")))) + + (setq org-enforce-todo-dependencies t) + (setq org-enforce-todo-checkbox-dependencies t) + (setq org-deadline-warning-days 7) ;; warn me w/in a week of deadlines + (setq org-treat-insert-todo-heading-as-state-change nil) ;; log task creation + (setq org-log-into-drawer nil) ;; log into the drawer + (setq org-log-done nil) ;; don't log completions + (setq org-habit-graph-column 75) ;; allow space for task name + + ;; inherit parents properties (sadly not schedules or deadlines) + (setq org-use-property-inheritance t)) + +;; ---------------------------------- Org Mode --------------------------------- + +(use-package org + :defer .5 + :ensure nil ;; use the built-in package + :pin manual ;; never upgrade from the version built-into Emacs + :preface + ;; create an org-table-map so we can use C-c t as prefix + (define-prefix-command 'org-table-map) + (global-set-key (kbd "C-c T") 'org-table-map) + :bind + ("C-c c" . org-capture) + ("C-c a" . org-agenda) + (:map org-mode-map + ("C-c I" . org-table-field-info) ;; was C-c ? + ("C-\\" . org-match-sparse-tree) + ("C-c t" . org-set-tags-command) + ("C-c l" . org-store-link) + ("C-c C-l" . org-insert-link) + ("s-<up>" . org-priority-up) + ("s-<down>" . org-priority-down) + ("C-c N" . org-narrow-to-subtree) + ("C-c >" . cj/org-narrow-forward) + ("C-c <" . cj/org-narrow-backwards) + ("<f5>" . org-reveal) + ("C-c <ESC>" . widen)) + (:map org-table-map + ("r i" . org-table-insert-row) + ("r d" . org-table-kill-row) + ("c i" . org-table-insert-column) + ("c d" . org-table-delete-column)) + + ;; backward and forward day are ',' and '.' + ;; shift & meta moves by week or year + ;; C-. jumps to today + ;; original keybindings blocked by windmove keys + ;; these are consistent with plain-old calendar mode + (:map org-read-date-minibuffer-local-map + ("," . (lambda () (interactive) + (org-eval-in-calendar '(calendar-backward-day 1)))) + ("." . (lambda () (interactive) + (org-eval-in-calendar '(calendar-forward-day 1)))) + ("<" . (lambda () (interactive) + (org-eval-in-calendar '(calendar-backward-month 1)))) + (">" . (lambda () (interactive) + (org-eval-in-calendar '(calendar-forward-month 1)))) + ("M-," . (lambda () (interactive) + (org-eval-in-calendar '(calendar-backward-year 1)))) + ("M-." . (lambda () (interactive) + (org-eval-in-calendar '(calendar-forward-year 1))))) + + :init + ;; windmove's keybindings conflict with org-agenda-todo-nextset/previousset keybindings + ;; solution: map the super key so that + ;; - super up/down increases and decreases the priority + ;; - super left/right changes the todo state + (setq org-replace-disputed-keys t) + (custom-set-variables + '(org-disputed-keys + '(([(shift left)] . [(super left)]) + ([(shift right)] . [(super right)]) + ([(shift up)] . [(super up)]) + ([(shift down)] . [(super down)]) + ([(control shift right)] . [(meta shift +)]) + ([(control shift left)] . [(meta shift -)])))) + + (defun cj/org-narrow-forward () + "Narrow to the next subtree at the same level." + (interactive) + (widen) + (org-forward-heading-same-level 1) + (org-narrow-to-subtree)) + + (defun cj/org-narrow-backwards () + "Narrow to the previous subtree at the same level." + (interactive) + (widen) + (org-backward-heading-same-level 1) + (org-narrow-to-subtree)) + + :hook + (org-mode . flyspell-mode) + (org-mode . turn-on-visual-line-mode) + (org-mode . org-indent-mode) + + :config + ;; bug workaround for org-element--get-category: Invalid function: org-element-with-disabled-cache + ;; https://github.com/doomemacs/doomemacs/issues/7347 + ;;(load-library "org-element.el") + + (cj/org-general-settings) + (cj/org-appearance-settings) + (cj/org-todo-settings)) + + +;; ------------------------------- Org Superstar ------------------------------- + +;; nicer bullets than simple asterisks. +(use-package org-superstar + :after org + :config + (org-superstar-configure-like-org-bullets) + (setq org-superstar-leading-bullet ?\s) + (add-hook 'org-mode-hook (lambda () (org-superstar-mode 1)))) + +;; ------------------------------- Org-Checklist ------------------------------- +;; needed for org-habits to reset checklists once task is complete +;; this was a part of org-contrib which was deprecated + +(use-package org-checklist + :ensure nil ;; in custom folder + :after org + :load-path "custom/org-checklist.el") + +;; -------------------------- Org Link To Current File ------------------------- +;; get a link to the file the current buffer is associated with. + +(defun cj/org-link-to-current-buffer-file () + "Create an Org mode link to the current file and copy it to the clipboard. + +The link is formatted as [[file:<file-path>][<file-name>]], +where <file-path> is the full path to the current file and <file-name> +is the name of the current file without any directory information. + +If the current buffer is not associated with a file, the function will throw an +error." + (interactive) + (if (buffer-file-name) + (let* ((filename (buffer-file-name)) + (description (file-name-nondirectory filename)) + (link (format "[[file:%s][%s]]" filename description))) + (kill-new link) + (message "Copied Org link to current file to clipboard: %s" link)) + (user-error "Buffer isn't associated with a file, so no link sent to clipboard"))) + +(provide 'org-config) +;;; org-config.el ends here diff --git a/modules/org-contacts-config.el b/modules/org-contacts-config.el new file mode 100644 index 00000000..899df28d --- /dev/null +++ b/modules/org-contacts-config.el @@ -0,0 +1,205 @@ +;;; org-contacts-config.el --- Org Contacts Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Configuration for org-contacts, providing contact management within org-mode. +;; Integrates with mu4e for email address completion and org-roam for linking +;; contacts to projects and notes. +;; +;; Email completion functionality has been moved to mu4e-org-contacts-integration.el + +;;; Code: + +(require 'user-constants) + +;; --------------------------- Org Agenda Integration -------------------------- + +(with-eval-after-load 'org-agenda + ;; Remove the direct hook first (in case it's already added) + (remove-hook 'org-agenda-finalize-hook 'org-contacts-anniversaries) + + ;; Add a wrapper function that ensures proper context + (defun cj/org-contacts-anniversaries-safe () + "Safely call org-contacts-anniversaries with required bindings." + (require 'diary-lib) + ;; These need to be dynamically bound for diary functions + (defvar date) + (defvar entry) + (defvar original-date) + (let ((date (calendar-current-date)) + (entry "") + (original-date (calendar-current-date))) + (ignore-errors + (org-contacts-anniversaries)))) + + ;; Use the safe wrapper instead + (add-hook 'org-agenda-finalize-hook 'cj/org-contacts-anniversaries-safe)) + +;;; ---------------------------- Capture Templates ------------------------------ + +(with-eval-after-load 'org-capture + (add-to-list 'org-capture-templates + '("C" "Contact" entry (file+headline contacts-file "Contacts") + "* %(cj/org-contacts-template-name) + +Added: %U"))) + +(defun cj/org-contacts-template-name () + "Get name for contact template from context." + (let ((name (when (boundp 'cj/contact-name) cj/contact-name))) + (or name + (when (eq major-mode 'mu4e-headers-mode) + (mu4e-message-field (mu4e-message-at-point) :from-or-to)) + (when (eq major-mode 'mu4e-view-mode) + (mu4e-message-field mu4e~view-message :from-or-to)) + (read-string "Name: ")))) + +(defun cj/org-contacts-template-email () + "Get email for contact template from context." + (let ((email (when (boundp 'cj/contact-email) cj/contact-email))) + (or email + (when (eq major-mode 'mu4e-headers-mode) + (let ((from (mu4e-message-field (mu4e-message-at-point) :from))) + (when from (cdr (car from))))) + (when (eq major-mode 'mu4e-view-mode) + (let ((from (mu4e-message-field mu4e~view-message :from))) + (when from (cdr (car from))))) + (read-string "Email: ")))) + +;;; ------------------------- Quick Contact Functions --------------------------- + +(defun cj/org-contacts-find () + "Find and open a contact." + (interactive) + (find-file contacts-file) + (goto-char (point-min)) + (let ((contact (completing-read "Contact: " + (org-map-entries + (lambda () (nth 4 (org-heading-components))) + nil (list contacts-file))))) + (goto-char (point-min)) + (search-forward contact) + (org-fold-show-entry) + (org-reveal))) + +(defun cj/org-contacts-new () + "Create a new contact." + (interactive) + (org-capture nil "C")) + +(defun cj/org-contacts-view-all () + "View all contacts in a column view." + (interactive) + (find-file contacts-file) + (org-columns)) + +;;; -------------------------- Org-Roam Integration ----------------------------- + +;; (with-eval-after-load 'org-roam +;; (defun cj/org-contacts-link-to-roam () +;; "Link current contact to an org-roam node." +;; (interactive) +;; (when (eq major-mode 'org-mode) +;; (let ((contact-name (org-entry-get (point) "ITEM"))) +;; (org-set-property "ROAM_REFS" +;; (org-roam-node-id +;; (org-roam-node-read nil nil nil nil +;; :initial-input contact-name))))))) + +;;; ----------------------------- Birthday Agenda -------------------------------- + +(with-eval-after-load 'org-agenda + ;; Add birthdays to agenda + (setq org-agenda-include-diary t) + + ;; Custom agenda command for upcoming birthdays + (add-to-list 'org-agenda-custom-commands + '("b" "Birthdays and Anniversaries" + ((tags-todo "BIRTHDAY|ANNIVERSARY" + ((org-agenda-overriding-header "Upcoming Birthdays and Anniversaries") + (org-agenda-sorting-strategy '(time-up)))))))) + +;;; ---------------------------- Core Contact Data Functions --------------------------- + +(defun cj/org-contacts--props-matching (entry pattern) + "Return all property values from ENTRY whose keys match PATTERN (a regexp)." + (let ((props (nth 2 entry))) + (delq nil + (mapcar (lambda (prop) + (when (string-match-p pattern (car prop)) + (cdr prop))) + props)))) + +(defun cj/get-all-contact-emails () + "Retrieve all contact emails from org-contacts database. +Returns a list of formatted strings like \"Name <email@example.com>\". +This is the core function used by the mu4e integration module." + (let ((contacts (org-contacts-db))) + (delq nil + (mapcan (lambda (e) + (let* ((name (car e)) + ;; This returns a LIST of email strings + (email-strings (cj/org-contacts--props-matching e "EMAIL"))) + ;; Need mapcan here to handle the list + (mapcan (lambda (email-str) + (when (and email-str (string-match-p "[^[:space:]]" email-str)) + (mapcar (lambda (email) + (format "%s <%s>" name (string-trim email))) + (split-string email-str "[,;[:space:]]+" t)))) + email-strings))) + contacts)))) + +;; Simple insertion function for use outside of mu4e +(defun cj/insert-contact-email () + "Select and insert a contact's email address at point. +For use outside of mu4e compose buffers. In mu4e, the integration +module provides more sophisticated completion." + (interactive) + (let* ((items (cj/get-all-contact-emails)) + (selected (completing-read "Contact: " items nil t))) + (insert selected))) + +;;; -------------------------------- Org Contacts -------------------------------- + +(use-package org-contacts + :after (org mu4e) + :custom + (org-contacts-files (list contacts-file)) + :config + (require 'mu4e) + ;; Basic settings + (setq org-contacts-icon-use-gravatar nil) ; Don't fetch gravatars + + ;; Birthday and anniversary handling + (setq org-contacts-birthday-format "It's %l's birthday today! 🎂") + (setq org-contacts-anniversary-format "%l's anniversary 💑") + + ;; Email address formatting + (setq org-contacts-email-link-description-format "%s <%e>") + + (setq mu4e-org-contacts-file contacts-file) + (add-to-list 'mu4e-headers-actions + '("org-contact-add" . mu4e-action-add-org-contact) t) + (add-to-list 'mu4e-view-actions + '("org-contact-add" . mu4e-action-add-org-contact) t) + + ;; Disable mu4e's built-in completion in favor of our custom solution + (setq mu4e-compose-complete-addresses nil)) + +;;; ---------------------------- Org-Contacts Keymap ---------------------------- + +;; Keymap for `org-contacts' commands +(defvar cj/org-contacts-map + (let ((map (make-sparse-keymap))) + (define-key map "f" 'cj/org-contacts-find) ;; find contact + (define-key map "n" 'cj/org-contacts-new) ;; new contact + (define-key map "e" 'cj/insert-contact-email) ;; inserts email from org-contact + (define-key map "v" 'cj/org-contacts-view-all) ;; view all contacts + map) + "Keymap for `org-contacts' commands.") + +;; Bind the org-contacts map to the C-c C prefix +(global-set-key (kbd "C-c C") cj/org-contacts-map) + +(provide 'org-contacts-config) +;;; org-contacts-config.el ends here
\ No newline at end of file diff --git a/modules/org-drill-config.el b/modules/org-drill-config.el new file mode 100644 index 00000000..ea3adebf --- /dev/null +++ b/modules/org-drill-config.el @@ -0,0 +1,109 @@ +;;; org-drill-config.el --- Org Drill Settings -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;;; Commentary: +;; +;; Notes: Org-Drill +;; Start out your org-drill with C-d s, then select your file. + +;; the javascript bookmark I use to capture information from the web for org-drill files: +;; javascript:location.href='org-protocol://capture?template=d&url=%27+encodeURIComponent(location.href)+%27&title=%27+encodeURIComponent(document.title)+%27&body=%27+encodeURIComponent(window.getSelection());void(0); + +;; create a new bookmark and add "Drill Entry" to the name field and the above +;; snippet to the URL field. + +;;; Code: + +(require 'user-constants) +(require 'org-capture-config) ;; for adding org-capture-templates +(require 'cl-lib) + +;; --------------------------------- Org Drill --------------------------------- + +(use-package org-drill + :after org + :config + (setq org-drill-leech-failure-threshold 50) ;; leech cards = 50 wrong anwers + (setq org-drill-leech-method 'warn) ;; leech cards show warnings + (setq org-drill-use-visible-cloze-face-p t) ;; cloze text show up in a different font + (setq org-drill-hide-item-headings-p t) ;; don't show heading text + (setq org-drill-maximum-items-per-session 1000) ;; drill sessions end after 1000 cards + (setq org-drill-maximum-duration 60) ;; each drill session can last up to a an hour + (setq org-drill-add-random-noise-to-intervals-p t) ;; slightly vary number of days to repetition + + (defun cj/drill-start () + "Prompt user to pick a drill org file, then start an org-drill session." + (interactive) + (let* ((choices (directory-files drill-dir nil "^[^.].*\\.org$")) + (chosen-drill-file (completing-read "Choose Flashcard File:" choices))) + (find-file (concat drill-dir chosen-drill-file)) + (org-drill))) + + (defun cj/drill-edit () + "Prompts the user to pick a drill org file, then opens it for editing." + (interactive) + (let* ((choices (directory-files drill-dir nil "^[^.].*\\.org$"))) + (find-file chosen-drill-file))) + + (defun cj/drill-capture () + "Quickly capture a drill question." + (interactive) + (org-capture nil "d")) + + (defun cj/drill-refile () + "Refile to a drill file." + (interactive) + (setq org-refile-targets '((nil :maxlevel . 1) + (drill-dir :maxlevel . 1))) + (call-interactively 'org-refile)) + + ;; add useful org drill capture templates + (setq org-capture-templates + (append org-capture-templates + '(("d" "Drill Question - Web" entry + (file (lambda () + (let ((files (directory-files drill-dir nil "^[^.].*\\.org$"))) + (expand-file-name + (completing-read "Choose file: " files) + drill-dir)))) + "* Item :drill:\n%?\n** Answer\n%i\nSource: [[%:link][%:description]]\nCaptured On: %U" :prepend t) + + ("b" "Drill Question - EPUB" entry + (file (lambda () + (let ((files (directory-files drill-dir nil "^[^.].*\\.org$"))) + (expand-file-name + (completing-read "Choose file: " files) + drill-dir)))) + "* Item :drill:\n%?\n** Answer\n%i\nSource: [[%:link][%(buffer-name (org-capture-get :original-buffer))]]\nCaptured On: %U" :prepend t) + + ("f" "Drill Question - PDF" entry + (file (lambda () + (let ((files (directory-files drill-dir nil "^[^.].*\\.org$"))) + (expand-file-name + (completing-read "Choose file: " files) + drill-dir)))) + "* Item :drill:\n%?\n** Answer\n%(cj/org-capture-pdf-active-region)\nSource:[[%L][%(buffer-name (org-capture-get :original-buffer))]]\nCaptured On: %U" :prepend t))))) + +;; ------------------------------ Org Drill Keymap ----------------------------- + +;; Buffer & file operations prefix and keymap +(define-prefix-command 'cj/drill-map nil + "Keymap for org-drill.") +(define-key cj/custom-keymap "D" 'cj/drill-map) +(define-key cj/drill-map "s" 'cj/drill-start) +(define-key cj/drill-map "e" 'cj/drill-edit) +(define-key cj/drill-map "c" 'cj/drill-capture) +(define-key cj/drill-map "r" 'cj/drill-refile) +(define-key cj/drill-map "R" 'org-drill-resume) + +;;(define-key cj/drill-map "P" 'cj/disabled) +;;(define-key cj/drill-map "b" 'cj/disabled) +;;(define-key cj/drill-map "d" 'cj/disabled) +;;(define-key cj/drill-map "l" 'cj/disabled) +;;(define-key cj/drill-map "m" 'cj/disabled) +;;(define-key cj/drill-map "p" 'cj/disabled) +;;(define-key cj/drill-map "t" 'cj/disabled) +;;(define-key cj/drill-map "x" 'cj/disabled) + + +(provide 'org-drill-config) +;;; org-drill-config.el ends here. diff --git a/modules/org-export-config.el b/modules/org-export-config.el new file mode 100644 index 00000000..43329cc3 --- /dev/null +++ b/modules/org-export-config.el @@ -0,0 +1,162 @@ +;;; org-export-config.el --- Org Export Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; This module configures Org mode's export capabilities, providing multiple +;; backend options for converting Org documents to various formats. +;; +;; Built-in backends configured: +;; - LaTeX/PDF: Academic documents and presentations +;; - HTML: Web publishing with HTML5 support +;; - Markdown: README files and web content +;; - ODT: Office documents for LibreOffice/MS Word +;; - Texinfo: GNU documentation and Info files +;; +;; Extended via Pandoc: +;; - Additional formats: DOCX, EPUB, reveal.js presentations +;; - Self-contained HTML exports with embedded resources +;; - Custom PDF export with Zathura integration +;; +;; Key features: +;; - UTF-8 encoding enforced across all backends +;; - Subtree export as default scope +;; - reveal.js presentations with CDN or local embedding options +;; +;;; Code: + +;; --------------------------------- Org Export -------------------------------- + +(use-package ox + :defer .5 + :ensure nil + :after org + :config + ;; load the built-in backends + (dolist (feat '(ox-odt ox-latex ox-md ox-ascii)) + (require feat)) + + ;; now tell Org exactly which backends to use + (setq org-export-backends '(odt latex md ascii)) + + ;; Other Settings + (setq org-export-preserve-breaks t) ;; keep line breaks in all Org export back-ends + (setq org-export-coding-system 'utf-8) ;; force utf-8 in org + (setq org-export-headline-levels 6) ;; export headlines 6 levels deep + (setq org-export-with-section-numbers nil) ;; export without section numbers by default + (setq org-export-with-tags nil) ;; export without tags by default + (setq org-export-with-tasks '("TODO")) ;; export with tasks by default + (setq org-export-with-tasks nil) ;; export WITHOUT tasks by default + (setq org-export-with-toc t) ;; export WITH table of contents by default + (setq org-export-initial-scope 'subtree) ;; 'buffer is your other choice + (setq org-export-with-author nil)) ;; export without author by default + +(use-package ox-html + :ensure nil ; Built into Org + :defer t + :after ox + :config + (setq org-html-postamble nil) + (setq org-html-html5-fancy t) + (setq org-html-head-include-default-style nil)) + + +(use-package ox-texinfo + :ensure nil ; Built into Org + :defer t + :after ox + :config + (setq org-texinfo-coding-system 'utf-8) + (setq org-texinfo-default-class "info") + (add-to-list 'org-export-backends 'texinfo)) + +(use-package ox-pandoc + :defer t + :after ox + :config + ;; Set default options for pandoc + (setq org-pandoc-options '((standalone . t) + (mathjax . t))) + + ;; Configure reveal.js specific options + (setq org-pandoc-options-for-revealjs + '((standalone . t) + (variable . "revealjs-url=https://cdn.jsdelivr.net/npm/reveal.js") + (variable . "theme=black") ; or white, league, beige, sky, night, serif, simple, solarized + (variable . "transition=slide") ; none, fade, slide, convex, concave, zoom + (variable . "slideNumber=true") + (variable . "hash=true") + (self-contained . t))) ; This embeds CSS/JS when possible + + ;; Custom function for self-contained reveal.js export + (defun my/org-pandoc-export-to-revealjs-standalone () + "Export to reveal.js with embedded dependencies." + (interactive) + (let* ((org-pandoc-options-for-revealjs + (append org-pandoc-options-for-revealjs + '((self-contained . t) + (embed-resources . t)))) ; pandoc 3.0+ option + (html-file (org-pandoc-export-to-revealjs))) + (when html-file + (browse-url-of-file html-file) + (message "Opened reveal.js presentation: %s" html-file)))) + + ;; Alternative: Download and embed local reveal.js + (defun my/org-pandoc-export-to-revealjs-local () + "Export to reveal.js using local copy of reveal.js." + (interactive) + (let* ((reveal-dir (expand-file-name "reveal.js" user-emacs-directory)) + (org-pandoc-options-for-revealjs + `((standalone . t) + (variable . ,(format "revealjs-url=%s" reveal-dir)) + (variable . "theme=black") + (variable . "transition=slide") + (variable . "slideNumber=true")))) + (unless (file-exists-p reveal-dir) + (message "Downloading reveal.js...") + (shell-command + (format "git clone https://github.com/hakimel/reveal.js.git %s" reveal-dir))) + (org-pandoc-export-to-revealjs))) + + ;; Configure specific format options (your existing config) + (setq org-pandoc-options-for-latex-pdf '((pdf-engine . "pdflatex"))) + (setq org-pandoc-options-for-html5 '((html-q-tags . t) + (self-contained . t))) + (setq org-pandoc-options-for-markdown '((atx-headers . t))) + + ;; Custom function to export to PDF and open with Zathura + (defun my/org-pandoc-export-to-pdf-and-open () + "Export to PDF via pandoc and open with Zathura." + (interactive) + (let ((pdf-file (org-pandoc-export-to-latex-pdf))) + (when pdf-file + (start-process "zathura-pdf" nil "zathura" pdf-file) + (message "Opened %s in Zathura" pdf-file)))) + + ;; Updated menu entries with reveal.js options + (setq org-pandoc-menu-entry + '((?4 "to html5 and open" org-pandoc-export-to-html5-and-open) + (?$ "as html5" org-pandoc-export-as-html5) + (?r "to reveal.js (CDN) and open" org-pandoc-export-to-revealjs-and-open) + (?R "to reveal.js (self-contained)" my/org-pandoc-export-to-revealjs-standalone) + (?< "to markdown" org-pandoc-export-to-markdown) + (?d "to docx and open" org-pandoc-export-to-docx-and-open) + (?z "to pdf and open (Zathura)" my/org-pandoc-export-to-pdf-and-open)))) + +;; hugo markdown +;; (use-package ox-hugo +;; :after ox) + +;; github flavored markdown +;; (use-package ox-gfm +;; :after ox) + +;; JIRA markup +;; (use-package ox-jira +;; :after ox) + +;; Confluence Wiki markup +;; (use-package ox-confluence +;; :after ox) + +(provide 'org-export-config) +;;; org-export-config.el ends here. diff --git a/modules/org-gcal-config.el b/modules/org-gcal-config.el new file mode 100644 index 00000000..7f9b656d --- /dev/null +++ b/modules/org-gcal-config.el @@ -0,0 +1,92 @@ +;;; org-gcal-config.el --- Google Calendar synchronization for Org-mode -*- lexical-binding: t; coding: utf-8; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; +;; Bidirectional synchronization between Google Calendar and Org-mode using org-gcal. +;; - Credential management via authinfo.gpg +;; - Automatic archival of past events +;; - Automatic removal of cancelled events, but with TODOs added for visibility +;; - System timezone configuration via functions in host-environment +;; - No notifications on syncing +;; - Initial automatic sync post Emacs startup. No auto resync'ing. +;; (my calendar doesn't change hourly and I want fewer distractions and slowdowns). +;; if you need it: https://github.com/kidd/org-gcal.el?tab=readme-ov-file#sync-automatically-at-regular-times +;; - Validates existing oath2-auto.plist file or creates it to avoid the issue mentioned here: +;; https://github.com/kidd/org-gcal.el?tab=readme-ov-file#note +;; +;; Prerequisites: +;; 1. Create OAuth 2.0 credentials in Google Cloud Console +;; See: https://github.com/kidd/org-gcal.el?tab=readme-ov-file#installation +;; 2. Store credentials in ~/.authinfo.gpg with this format: +;; machine org-gcal login YOUR_CLIENT_ID password YOUR_CLIENT_SECRET +;; 3. Define `gcal-file' in user-constants (location of org file to hold sync'd events). +;; +;; Usage: +;; - Manual sync: C-; g (or M-x org-gcal-sync) +;; +;; Note: +;; This configuration creates oauth2-auto.plist on first run to prevent sync errors. +;; Passphrase caching is enabled. +;; +;;; Code: + +(require 'host-environment) +(require 'user-constants) + +(use-package org-gcal + :defer t ;; unless idle timer is set below (currently disabled in config) + :bind ("C-; g" . org-gcal-sync) + :preface + ;; org-gcal stumbles if this doesn't exist before initial sync + (let ((oauth-file (concat user-emacs-directory "oauth2-auto.plist"))) + (unless (file-exists-p oauth-file) + (with-temp-buffer + (write-file oauth-file)))) + + :init + ;; identify calendar to sync and it's destination + (setq org-gcal-fetch-file-alist `(("craigmartinjennings@gmail.com" . ,gcal-file))) + + (setq org-gcal-up-days 30) ;; Look 30 days back + (setq org-gcal-down-days 60) ;; Look 60 days forward + (setq org-gcal-auto-archive t) ;; auto-archive old events + (setq org-gcal-notify-p nil) ;; nil disables; t enables notifications + (setq org-gcal-remove-api-cancelled-events t) ;; auto-remove cancelled events + (setq org-gcal-update-cancelled-events-with-todo t) ;; todo cancelled events for visibility + + :config + ;; Retrieve credentials from authinfo.gpg + (require 'auth-source) + (let ((credentials (car (auth-source-search :host "org-gcal" :require '(:user :secret))))) + (when credentials + (setq org-gcal-client-id (plist-get credentials :user)) + ;; The secret might be a function, so we need to handle that + (let ((secret (plist-get credentials :secret))) + (setq org-gcal-client-secret + (if (functionp secret) + (funcall secret) + secret))))) + + + ;; Enable plstore passphrase caching after org-gcal loads + (require 'plstore) + (setq plstore-cache-passphrase-for-symmetric-encryption t) + + ;; set org-gcal timezone based on system timezone + (setq org-gcal-local-timezone (cj/detect-system-timezone)) + + ;; Reload client credentials + (org-gcal-reload-client-id-secret)) + +;; Set up automatic initial sync on boot with error handling +(run-with-idle-timer + 5 nil + (lambda () + (condition-case err + (org-gcal-sync) + (error (message "org-gcal: Initial sync failed: %s" err))))) + +(provide 'org-gcal-config) +;;; org-gcal-config.el ends here diff --git a/modules/org-noter-config.el b/modules/org-noter-config.el new file mode 100644 index 00000000..a3968aa6 --- /dev/null +++ b/modules/org-noter-config.el @@ -0,0 +1,60 @@ +;;; org-noter-config.el --- -*- coding: utf-8; lexical-binding: t; -*- + +;;; Commentary: +;; Open a PDF or DjVu file, hit F6, and org-noter splits the frame with notes beside the document. +;; Notes live under ~/sync/org-noter/reading-notes.org by default; adjust the path when prompted the first time. +;; Use org-noter capture keys while annotating—`C-c n c` checks linked documents, and `C-c n u` rewrites stale paths after moving files. +;; Sessions resume where you stopped thanks to automatic location saves. + +;;; Code: + +(use-package djvu + :defer 0.5) + +(use-package pdf-tools + :defer t + :mode ("\\.pdf\\'" . pdf-view-mode) + :config + (pdf-tools-install :no-query)) + +(use-package org-pdftools + :after (org pdf-tools) + :hook (org-mode . org-pdftools-setup-link)) + +(use-package org-noter + :after (:any org pdf-tools djvu) + :commands org-noter + :bind ("<f6>" . org-noter) + :config + ;; Basic settings + (setq org-noter-always-create-frame nil) + (setq org-noter-notes-window-location 'horizontal-split) + (setq org-noter-notes-window-behavior '(start scroll)) ; note: must be a list! + (setq org-noter-doc-split-fraction '(0.5 . 0.5)) + (setq org-noter-notes-search-path (list (concat sync-dir "/org-noter/"))) + (setq org-noter-default-notes-file-names '("reading-notes.org")) + (setq org-noter-separate-notes-from-heading t) + (setq org-noter-kill-frame-at-session-end t) ; kill frame when closing session + + (setq org-noter-auto-save-last-location t) ; Save position when closing + (setq org-noter-insert-selected-text-inside-note t) ; Insert highlighted text + (setq org-noter-closest-tipping-point 0.3) ; When to show closest previous note + (setq org-noter-hide-other t) ; Hide unrelated notes + + ;; Load the integration file if it exists in your config + (let ((integration-file (expand-file-name "org-noter-integration.el" + (file-name-directory (locate-library "org-noter"))))) + (when (file-exists-p integration-file) + (load integration-file))) + + ;; If you want to use the org-noter-pdftools integration features + (when (featurep 'org-noter-integration) + (setq org-noter-use-pdftools-link-location t) + (setq org-noter-use-org-id t) + (setq org-noter-use-unique-org-id t)) + (org-noter-enable-org-roam-integration) + + (org-noter-enable-org-roam-integration)) + +(provide 'org-noter-config) +;;; org-noter-config.el ends here. diff --git a/modules/org-refile-config.el b/modules/org-refile-config.el new file mode 100644 index 00000000..7b50604a --- /dev/null +++ b/modules/org-refile-config.el @@ -0,0 +1,78 @@ +;;; org-refile-config.el --- Org Refile Customizations -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;;; Commentary: +;; Configuration and custom functions for org-mode refiling. + +;;; Code: + +;; ----------------------------- Org Refile Targets ---------------------------- +;; sets refile targets +;; - adds project files in org-roam to the refile targets +;; - adds todo.org files in subdirectories of the code and project directories + +(defun cj/build-org-refile-targets () + "Build =org-refile-targets=." + (interactive) + (let ((new-files + (list + (cons inbox-file '(:maxlevel . 1)) + (cons reference-file '(:maxlevel . 2)) + (cons schedule-file '(:maxlevel . 1))))) + ;; Extend with org-roam files if available AND org-roam is loaded + (when (and (fboundp 'cj/org-roam-list-notes-by-tag) + (fboundp 'org-roam-node-list)) ; <-- Add this check + (let* ((project-and-topic-files + (append (cj/org-roam-list-notes-by-tag "Project") + (cj/org-roam-list-notes-by-tag "Topic"))) + (file-rule '(:maxlevel . 1))) + (dolist (file project-and-topic-files) + (unless (assoc file new-files) + (push (cons file file-rule) new-files))))) + ;; Add todo.org files from known directories + (dolist (dir (list user-emacs-directory code-dir projects-dir)) + (let* ((todo-files (directory-files-recursively + dir "^[Tt][Oo][Dd][Oo]\\.[Oo][Rr][Gg]$")) + (file-rule '(:maxlevel . 1))) + (dolist (file todo-files) + (unless (assoc file new-files) + (push (cons file file-rule) new-files))))) + (setq org-refile-targets (nreverse new-files)))) + +(add-hook 'emacs-startup-hook #'cj/build-org-refile-targets) + +(defun cj/org-refile (&optional ARG DEFAULT-BUFFER RFLOC MSG) + "Simply rebuilds the refile targets before calling org-refile. + +ARG DEFAULT-BUFFER RFLOC and MSG parameters passed to org-refile." + (interactive "P") + (cj/build-org-refile-targets) + (org-refile ARG DEFAULT-BUFFER RFLOC MSG)) + +;; ----------------------------- Org Refile In File ---------------------------- +;; convenience function for scoping the refile candidates to the current buffer. + +(defun cj/org-refile-in-file () + "Refile to a target within the current file and save the buffer." + (interactive) + (let ((org-refile-targets `(((,(buffer-file-name)) :maxlevel . 6)))) + (call-interactively 'org-refile) + (save-buffer))) + + +;; --------------------------------- Org Refile -------------------------------- + +(use-package org-refile + :ensure nil ;; built-in + :defer .5 + :bind + (:map org-mode-map + ("C-c C-w" . cj/org-refile) + ("C-c w" . cj/org-refile-in-file)) + :config + ;; save all open org buffers after a refile is complete + (advice-add 'org-refile :after + (lambda (&rest _) + (org-save-all-org-buffers)))) + +(provide 'org-refile-config) +;;; org-refile-config.el ends here. diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el new file mode 100644 index 00000000..741e42cf --- /dev/null +++ b/modules/org-roam-config.el @@ -0,0 +1,178 @@ +;;; org-roam-config.el --- Org-Roam Config -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;;; Commentary: +;; Currently a work in progress. The initial version of this was taken from David Wilson: +;; https://systemcrafters.net/build-a-second-brain-in-emacs/5-org-roam-hacks/ + +;;; Code: + +(require 'user-constants) + +;; ---------------------------------- Org Roam --------------------------------- + +(use-package org-roam + :after org + :defer 1 + :commands (org-roam-node-find org-roam-node-insert) + :hook (after-init . org-roam-db-autosync-mode) + :custom + (org-roam-directory roam-dir) + (org-roam-dailies-directory journals-dir) + (org-roam-completion-everywhere t) + (org-roam-dailies-capture-templates + '(("d" "default" entry "* %<%I:%M:%S %p %Z> %?" + :if-new (file+head "%<%Y-%m-%d>.org" + "#+FILETAGS: Journal #+TITLE: %<%Y-%m-%d>")))) + + (org-roam-capture-templates + `(("d" "default" plain "%?" + :if-new (file+head "%<%Y%m%d%H%M%S>-${slug}.org" + "#+TITLE: ${title}\n") + :unnarrowed t) + + ("v" "v2mom" plain + (file ,(concat user-emacs-directory "org-roam-templates/v2mom.org")) + :if-new (file+head "%<%Y%m%d%H%M%S>-${slug}.org" "") + :unnarrowed t) + + ("r" "recipe" plain + (file ,(concat user-emacs-directory "org-roam-templates/recipe.org")) + :if-new (file+head "recipes/%<%Y%m%d%H%M%S>-${slug}.org" "") + :unnarrowed t) + + ("t" "topic" plain + (file ,(concat user-emacs-directory "org-roam-templates/topic.org")) + :if-new (file+head "%<%Y%m%d%H%M%S>-${slug}.org" "") + :unnarrowed t))) + + :bind (("C-c n l" . org-roam-buffer-toggle) + ("C-c n f" . org-roam-node-find) + ("C-c n p" . cj/org-roam-find-node-project) + ("C-c n r" . cj/org-roam-find-node-recipe) + ("C-c n t" . cj/org-roam-find-node-topic) + ("C-c n i" . org-roam-node-insert) + ("C-c n w" . cj/org-roam-find-node-webclip) + :map org-mode-map + ("C-M-i" . completion-at-point) + :map org-roam-dailies-map + ("Y" . org-roam-dailies-capture-yesterday) + ("T" . org-roam-dailies-capture-tomorrow)) + :bind-keymap + ("C-c n d" . org-roam-dailies-map) + :config + (setq org-log-done 'time) + (setq org-agenda-timegrid-use-ampm t) + + (when (fboundp 'cj/build-org-refile-targets) + (cj/build-org-refile-targets)) + + ;; remove/disable if performance slows + ;; (setq org-element-use-cache nil) ;; disables caching org files + + ;; move closed tasks to today's journal when marked done + (add-to-list 'org-after-todo-state-change-hook + (lambda () + (when (equal org-state "DONE") + (cj/org-roam-copy-todo-to-today)))) + + (require 'org-roam-dailies) ;; Ensures the keymap is available + (org-roam-db-autosync-mode)) + +;; ------------------------- Org Roam Insert Immediate ------------------------- + +(defun cj/org-roam-node-insert-immediate (arg &rest args) + "Create new node and insert its link immediately. +This is mainly a wrapper around org-roam-node-insert to achieve immediate finish +to the capture. The prefix ARG and ARGS are the filter function and the rest of +the arguments that org-roam-node-insert expects." + (interactive "P") + (let ((args (cons arg args)) + (org-roam-capture-templates (list (append (car org-roam-capture-templates) + '(:immediate-finish t))))) + (apply #'org-roam-node-insert args))) +(global-set-key (kbd "C-c n I") 'cj/org-roam-node-insert-immediate) + +;; ------------------------- Tag Listing And Filtering ------------------------- + +(defun cj/org-roam-filter-by-tag (tag-name) + (lambda (node) + (member tag-name (org-roam-node-tags node)))) + +(defun cj/org-roam-list-notes-by-tag (tag-name) + (mapcar #'org-roam-node-file + (seq-filter + (cj/org-roam-filter-by-tag tag-name) + (org-roam-node-list)))) + +;; -------------------------- Org Roam Find Functions -------------------------- + +(defun cj/org-roam-find-node (tag template-key template-file &optional subdir) + "List all nodes of type TAG in completing read for selection or creation. +Interactively find or create an Org-roam node with a given TAG. Newly +created nodes are added to the agenda and follow a template defined by +TEMPLATE-KEY and TEMPLATE-FILE." + (interactive) + (add-hook 'org-capture-after-finalize-hook + #'cj/org-roam-add-node-to-agenda-files-finalize-hook) + (org-roam-node-find + nil nil (cj/org-roam-filter-by-tag tag) nil + :templates + `((,template-key ,tag plain (file ,template-file) + :if-new (file+head ,(concat (or subdir "") "%<%Y%m%d%H%M%S>-${slug}.org") "") + :unnarrowed t)))) + +(defun cj/org-roam-find-node-topic () + "List nodes of type \=`topic\=` in completing read for selection or creation." + (interactive) + (cj/org-roam-find-node "Topic" "t" (concat roam-dir "templates/topic.org"))) + +(defun cj/org-roam-find-node-recipe () + (interactive) + (cj/org-roam-find-node "Recipe" "r" (concat roam-dir "templates/recipe.org") "recipes/")) + +(defun cj/org-roam-find-node-project () + "List nodes of type \='project\=' in completing read for selection or creation." + + (interactive) + (cj/org-roam-find-node "Project" "p" (concat roam-dir "templates/project.org"))) + +;; ---------------------- Org Capture After Finalize Hook ---------------------- + +(defun cj/org-roam-add-node-to-agenda-files-finalize-hook () + "Add the captured project file to \='org-agenda-files\='." + ;; Remove the hook since it was added temporarily + (remove-hook 'org-capture-after-finalize-hook + #'cj/org-roam-add-node-to-agenda-files-finalize-hook) + + + ;; Add project file to the agenda list if the capture was confirmed + (unless org-note-abort + (with-current-buffer (org-capture-get :buffer) + (add-to-list 'org-agenda-files (buffer-file-name))))) + +;; ------------------------ Org Roam Copy Done To Daily ------------------------ + +(defun cj/org-roam-copy-todo-to-today () + "Copy completed tasks to today's daily org-roam node." + (interactive) + (let ((org-refile-keep t) ;; Set this to nil to delete the original! + (org-roam-dailies-capture-templates + '(("t" "tasks" entry "%?" + :if-new (file+head+olp "%<%Y-%m-%d>.org" + "#+FILETAGS: Journal +#+TITLE: %<%Y-%m-%d>\n" ("Completed Tasks"))))) + (org-after-refile-insert-hook #'save-buffer) + today-file + pos) + (save-window-excursion + (org-roam-dailies--capture (current-time) t) + (setq today-file (buffer-file-name)) + (setq pos (point))) + + ;; Only refile if the target file is different than the current file + (unless (equal (file-truename today-file) + (file-truename (buffer-file-name))) + (org-refile nil nil (list "Completed Tasks" today-file nil pos))))) + +(provide 'org-roam-config) +;;; org-roam-config.el ends here. diff --git a/modules/org-webclipper.el b/modules/org-webclipper.el new file mode 100644 index 00000000..c7b80499 --- /dev/null +++ b/modules/org-webclipper.el @@ -0,0 +1,145 @@ +;;; org-webclipper.el --- Web Page Clipping Workflow to Org Roam -*- coding: utf-8; lexical-binding: t; -*- + +;;; Commentary: +;; +;; Allows saving a copy of the page EWW is visiting for offline reading. +;; In other words, it's a "Pocket/Instapaper" that collects the articles in an Emacs org-mode file. +;; +;; I review the articles, then add the ones I want for future reference by moving it to an +;; org-roam file. +;; +;;; Code: + +(require 'user-constants) ;; for location of 'webclipped-file' + +;; ---------------------------- Org Webpage Clipper ---------------------------- + +(defun cj/org-webpage-clipper () + "Capture the current web page for later viewing in an Org file. + +Return the yanked content as a string so templates can insert it." + (interactive) + (let* ((source-buffer (org-capture-get :original-buffer)) + (source-mode (with-current-buffer source-buffer major-mode))) + (cond + ((eq source-mode 'w3m-mode) + (with-current-buffer source-buffer + (org-w3m-copy-for-org-mode))) + ((eq source-mode 'eww-mode) + (with-current-buffer source-buffer + (org-eww-copy-for-org-mode))) + (t + (error "Not valid -- must be in w3m or eww mode"))) + ;; extract the webpage content from the kill ring + (car kill-ring))) + +;; ------------------------------ Capture Template ----------------------------- + +(with-eval-after-load 'org-capture + ;; Ensure org-capture-templates exists before adding to it + (unless (boundp 'org-capture-templates) + (setq org-capture-templates nil)) + + ;; Add the webclipper template to org-capture-templates + (add-to-list 'org-capture-templates + '("w" "Web Page Clipper" entry + (file+headline webclipped-file "Webclipped Inbox") + "* %a\nURL: %L\nCaptured On:%U\n%(cj/org-webpage-clipper)\n" + :prepend t :immediate-finish t) + t)) + +;; ------------------------ Org-Branch To Org-Roam-Node ------------------------ + +(defun cj/org-link-get-description (text) + "Extract the description from an org link, or return the text unchanged. +If TEXT contains an org link like [[url][description]], return description. +If TEXT contains multiple links, only process the first one. +Otherwise return TEXT unchanged." + (if (string-match "\\[\\[\\([^]]+\\)\\]\\(?:\\[\\([^]]+\\)\\]\\)?\\]" text) + (let ((description (match-string 2 text)) + (url (match-string 1 text))) + ;; If there's a description, use it; otherwise use the URL + (or description url)) + text)) + +(defun cj/move-org-branch-to-roam () + "Move the org subtree at point to a new org-roam node. +The node filename will be timestamp-based with the heading name. +The heading becomes the node title, and the entire subtree is demoted to level 1. +If the heading contains a link, extract the description for the title." + (interactive) + (unless (org-at-heading-p) + (user-error "Not at an org heading")) + + (let* ((heading-components (org-heading-components)) + (current-level (nth 0 heading-components)) + (raw-title (nth 4 heading-components)) + ;; Extract clean title from potential link + (title (cj/org-link-get-description raw-title)) + (timestamp (format-time-string "%Y%m%d%H%M%S")) + ;; Convert title to filename-safe format + (title-slug (replace-regexp-in-string + "[^a-zA-Z0-9]+" "-" + (downcase title))) + ;; Remove leading/trailing hyphens + (title-slug (replace-regexp-in-string + "^-\\|-$" "" title-slug)) + (filename (format "%s-%s.org" timestamp title-slug)) + (filepath (expand-file-name filename org-roam-directory)) + ;; Generate a unique ID for the node + (node-id (org-id-new)) + ;; Store the subtree in a temporary buffer + subtree-content) + + ;; Copy the subtree content + (org-copy-subtree) + (setq subtree-content (current-kill 0)) + + ;; Now cut it to remove from original buffer + (org-cut-subtree) + + ;; Process the subtree to demote it to level 1 + (with-temp-buffer + (org-mode) + (insert subtree-content) + ;; Demote the entire tree so the top level becomes level 1 + (goto-char (point-min)) + (when (> current-level 1) + (let ((demote-count (- current-level 1))) + (while (re-search-forward "^\\*+ " nil t) + (beginning-of-line) + (dotimes (_ demote-count) + (when (looking-at "^\\*\\*") + (delete-char 1))) + (forward-line)))) + (setq subtree-content (buffer-string))) + + ;; Create the new org-roam file + (with-temp-file filepath + ;; Insert the org-roam template with ID at file level + (insert ":PROPERTIES:\n") + (insert ":ID: " node-id "\n") + (insert ":END:\n") + (insert "#+TITLE: " title "\n") + (insert "#+CATEGORY: " title "\n") + (insert "#+FILETAGS: Topic\n\n") + + ;; Insert the demoted subtree content + (insert subtree-content)) + + ;; Sync the org-roam database + (org-roam-db-sync) + + ;; Message to user + (message "'%s' added as an org-roam node." title))) + +;; ----------------------------- Webclipper Keymap ----------------------------- + +;; Buffer & file operations prefix and keymap +(define-prefix-command 'cj/webclipper-map nil + "Keymap for weblipper operations.") +(define-key cj/custom-keymap "w" 'cj/webclipper-map) +(define-key cj/webclipper-map "N" 'cj/move-org-branch-to-roam) ;; for node + +(provide 'org-webclipper) +;;; org-webclipper.el ends here. diff --git a/modules/pdf-config.el b/modules/pdf-config.el new file mode 100644 index 00000000..c89295bc --- /dev/null +++ b/modules/pdf-config.el @@ -0,0 +1,59 @@ +;;; pdf-config --- PDF Viewer Setup -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;;; Code: + +;; --------------------------------- PDF Tools --------------------------------- + +(use-package pdf-tools + :defer t + :mode (("\\.pdf\\'" . pdf-view-mode)) + :hook + (pdf-view-mode . pdf-view-midnight-minor-mode) + :custom + (pdf-view-display-size 'fit-page) + (pdf-view-resize-factor 1.1) + (pdf-view-midnight-colors '("#F1D5AC" . "#0F0E06")) ;; fg . bg + ;; Avoid searching for unicodes to speed up pdf-tools. + ;; ... and yes, 'ligther' is not a typo + (pdf-view-use-unicode-ligther nil) + ;; Enable HiDPI support, at the cost of memory. + (pdf-view-use-scaling t) + :bind + (:map pdf-view-mode-map + ("M" . pdf-view-midnight-minor-mode) + ("m" . bookmark-set) + ("C-=" . pdf-view-enlarge) + ("C--" . pdf-view-shrink) + ("C-c l" . org-store-link) + ("z" . (lambda () (interactive) (cj/open-file-with-command "zathura"))) + ("j" . pdf-view-next-line-or-next-page) + ("k" . pdf-view-previous-line-or-previous-page)) + :config + (pdf-tools-install :no-query)) ;; automatically compile on first launch + +;; ------------------------------ PDF View Restore ----------------------------- + +;; restores the last known position on opening a pdf file. +(use-package pdf-view-restore + :after pdf-tools + :defer 1 + :hook + (pdf-view-mode . pdf-view-restore-mode) + :config + (setq pdf-view-restore-filename (concat user-emacs-directory "/.pdf-view-restore"))) + +;; --------------------------- PDF Continuous Scroll --------------------------- + +;; Note: This appears to behave badly in conjunction with org-noter +;; provides continuous scrolling of PDF documents in PDF View +;; (use-package pdf-continuous-scroll-mode +;; :ensure nil ;; in custom folder +;; :after pdf-tools +;; :load-path "custom/pdf-continuous-scroll-mode-latest.el" +;; :hook (pdf-view-mode . pdf-continuous-scroll-mode)) + +(provide 'pdf-config) +;;; pdf-config.el ends here. diff --git a/modules/prog-c.el b/modules/prog-c.el new file mode 100644 index 00000000..4dc4c5af --- /dev/null +++ b/modules/prog-c.el @@ -0,0 +1,32 @@ +;;; prog-c --- C Programming Settings and Functionality -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;;; Code: + +;;;; ------------------------------ C-Mode Settings ------------------------------ + +(defun cj/c-mode-settings () + "Settings for \='c-mode\='." + (setq-default indent-tabs-mode nil) ;; spaces, not tabs + (setq-default c-basic-offset 4) ;; 4 spaces offset + (setq c-default-style "stroustrup") ;; k&r c, 2nd edition + (setq c-basic-indent 4) ;; indent 4 spaces + (setq compile-command "CFLAGS=\"-Wall -g \" make ") ;; default make command + (setq display-line-numbers-type t) ;; disable relative line numbers in C + (setq comment-auto-fill-only-comments t) ;; only auto-fill inside comments + (auto-fill-mode) ;; auto-fill multiline comments + (electric-pair-mode)) ;; automatic parenthesis pairing +(add-hook 'c-mode-common-hook 'cj/c-mode-settings) + +;;;; -------------------------- Keybindings -------------------------- + +(add-hook 'c-mode-common-hook (lambda () + (local-set-key (kbd "S-<f2>") #'compile) + (local-set-key (kbd "S-<f3>") #'gdb))) + + + +(provide 'prog-c) +;;; prog-c.el ends here diff --git a/modules/prog-general.el b/modules/prog-general.el new file mode 100644 index 00000000..589a3146 --- /dev/null +++ b/modules/prog-general.el @@ -0,0 +1,299 @@ +;;; prog-general --- General Programming Settings -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; This module provides general programming functionality not related to a +;; specific programming language, such as code-folding, project management, +;; highlighting symbols, snippets, and whitespace management. + +;;; Code: + +(require 'seq) + +;; --------------------- General Programming Mode Settings --------------------- +;; keybindings, minor-modes, and prog-mode settings + +(defun cj/general-prog-settings () + "Keybindings, minor modes, and settings for programming mode." + (interactive) + (display-line-numbers-mode) ;; show line numbers + (setq display-line-numbers-type 'relative) ;; display numbers relative to 'the point' + (setq-default display-line-numbers-width 3) ;; 3 characters reserved for line numbers + (turn-on-visual-line-mode) ;; word-wrapping + (auto-fill-mode) ;; auto wrap at the fill column set + (local-set-key (kbd "M-;") 'comment-dwim)) ;; comment/uncomment region as appropriate + +(add-hook 'prog-mode-hook #'cj/general-prog-settings) +(add-hook 'html-mode-hook #'cj/general-prog-settings) +(add-hook 'yaml-mode-hook #'cj/general-prog-settings) +(add-hook 'toml-mode-hook #'cj/general-prog-settings) + + +;; --------------------------------- Treesitter -------------------------------- +;; incremental language syntax parser + +(use-package tree-sitter + :defer .5) + +;; installs tree-sitter grammars if they're absent +(use-package treesit-auto + :defer .5 + :custom + (treesit-auto-install t) + ;; (treesit-auto-install 'prompt) ;; optional prompt instead of auto-install + :config + (treesit-auto-add-to-auto-mode-alist 'all) + (global-treesit-auto-mode)) + +;; -------------------------------- Code Folding ------------------------------- + +;; BICYCLE +;; cycle visibility of outline sections and code blocks. +;; additionally it can make use of the hideshow package. +(use-package bicycle + :after outline + :defer 1 + :hook ((prog-mode . outline-minor-mode) + (prog-mode . hs-minor-mode)) + :bind (:map outline-minor-mode-map + ("C-<tab>" . bicycle-cycle) + ;; backtab is shift-tab + ("<backtab>" . bicycle-cycle-global))) + +;; --------------------------------- Projectile -------------------------------- +;; project support + +;; only discover projects when there's no bookmarks file +(defun cj/projectile-schedule-project-discovery () + (let ((projectile-bookmark-file (concat user-emacs-directory "/projectile-bookmarks.eld"))) + (unless (file-exists-p projectile-bookmark-file) + (run-at-time "3" nil 'projectile-discover-projects-in-search-path)))) + +(use-package projectile + :defer .5 + :bind-keymap + ("C-c p" . projectile-command-map) + :bind + (:map projectile-command-map + ("r" . projectile-replace-regexp) + ("t" . cj/open-project-root-todo)) + :custom + (projectile-auto-discover nil) + (projectile-project-search-path `(,code-dir ,projects-dir)) + :config + (defun cj/find-project-root-file (regexp) + "Return first file in the current Projectile project root matching REGEXP. + +Match is done against (downcase file) for case-insensitivity. +REGEXP must be a string or an rx form." + (when-let ((root (projectile-project-root))) + (seq-find (lambda (file) + (string-match-p (if (stringp regexp) + regexp + (rx-to-string regexp)) + (downcase file))) + (directory-files root)))) + + (defun cj/open-project-root-todo () + "Open todo.org in the current Projectile project root. + +If no such file exists there, display a message." + (interactive) + (if-let ((root (projectile-project-root))) + (let ((file (cj/find-project-root-file "^todo\\.org$"))) + (if file + (find-file (expand-file-name file root)) + (message "No todo.org in project root: %s" root))) + (message "Not in a Projectile project"))) + + (defun cj/project-switch-actions () + "On =projectile-after-switch-project-hook=, open TODO.{org,md,txt} or fall back to Magit." + (let ((file (cj/find-project-root-file + (rx bos "todo." (or "org" "md" "txt") eos)))) + (if file + (find-file (expand-file-name file (projectile-project-root))) + (magit-status (projectile-project-root))))) + + ;; scan for projects if none are defined + (cj/projectile-schedule-project-discovery) + + ;; don't reuse comp buffers between projects + (setq projectile-per-project-compilation-buffer t) + (projectile-mode) + (setq projectile-switch-project-action #'cj/project-switch-actions)) + +;; groups ibuffer by projects +(use-package ibuffer-projectile + :defer .5 + :after projectile + :hook (ibuffer-mode . ibuffer-projectile-set-filter-groups)) + +;; list all errors project-wide +(use-package flycheck-projectile + :defer .5 + :after projectile + :commands flycheck-projectile-list-errors + :bind + (:map projectile-command-map + ("x" . flycheck-projectile-list-errors))) + +;; ---------------------------------- Ripgrep ---------------------------------- + +(use-package deadgrep + :after projectile + :bind + (:map projectile-command-map + ("G" . deadgrep) ;; project-wide search + ("g" . cj/deadgrep-here) ;; search in context directory + ("d" . cj/deadgrep-in-dir)) ;; prompt for directory + + :config + (require 'thingatpt) + + (defun cj/deadgrep--initial-term () + (cond + ((use-region-p) + (buffer-substring-no-properties (region-beginning) (region-end))) + (t (thing-at-point 'symbol t)))) + + (defun cj/deadgrep-here (&optional term) + "Search with Deadgrep in the most relevant directory at point." + (interactive) + (let* ((root + (cond + ((derived-mode-p 'dired-mode) + (let ((path (dired-get-filename nil t))) + (cond + ;; If point is on a directory entry, search within that directory. + ((and path (file-directory-p path)) path) + ;; If point is on a file, search in its containing directory. + ((and path (file-regular-p path)) (file-name-directory path)) + (t default-directory)))) + (buffer-file-name + (file-name-directory (file-truename buffer-file-name))) + (t default-directory))) + (root (file-name-as-directory (expand-file-name root))) + (term (or term (read-from-minibuffer "Search: " (cj/deadgrep--initial-term))))) + (deadgrep term root))) + + (defun cj/deadgrep-in-dir (&optional dir term) + "Prompt for a directory, then search there with Deadgrep." + (interactive) + (let* ((dir (or dir (read-directory-name "Search in directory: " default-directory nil t))) + (dir (file-name-as-directory (expand-file-name dir))) + (term (or term (read-from-minibuffer "Search: " (cj/deadgrep--initial-term))))) + (deadgrep term dir)))) + +(with-eval-after-load 'dired + (define-key dired-mode-map (kbd "d") #'cj/deadgrep-here)) + + +;; ---------------------------------- Snippets --------------------------------- +;; reusable code and text + +(use-package yasnippet + :defer 1 + :bind + ("C-c s n" . yas-new-snippet) + ("C-c s e" . yas-visit-snippet-file) + :config + (setq yas-snippet-dirs '(snippets-dir)) + (yas-global-mode 1)) + +(use-package ivy-yasnippet + :after yasnippet + :bind + ("C-c s i" . ivy-yasnippet)) + +;; --------------------- Display Color On Color Declaration -------------------- +;; display the actual color as highlight to color hex code + +(use-package rainbow-mode + :defer .5 + :hook (prog-mode . rainbow-mode)) + +;; ---------------------------- Symbol Overlay Mode ---------------------------- +;; Highlight symbols with keymap-enabled overlays +;; replaces highlight-symbol-mode + +(use-package symbol-overlay + :defer .5 + :bind-keymap + ("C-c C-s" . symbol-overlay-map) + :hook + (prog-mode . symbol-overlay-mode)) + + +;; --------------------------- Highlight Indentation --------------------------- + +(use-package highlight-indent-guides + :ensure t + :hook (prog-mode . cj/highlight-indent-guides-enable) + :config + ;; Disable auto face coloring to use explicit faces for better visibility across themes + (setq highlight-indent-guides-auto-enabled nil) + + ;; Set explicit face backgrounds and foreground for the indentation guides + (set-face-background 'highlight-indent-guides-odd-face "darkgray") + (set-face-background 'highlight-indent-guides-even-face "darkgray") + (set-face-foreground 'highlight-indent-guides-character-face "dimgray") + + (defun cj/highlight-indent-guides-enable () + "Enable highlight-indent-guides with preferred settings for programming modes." + (setq-local highlight-indent-guides-method 'bitmap) + (setq-local highlight-indent-guides-responsive nil) + (highlight-indent-guides-mode 1)) + + ;; Disable in non-prog-mode buffers + (defun cj/highlight-indent-guides-disable-in-non-prog-modes () + "Disable highlight-indent-guides-mode outside programming modes." + (unless (derived-mode-p 'prog-mode) + (highlight-indent-guides-mode -1))) + + (add-hook 'after-change-major-mode-hook + #'cj/highlight-indent-guides-disable-in-non-prog-modes)) + +;; ------------------------------ Highlight TODOs ------------------------------ +;; Highlights todo keywords in code for easy spotting. + +(use-package hl-todo + :defer 1 + :hook + (prog-mode . hl-todo-mode) + :config + (setq hl-todo-keyword-faces + '(("FIXME" . "#FF0000") + ("BUG" . "#FF0000") + ("HACK" . "#FF0000") + ("ISSUE" . "#DAA520") + ("TASK" . "#DAA520") + ("NOTE" . "#2C780E") + ("WIP" . "#1E90FF")))) + +;; --------------------------- Whitespace Management --------------------------- +;; trims trailing whitespace only from lines you've modified when saving buffer + +(use-package ws-butler + :defer .5 + :commands (ws-butler-mode) + :init + (add-hook 'prog-mode-hook #'ws-butler-mode) + ;; no org and text mode as org branches occasionally move up a line and become invalid + :config + (setq ws-butler-convert-leading-tabs-or-spaces t)) + +;; ----------------- Auto-Close Successful Compilation Windows ----------------- +;; close compilation windows when successful. from 'enberg' on #emacs + +(add-hook 'compilation-finish-functions + (lambda (buf str) + (if (null (string-match ".*exited abnormally.*" str)) + ;;no errors, make the compilation window go away in a few seconds + (progn + (run-at-time + "1.5 sec" nil 'delete-windows-on + (get-buffer-create "*compilation*")))))) + + +(provide 'prog-general) +;;; prog-general.el ends here diff --git a/modules/prog-go.el b/modules/prog-go.el new file mode 100644 index 00000000..cf12cb6f --- /dev/null +++ b/modules/prog-go.el @@ -0,0 +1,41 @@ +;;; prog-go --- Golang Specific Settings and Functionality -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;;; Code: + +;; ---------------------------------- Go Setup --------------------------------- +;; golang preferences + +(defun cj/go-setup () + "My default code preferences for Golang." + (require 'tree-sitter) + (require 'tree-sitter-langs) + (require 'tree-sitter-hl) + (tree-sitter-hl-mode) + (hs-minor-mode) + (company-mode) + (setq-default tab-width 4) ;; set the tab width to 4 spaces + (setq-default standard-indent 4) ;; indent 4 spaces + (setq-default indent-tabs-mode nil) ;; disable tab characters + (electric-pair-mode t)) ;; match delimiters automatically +(add-hook 'go-mode-hook 'cj/go-setup) + +;; ---------------------------------- Go Mode ---------------------------------- +;; go mode configuration + +(use-package go-mode + :bind (:map go-mode-map + ("<f6>" . gofmt) + ("C-c 6" . gofmt) + ("<f4>" . golint) + ("C-c 4" . golint)) + :config + (add-to-list 'exec-path "~/go/bin") + ;; allow adding/removing fmt lines; install with: + ;; go install golang.org/x/tools/cmd/goimports@latest + (setq gofmt-command "goimports")) + +(provide 'prog-go) +;;; prog-go.el ends here diff --git a/modules/prog-lisp.el b/modules/prog-lisp.el new file mode 100644 index 00000000..11dc7851 --- /dev/null +++ b/modules/prog-lisp.el @@ -0,0 +1,126 @@ +;;; prog-lisp --- Lisp Specific Settings and Functionality -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; ==== Setting up Quicklisp ==== +;; Quicklisp is a library manager for Common Lisp. It works with your existing Common Lisp +;; implementation to download, install, and load any of over 1,500 libraries with a few +;; simple commands. +;; https://www.quicklisp.org/beta/ + +;; mostly from: https://gist.github.com/jteneycke/7947353 +;; * Install SBCL +;; sudo pacman -S sbcl # arch +;; doas pkg install sbcl # bsd +;; sudo apt-get install sbcl # debian + +;; * Install QuickLisp +;; curl -O http://beta.quicklisp.org/quicklisp.lisp +;; sbcl --load quicklisp.lisp --eval "(quicklisp-quickstart:install)" --quit +;; sbcl --load ~/quicklisp/setup.lisp --eval "(ql:add-to-init-file)" --quit + +;; * Emacs Config +;; (load (expand-file-name "~/quicklisp/slime-helper.el")) +;; ;; Replace "sbcl" with the path to your implementation +;; (setq inferior-lisp-program "/usr/bin/sbcl") + +;; to get readline support in SBCL's REPL, install rlwrap and run it +;; before sbcl like so: + +;; $ rlwrap sbcl + +;;; Code: +(require 'ert) + +;; -------------------------------- Elisp Setup -------------------------------- +;; run this on editing an elisp file + +(defun cj/elisp-setup () + "My default code preferences for emacs-lisp." + (setq-default tab-width 4) ;; set the tab width to 4 spaces + (setq-default indent-tabs-mode -1) ;; disable tab characters + (setq-default fill-column 120) ;; wrap code at this column + (display-fill-column-indicator-mode)) ;; show where the fill-column is +(add-hook 'emacs-lisp-mode-hook 'cj/elisp-setup) + +;; ------------------------------ Emacs Lisp REPL ------------------------------ + +(use-package ielm + :ensure nil ;; built-in + :hook (ielm-mode . eldoc-mode) + :config (setq ielm-prompt "elisp> ")) + +;; ----------------------------------- Eldoc ----------------------------------- + +(use-package eldoc + :ensure nil ;; built-in + :hook ((c-mode-common emacs-lisp-mode) . eldoc-mode) + :custom + (eldoc-echo-area-use-multiline-p 3) + (eldoc-echo-area-display-truncation-message nil)) + +;; -------------------------- ERT + Testing Libraries -------------------------- +;; unit/regression testing framework +;; basic introduction: https://nullprogram.com/blog/2012/08/15/ +;; https://www.gnu.org/software/emacs/manual/html_node/ert/ +;; or: [[info:ert#User Input]] + +(use-package ert + :ensure nil ;; built-into emacs + :defer 1) + +;; ---------------------------------- El-Mock ---------------------------------- + +(use-package el-mock + :defer 1) ;; mock/stub framework + +;; --------------------------------- Elisp Lint -------------------------------- + +(use-package elisp-lint + :defer 1) + +;; ------------------------------ Package Tooling ------------------------------ + +(use-package package-lint + :defer 1) + +(use-package flycheck-package + :defer 1 + :after (flycheck package-lint) + :config + (flycheck-package-setup)) + +(use-package package-build + :defer 1) + +;; ----------------------------- Rainbow Delimiters ---------------------------- + +(use-package rainbow-delimiters + :defer .5 + :hook + (emacs-lisp-mode . rainbow-delimiters-mode) + (lisp-mode . rainbow-delimiters-mode) + :config + (set-face-foreground 'rainbow-delimiters-depth-1-face "#c66") ;; red + (set-face-foreground 'rainbow-delimiters-depth-2-face "#6c6") ;; green + (set-face-foreground 'rainbow-delimiters-depth-3-face "#69f") ;; blue + (set-face-foreground 'rainbow-delimiters-depth-4-face "#cc6") ;; yellow + (set-face-foreground 'rainbow-delimiters-depth-5-face "#6cc") ;; cyan + (set-face-foreground 'rainbow-delimiters-depth-6-face "#c6c") ;; magenta + (set-face-foreground 'rainbow-delimiters-depth-7-face "#ccc") ;; light gray + (set-face-foreground 'rainbow-delimiters-depth-8-face "#999") ;; medium gray + (set-face-foreground 'rainbow-delimiters-depth-9-face "#666")) ;; dark gray + +;; -------------------------------- Geiser Guile ------------------------------- +;; Guile support in Emacs + +(use-package geiser-guile + :defer 1 + :commands (geiser-guile) + :bind ("C-c G" . geiser-guile) + :config + (setq geiser-guile-binary "/usr/bin/guile")) + +(provide 'prog-lisp) +;;; prog-lisp.el ends here diff --git a/modules/prog-lsp.el b/modules/prog-lsp.el new file mode 100644 index 00000000..764d94df --- /dev/null +++ b/modules/prog-lsp.el @@ -0,0 +1,55 @@ +;;; prog-lsp --- Setup for LSP Mode -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; good reference as to what to enable/disable in lsp-mode +;; https://emacs-lsp.github.io/lsp-mode/tutorials/how-to-turn-off/ + +;;; Code: + + +;;;;; ---------------------------- LSP Mode --------------------------- + +(use-package lsp-mode + :hook + ((c-mode c++-mode go-mode js-mode js-jsx-mode typescript-mode python-mode web-mode) . lsp-deferred) + :commands (lsp) + :bind (:map lsp-mode-map + ("C-c d" . lsp-describe-thing-at-point) + ("C-c a" . lsp-execute-code-action)) + :bind-keymap ("C-c L" . lsp-command-map) + :config + (setq lsp-auto-guess-root t) + (setq lsp-log-io nil) + (setq lsp-restart 'auto-restart) + (setq lsp-enable-symbol-highlighting nil) + (setq lsp-enable-on-type-formatting nil) + (setq lsp-signature-auto-activate nil) + (setq lsp-signature-render-documentation nil) + (setq lsp-eldoc-hook nil) + (setq lsp-modeline-code-actions-enable nil) + (setq lsp-modeline-diagnostics-enable nil) + (setq lsp-headerline-breadcrumb-enable nil) + (setq lsp-semantic-tokens-enable nil) + (setq lsp-enable-folding nil) + (setq lsp-enable-imenu nil) + (setq lsp-enable-snippet nil) + (setq read-process-output-max (* 1024 1024)) ;; 1MB + (setq lsp-idle-delay 0.5)) + +;;;;; ----------------------------- LSP UI ---------------------------- + +(use-package lsp-ui + :after lsp-mode + :commands lsp-ui-mode + :config + (setq lsp-ui-doc-enable nil) + (setq lsp-ui-doc-header t) + (setq lsp-ui-doc-include-signature t) + (setq lsp-ui-doc-border (face-foreground 'default)) + (setq lsp-ui-sideline-show-code-actions nil) ;; turn off code actions in sidebar + (setq lsp-ui-sideline-delay 0.05)) + +(provide 'prog-lsp) +;;; prog-lsp.el ends here diff --git a/modules/prog-python.el b/modules/prog-python.el new file mode 100644 index 00000000..2dc1fb3b --- /dev/null +++ b/modules/prog-python.el @@ -0,0 +1,81 @@ +;;; prog-python --- Python Specific Setup and Functionality -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;;; Code: + +;; -------------------------------- Python Setup ------------------------------- +;; preferences for Python programming + +(defun cj/python-setup () + "My default code preferences for Python coding." + (tree-sitter-hl-mode) ;; use tree-sitter's highlighting + (hs-minor-mode) ;; folding + (company-mode) ;; completion framework + (flyspell-prog-mode) ;; spell check comments + (superword-mode) ;; see-this-as-one-word + (setq-default fill-column 80) ;; wrap at 80 columns + (setq-default tab-width 4) ;; set the tab width to 4 spaces + (setq-default standard-indent 4) ;; indent 4 spaces + (setq-default indent-tabs-mode nil) ;; disable tab characters + (electric-pair-mode t)) ;; match delimiters automatically + +;; ----------------------------------- Python ---------------------------------- +;; configuration for Emacs' built-in Python editing support + +(use-package python + :ensure nil ;; built-in + :hook + (python-mode . cj/python-setup) + :custom + (python-shell-interpreter "python3") + :config + ;; remove the "guess indent" python message + (setq python-indent-guess-indent-offset-verbose nil)) + +;; ----------------------------------- Poetry ---------------------------------- +;; virtual environments and dependencies + +(use-package poetry + :defer t + :after (python) + :hook (python-mode . poetry-tracking-mode) + :config + ;; Checks for the correct virtualenv. Better strategy IMO because the default + ;; one is quite slow. + (setq poetry-tracking-strategy 'switch-buffer)) + +;; ---------------------------------- Blacken ---------------------------------- +;; formatting on save + +(use-package blacken + :defer 1 + :custom + (blacken-allow-py36 t) + (blacken-skip-string-normalization t) + :hook (python-mode . blacken-mode)) + +;; ---------------------------------- Numpydoc --------------------------------- +;; automatically insert NumPy style docstrings in Python function definitions + +(use-package numpydoc + :defer 1 + :custom + (numpydoc-insert-examples-block nil) + (numpydoc-template-long nil) + :bind (:map python-mode-map + ("C-c C-n" . numpydoc-generate))) + +;; ------------------------------------ TOML ----------------------------------- +;; editing support and documentation for TOML files + +(use-package toml-mode + :defer 1) + +(use-package eldoc-toml + :defer 1) + + +(provide 'prog-python) +;;; prog-python.el ends here diff --git a/modules/prog-shell.el b/modules/prog-shell.el new file mode 100644 index 00000000..e63387cf --- /dev/null +++ b/modules/prog-shell.el @@ -0,0 +1,15 @@ +;;; prog-shell --- Shell Programming Settings and Functionality -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; Open any *.sh buffer and sh-mode loads with Flycheck attached, so syntax errors appear immediately. +;; Re-save or invoke C-c ! l to refresh diagnostics while you iterate on scripts. + +;;; Code: + +(use-package sh-script + :defer .5 + :hook (sh-mode . flycheck-mode)) + +(provide 'prog-shell) +;;; prog-shell.el ends here diff --git a/modules/prog-training.el b/modules/prog-training.el new file mode 100644 index 00000000..9eb5a9a3 --- /dev/null +++ b/modules/prog-training.el @@ -0,0 +1,35 @@ +;;; prog-training.el --- Training -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;;; Commentary: +;; Use C-h E to launch Exercism when you want to fetch or submit practice problems. +;; Use C-h L for LeetCode sessions; the package drops solved files under ~/code/leetcode in Go format. +;; Both bindings autoload their packages, so invoking the key is the whole workflow. + +;;; Code: + + +;; ----------------------------- Exercism ---------------------------- + +(use-package exercism + :defer t + :commands (exercism) + :bind + ("C-h E" . exercism)) + + +;;; ----------------------------- Leetcode ---------------------------- + +(use-package leetcode + :defer t + :commands (leetcode) + :bind ("C-h L" . leetcode) + :custom + (url-debug t) + :config + (setq leetcode-prefer-language "golang") + (setq leetcode-directory (concat code-dir "/leetcode")) + (setq leetcode-save-solutions t)) + + +(provide 'prog-training) +;;; prog-training.el ends here. diff --git a/modules/prog-webdev.el b/modules/prog-webdev.el new file mode 100644 index 00000000..a45bd376 --- /dev/null +++ b/modules/prog-webdev.el @@ -0,0 +1,120 @@ +;;; prog-webdev.el --- Web Development Packages and Settings -*- lexical-binding: t; coding: utf-8; -*- +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Open a project file and Emacs selects the right helper: +;; - *.json buffers drop into json-mode for quick structural edits. +;; - *.js buffers jump into js2-mode for linty feedback. +;; - Mixed HTML templates land in web-mode which chains Tide and CSS Eldoc. +;; +;; Workflow: +;; - Hit C-RET in web-mode to ask for completions; the command routes to Tide, +;; company-css, or dabbrev based on the language at point. +;; - Eldoc messages come from `cj/eldoc-web-mode`, so keeping point over JS, CSS, +;; or markup swaps the doc source automatically. +;; - New web buffers call `cj/setup-web-mode-mixed`, enabling Tide so goto-definition +;; and rename are ready without extra setup. + +;;; Code: + +;; --------------------------------- JSON Mode --------------------------------- +;; mode for editing JavaScript Object Notation (JSON) data files + +(use-package json-mode + :mode ("\\.json\\'" . json-mode) + :defer .5) + +;; ---------------------------------- JS2 Mode --------------------------------- +;; javascript editing mode + +(use-package js2-mode + :mode ("\\.js\\'" . js2-mode) + :defer .5) + +;; --------------------------------- CSS Eldoc --------------------------------- +;; CSS info in the echo area + +(use-package css-eldoc + :defer .5) + +;; ------------------------------------ Tide ----------------------------------- +;; typescript interactive development environment + +(use-package tide + :defer .5) + +(defun cj/activate-tide () + "Activate Tide mode for TypeScript development. +Calls Tide's setup, enables `eldoc-mode, and activates identifier highlighting." + (interactive) + (tide-setup) + (eldoc-mode 1) + (tide-hl-identifier-mode 1)) + +;; ---------------------------------- Web Mode --------------------------------- +;; major mode for editing web templates + +(use-package web-mode + :defer .5 + :after (tide css-eldoc) + :custom + (web-mode-enable-current-element-highlight t) + :bind + ([(control return)] . cj/complete-web-mode) + :mode + (("\\.html?$" . cj/setup-web-mode-mixed))) + +(defun cj/complete-web-mode () + "Provide context-aware completion in `web-mode' buffers. +Determines the language at point (JavaScript, CSS, or markup) +and invokes the appropriate completion backend: +- JavaScript: uses `company-tide' +- CSS: uses `company-css' +- Other markup: uses `company-dabbrev-code' + +This function is typically bound to \\[cj/complete-web-mode] in +`web-mode' buffers to provide intelligent completions based on +the current context." + (interactive) + (let ((current-scope (web-mode-language-at-pos (point)))) + (cond ((string-equal "javascript" current-scope) + (company-tide 'interactive)) + ((string-equal "css" current-scope) + (company-css 'interactive)) + (t + (company-dabbrev-code 'interactive))))) + +(defun cj/eldoc-web-mode () + "Provide context-aware eldoc documentation in `web-mode' buffers. +Return appropriate documentation based on the language at point: +- JavaScript: uses `tide-eldoc-function' for TypeScript/JavaScript docs +- CSS: uses `css-eldoc-function' for CSS property documentation +- Other markup: returns nil (no documentation) + +This function is designed to be used as the buffer-local value +of `eldoc-documentation-function' in `web-mode' buffers with +mixed content." + (let ((current-scope (web-mode-language-at-pos (point)))) + (cond ((string-equal "javascript" current-scope) + (tide-eldoc-function)) + ((string-equal "css" current-scope) + (css-eldoc-function)) + (t + nil)))) + +(defun cj/setup-web-mode-mixed () + "Set up `web-mode' with Tide and context-aware eldoc support. +Enable `web-mode' for the current buffer and configure it for +mixed HTML/JavaScript/CSS content. Activate Tide for JavaScript +support and configure eldoc to use `cj/eldoc-web-mode' for +context-aware documentation. + +This function is typically used as an auto-mode entry point for +HTML files that contain embedded JavaScript and CSS." + (web-mode) + (cj/activate-tide) + (setq-local eldoc-documentation-function #'cj/eldoc-web-mode)) + + +(provide 'prog-webdev) +;;; prog-webdev.el ends here. diff --git a/modules/prog-yaml.el b/modules/prog-yaml.el new file mode 100644 index 00000000..1a970313 --- /dev/null +++ b/modules/prog-yaml.el @@ -0,0 +1,18 @@ +;;; prog-yaml --- YAML Settings -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;;; Code: + +(use-package yaml-mode + :defer .5 + :commands (yaml-mode) + :config + (add-to-list 'auto-mode-alist '("\\.yml\\'" . yaml-mode)) + (add-to-list 'auto-mode-alist '("\\.yaml\\'" . yaml-mode))) + +(add-hook 'yaml-mode-hook ' flycheck-mode-hook) + +(provide 'prog-yaml) +;;; prog-yaml.el ends here diff --git a/modules/quick-video-capture.el b/modules/quick-video-capture.el new file mode 100644 index 00000000..05ad70c8 --- /dev/null +++ b/modules/quick-video-capture.el @@ -0,0 +1,104 @@ +;;; quick-video-capture.el --- Video Capturing with Org Capture -*- coding: utf-8; lexical-binding: t; -*- + +;;; Commentary: + +;; This package provides a seamless "fire-and-forget" workflow for downloading +;; videos from the browser to your local system using yt-dlp and task-spooler. +;; +;; Features: +;; - Browser bookmarklet integration via org-protocol +;; - Automatic queueing of downloads through task-spooler +;; - Works with any yt-dlp supported site +;; - Can be triggered manually via org-capture with URL prompt +;; +;; Setup: +;; 1. Load this file and call (cj/setup-video-download) +;; 2. Add the bookmarklet from `cj/video-download-bookmarklet' to your browser +;; 3. Click the bookmarklet on any video page to queue a download +;; +;; Alternatively, trigger manually with C-c c v and enter a URL + +;;; Code: + +(require 'org-protocol) +(require 'org-capture) +(require 'media-utils) + +(defconst cj/video-download-bookmarklet + "javascript:location.href='org-protocol://video-download?url='+encodeURIComponent(location.href);void(0);" + "JavaScript bookmarklet for triggering video downloads from the browser. +Add this as a bookmark in your browser to enable one-click video downloads.") + +(defun cj/org-protocol-video-download (info) + "Process org-protocol video download requests. +INFO is a plist containing :url from the org-protocol call." + (let ((url (plist-get info :url))) + (when url + ;; Store the URL for the capture template to use + (setq cj/video-download-current-url url)) + ;; Trigger the capture + (org-capture nil "v") + nil)) ; Return nil to indicate we handled it + +(defun cj/video-download-capture-handler () + "Handle video download during org-capture. +This function is called from the capture template." + (let ((url (or cj/video-download-current-url + (read-string "Video URL: ")))) + ;; Clear the stored URL after using it + (setq cj/video-download-current-url nil) + (if (string-empty-p url) + (error "No URL provided for download") + (cj/yt-dl-it url) + ;; Return empty string to prevent capture from saving anything + ""))) + +(defvar cj/video-download-current-url nil + "Temporary storage for URL passed via org-protocol.") + +;; register the handler and the capture template after org-protocol is loaded +(with-eval-after-load 'org-protocol + ;; Register the org-protocol handler + (add-to-list 'org-protocol-protocol-alist + '("video-download" + :protocol "video-download" + :function cj/org-protocol-video-download + :kill-client t)) + + ;; Add the capture template + (add-to-list 'org-capture-templates + '("v" "Video Download" entry + (file "") ; No file needed since we're not saving + "%(cj/video-download-capture-handler)" + :immediate-finish t + :jump-to-captured nil))) + +(defun cj/video-download-bookmarklet-instructions () + "Display instructions for setting up the browser bookmarklet." + (interactive) + (let ((buf (get-buffer-create "*Video Download Bookmarklet Setup*"))) + (with-current-buffer buf + (erase-buffer) + (insert "Video Download Bookmarklet Setup\n") + (insert "=================================\n\n") + (insert "1. Create a new bookmark in your browser\n") + (insert "2. Set the name to: Download Video (or your preference)\n") + (insert "3. Set the URL to the following JavaScript code:\n\n") + (insert cj/video-download-bookmarklet) + (insert "\n\n") + (insert "4. Save the bookmark to your bookmarks bar\n") + (insert "5. Click the bookmark when viewing a video to download it\n\n") + (insert "Note: Make sure Emacs server is running (M-x server-start)\n") + (insert "and emacsclient is properly configured for org-protocol.\n")) + (switch-to-buffer buf))) + +;;; Commentary for bookmarklet: +;; +;; To use the browser bookmarklet, add this JavaScript as a bookmark URL: +;; javascript:location.href='org-protocol://video-download?url='+encodeURIComponent(location.href);void(0); +;; +;; This will send the current page URL to Emacs via org-protocol, triggering +;; the download through yt-dlp and task-spooler. + +(provide 'quick-video-capture) +;;; quick-video-capture.el ends here. diff --git a/modules/reconcile-open-repos.el b/modules/reconcile-open-repos.el new file mode 100644 index 00000000..8a2eda5e --- /dev/null +++ b/modules/reconcile-open-repos.el @@ -0,0 +1,72 @@ +;;; reconcile-open-repos.el --- reconcile open repos -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;;; Commentary: +;; I have tried to keep this Emacs config as general as possible, and move all +;; config related to my personal workflows here. + +;; I typically work with multiple git repositories the day, and can forget to +;; commit uncommitted work. Also, I often forget to start a session by pulling +;; changes. So, making it a habit to run cj/check-for-open-work when starting +;; and ending the work session allows me to know that I've iterated through all +;; git repositories in my projects and code directories and have reconciled all +;; changes. + +;;; Code: + +;; -------------------------- Reconcile Git Directory -------------------------- + +(defun cj/reconcile-git-directory (directory) + "Reconcile unopened work in a git project directory leveraging magit." + (message "checking: %s" directory) + (let ((default-directory directory)) + ;; Check for the presence of the .git directory + (if (file-directory-p (expand-file-name ".git" directory)) + (progn + (let ((remote-url (shell-command-to-string "git config --get remote.origin.url"))) + (setq remote-url (string-trim remote-url)) + + ;; skip local git repos, or remote URLs that are http or https, + ;; these are typically cloned for reference only + (unless (or (string-empty-p remote-url) + (string-match-p "^\\(http\\|https\\)://" remote-url)) + + ;; if git directory is clean, pulling generates no errors + (if (string-empty-p (shell-command-to-string "git status --porcelain")) + (progn + ;; (message "%s is a clean git repository" directory) + (shell-command "git pull --quiet")) + + ;; if directory not clean, pull latest changes and display Magit for manual intervention + (progn + (message "%s contains uncommitted work" directory) + (shell-command "git stash --quiet") + (shell-command "git pull --quiet") + (shell-command "git stash pop --quiet") + (call-interactively #'magit-status) + ;; pause until magit buffer is closed + (while (buffer-live-p (get-buffer (format "*magit: %s*" (file-name-nondirectory directory)))) + (sit-for 0.5)))))))))) + +;; ---------------------------- Check For Open Work ---------------------------- + +(defun cj/check-for-open-work () + "Check all project directories for open work." + (interactive) + ;; these are constants defined in init.el + ;; children of these directories will be checked + (dolist (base-dir (list projects-dir code-dir)) + (when (file-directory-p base-dir) + (dolist (child-dir (directory-files base-dir t "^[^.]+$" 'nosort)) + (when (file-directory-p child-dir) + (cj/reconcile-git-directory child-dir))))) + + ;; check these directories individually + (cj/reconcile-git-directory sync-dir) + (cj/reconcile-git-directory user-emacs-directory) + + ;; communicate when finished. + (message "Complete. All project repositories checked for uncommitted work and code updated from remote repository")) +(global-set-key (kbd "M-P") 'cj/check-for-open-work) + +(provide 'reconcile-open-repos) +;;; reconcile-open-repos.el ends here. diff --git a/modules/selection-framework.el b/modules/selection-framework.el new file mode 100644 index 00000000..9e7e44a3 --- /dev/null +++ b/modules/selection-framework.el @@ -0,0 +1,264 @@ +;;; selection-framework.el --- Completion and Selection Framework -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; This module configures the completion and selection framework using: +;; - Vertico: Vertical completion UI +;; - Marginalia: Rich annotations in minibuffer +;; - Consult: Practical commands based on completing-read +;; - Orderless: Advanced completion style +;; - Embark: Contextual actions +;; - Company: In-buffer completion +;; +;; The configuration provides a modern, fast, and feature-rich completion +;; experience that enhances Emacs' built-in completing-read functionality. +;; +;;; Code: + +;; ---------------------------------- Vertico ---------------------------------- +;; Vertical completion UI + +(use-package vertico + :demand t + :custom + (vertico-cycle t) ; Cycle through candidates + (vertico-count 10) ; Number of candidates to display + (vertico-resize nil) ; Don't resize the minibuffer + (vertico-sort-function #'vertico-sort-history-alpha) ; History first, then alphabetical + :bind (:map vertico-map + ;; Match ivy's C-j C-k behavior + ("C-j" . vertico-next) + ("C-k" . vertico-previous) + ("C-l" . vertico-insert) ; Insert current candidate + ("RET" . vertico-exit) + ("C-RET" . vertico-exit-input) + ("M-RET" . minibuffer-force-complete-and-exit) + ("TAB" . minibuffer-complete)) + :init + (vertico-mode)) + +(use-package marginalia + :demand t + :custom + (marginalia-max-relative-age 0) + (marginalia-align 'right) + :init + (marginalia-mode)) + +(use-package nerd-icons-completion + :demand t + :hook (marginalia-mode nerd-icons-completion-marginalia-setup) + :after marginalia + :init + (nerd-icons-completion-mode)) + +;; ---------------------------------- Consult ---------------------------------- +;; Practical commands based on completing-read + +(use-package consult + :demand t + :bind (;; C-c bindings (mode-specific-map) + ("C-c h" . consult-history) + ;; C-x bindings (ctl-x-map) + ("C-x M-:" . consult-complex-command) + ("C-x b" . consult-buffer) + ("C-x 4 b" . consult-buffer-other-window) + ("C-x 5 b" . consult-buffer-other-frame) + ("C-x r b" . consult-bookmark) + ("C-x p b" . consult-project-buffer) + ;; M-g bindings (goto-map) + ("M-g e" . consult-compile-error) + ("M-g f" . consult-flymake) + ("M-g g" . consult-goto-line) + ("M-g M-g" . consult-goto-line) + ("M-g o" . consult-outline) + ("M-g m" . consult-mark) + ("M-g k" . consult-global-mark) + ("M-g i" . consult-imenu) + ("M-g I" . consult-imenu-multi) + ;; M-s bindings (search-map) + ("M-s d" . consult-find) + ("M-s D" . consult-locate) + ("M-s g" . consult-grep) + ("M-s G" . consult-git-grep) + ("M-s r" . consult-ripgrep) + ("M-s l" . consult-line) + ("M-s L" . consult-line-multi) + ("M-s k" . consult-keep-lines) + ("M-s u" . consult-focus-lines) + ;; Isearch integration + ("M-s e" . consult-isearch-history) + :map isearch-mode-map + ("M-e" . consult-isearch-history) + ("M-s e" . consult-isearch-history) + ("M-s l" . consult-line) + ("M-s L" . consult-line-multi) + ;; Minibuffer history + :map minibuffer-local-map + ("M-s" . consult-history) + ("M-r" . consult-history)) + + :hook (completion-list-mode . consult-preview-at-point-mode) + + :init + ;; Optionally configure the register formatting. This improves the register + ;; preview for =consult-register', =consult-register-load', + ;; =consult-register-store' and the Emacs built-ins. + (setq register-preview-delay 0.5 + register-preview-function #'consult-register-format) + + ;; Optionally tweak the register preview window. + (advice-add #'register-preview :override #'consult-register-window) + + ;; Configure other variables and modes + (setq xref-show-xrefs-function #'consult-xref + xref-show-definitions-function #'consult-xref) + + :config + ;; Configure preview. Default is 'any. + (setq consult-preview-key 'any) + + ;; Configure narrowing key + (setq consult-narrow-key "<") + + ;; Reset to defaults to avoid issues + (setq consult-point-placement 'match-beginning) + + ;; Use Consult for completion-at-point + (setq completion-in-region-function #'consult-completion-in-region)) + +(global-unset-key (kbd "C-s")) +(global-set-key (kbd "C-s") 'consult-line) + +;; Consult integration with Embark +(use-package embark-consult + :after (embark consult) + :hook + (embark-collect-mode . consult-preview-at-point-mode)) + +(use-package consult-dir + :bind (("C-x C-d" . consult-dir) + :map vertico-map + ("C-x C-d" . consult-dir) + ("C-x C-j" . consult-dir-jump-file)) + :config + (add-to-list 'consult-dir-sources 'consult-dir--source-tramp-ssh t) + (setq consult-dir-project-list-function #'consult-dir-projectile-dirs)) + +;; --------------------------------- Orderless --------------------------------- +;; Advanced completion style - provides space-separated, out-of-order matching + + +(use-package orderless + :demand t + :custom + (completion-styles '(orderless)) + (completion-category-defaults nil) + (completion-category-overrides '((file (styles partial-completion)) + (multi-category (styles orderless)))) + (orderless-matching-styles '(orderless-literal + orderless-regexp + orderless-initialism + orderless-prefixes))) + +;; ---------------------------------- Embark ----------------------------------- +;; Contextual actions - provides right-click like functionality + +(use-package embark + :demand t + :bind + (("C-." . embark-act) ;; pick an action to run + ("C->" . embark-act-all) ;; pick an action to run on all candidates + ("C-," . embark-dwim) ;; do what I mean + ("C-h B" . embark-bindings)) ;; alternative for =describe-bindings' + + :init + ;; Optionally replace the key help with a completing-read interface + (setq prefix-help-command #'embark-prefix-help-command) + + :config + ;; Hide the mode line of the Embark live/completions buffers + (add-to-list 'display-buffer-alist + '("\\`\\*Embark Collect \\(Live\\|Completions\\)\\*" + nil + (window-parameters (mode-line-format . none))))) + +;; this typo causes crashes +;; (add-to-list 'display-buffer-alist +;; '("\\=\\*Embark Collect \\(Live\\|Completions\\)\\*" +;; nil +;; (window-parameters (mode-line-format . none))))) + +;; --------------------------- Consult Integration ---------------------------- +;; Additional integrations for specific features + +;; Yasnippet integration - replaces ivy-yasnippet +(use-package consult-yasnippet + :after yasnippet + :bind ("C-c s i" . consult-yasnippet)) + +;; Flycheck integration +(use-package consult-flycheck + :after flycheck + :bind (:map flycheck-mode-map + ("C-c ! c" . consult-flycheck))) + +;; ---------------------------------- Company ---------------------------------- +;; In-buffer completion (retained from original configuration) + +(use-package company + :demand t + :hook (after-init . global-company-mode) + :bind + (:map company-active-map + ("<tab>" . company-complete-selection) + ("C-n" . company-select-next) + ("C-p" . company-select-previous)) + :custom + (company-backends '(company-capf company-files company-keywords)) + (company-idle-delay 2) + (company-minimum-prefix-length 2) + (company-show-numbers t) + (company-tooltip-align-annotations t) + (company-tooltip-flip-when-above t) + (company-tooltip-limit 10) + (company-selection-wrap-around t) + (company-require-match nil) + :config + ;; Disable company in mail-related modes + (setq company-global-modes + '(not message-mode + mu4e-compose-mode + org-msg-edit-mode))) + + +(use-package company-quickhelp + :after company + :config + (company-quickhelp-mode)) + +;; Icons for company +(use-package company-box + :after company + :hook (company-mode . company-box-mode)) + +;; --------------------------------- Prescient --------------------------------- + +(use-package prescient + :demand t + :config + (prescient-persist-mode)) + +(use-package vertico-prescient + :demand t + :config + (vertico-prescient-mode)) + +(use-package company-prescient + :demand t + :config + (company-prescient-mode)) + +(provide 'selection-framework) +;;; selection-framework.el ends here diff --git a/modules/show-kill-ring.el b/modules/show-kill-ring.el new file mode 100644 index 00000000..c50e5dbb --- /dev/null +++ b/modules/show-kill-ring.el @@ -0,0 +1,125 @@ +;;; show-kill-ring --- Displays Previous Kill Ring Entries -*- lexical-binding: t; coding: utf-8; -*- +;; Show Kill Ring +;; Stolen from Steve Yegge when he wasn't looking +;; enhancements and bugs added by Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Browse items you've previously killed. +;; Yank text using C-u, the index, then C-y. +;; +;; I've lovingly kept the nice 1970s aesthetic, complete with wood paneling. +;; Maybe I'll give it a makeover at some point. +;; +;;; Code: + +(require 'cl-lib) + +(defvar show-kill-max-item-size 1000 + "This represents the size of a \='kill ring\=' entry. + +A positive number means to limit the display of \='kill-ring\=' items to +that number of characters.") + +(defun show-kill-ring-exit () + "Exit the show-kill-ring buffer." + (interactive) + (quit-window t)) + +(defun show-kill-ring () + "Show the current contents of the kill ring in a separate buffer. + +This makes it easy to figure out which prefix to pass to yank." + (interactive) + ;; kill existing one, since erasing it doesn't work + (let ((buf (get-buffer "*Kill Ring*"))) + (and buf (kill-buffer buf))) + + (let* ((buf (get-buffer-create "*Kill Ring*")) + (temp kill-ring) + (count 1) + (bar (make-string 32 ?=)) + (bar2 (concat " " bar)) + (item " Item ") + (yptr nil) (ynum 1)) + (set-buffer buf) + (erase-buffer) + + (show-kill-insert-header) + + ;; show each of the items in the kill ring, in order + (while temp + ;; insert our little divider + (insert (concat "\n" bar item (prin1-to-string count) " " + (if (< count 10) bar2 bar) "\n")) + + ;; if this is the yank pointer target, grab it + (when (equal temp kill-ring-yank-pointer) + (setq yptr (car temp) ynum count)) + + ;; insert the item and loop + (show-kill-insert-item (car temp)) + (cl-incf count) + (setq temp (cdr temp))) + + ;; show info about yank item + (show-kill-insert-footer yptr ynum) + + ;; use define-key instead of local-set-key + (use-local-map (make-sparse-keymap)) + (define-key (current-local-map) "q" 'show-kill-ring-exit) + + ;; show it + (goto-char (point-min)) + (setq buffer-read-only t) + (set-buffer-modified-p nil) + ;; display-buffer rather than pop-to-buffer + ;; easier for user to C-u (item#) C-y + ;; while the point is where they want to yank + (display-buffer buf))) + +(defun show-kill-insert-item (item) + "Insert an ITEM from the kill ring into the current buffer. + +If it's too long, truncate it first." + (let ((max show-kill-max-item-size)) + (cond + ((or (not (numberp max)) + (< max 0) + (< (length item) max)) + (insert item)) + (t + ;; put ellipsis on its own line if item is longer than 1 line + (let ((preview (substring item 0 max))) + (if (< (length item) (- (frame-width) 5)) + (insert (concat preview "..." )) + (insert (concat preview "\n...")))))))) + +(defun show-kill-insert-header () + "Insert the show-kill-ring header or a notice if the kill ring is empty." + (if kill-ring + (insert "Contents of the kill ring:\n") + (insert "The kill ring is empty"))) + +(defun show-kill-insert-footer (yptr ynum) + "Insert final divider and the yank-pointer (YPTR YNUM) info." + (when kill-ring + (save-excursion + (re-search-backward "^\\(=+ Item [0-9]+\\ +=+\\)$")) + (insert "\n") + (insert (make-string (length (match-string 1)) ?=)) + ;; Use number-to-string instead of int-to-string + (insert (concat "\n\nItem " (number-to-string ynum) + " is the next to be yanked:\n\n")) + (show-kill-insert-item yptr) + (insert "\n\nThe prefix arg will yank relative to this item."))) + +(defun empty-kill-ring () + "Force garbage collection of huge kill ring entries that I don't care about." + (interactive) + (setq kill-ring nil) + (garbage-collect)) + +(global-set-key (kbd "M-K") 'show-kill-ring) + +(provide 'show-kill-ring) +;;; show-kill-ring.el ends here diff --git a/modules/system-defaults.el b/modules/system-defaults.el new file mode 100644 index 00000000..c0879d51 --- /dev/null +++ b/modules/system-defaults.el @@ -0,0 +1,243 @@ +;;; system-defaults --- Non-UI Preferences -*- lexical-binding: t; coding: utf-8-unix; -*- +;; author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Loads during init to set sane defaults: UTF-8 everywhere, quiet prompts, synced clipboards, +;; and hands-off async shell buffers. Nothing to call—just launch Emacs and the environment is ready. +;; Native compilation is tuned for performance and its warnings get logged to comp-warnings.log. + +;;; Implementation Notes: +;; `cj/log-comp-warning` advices `display-warning` so native-comp notices land in the log instead of popping Messages. +;; Remove the advice if you need stock warning buffers for debugging. + +;;; Code: + +(require 'host-environment) + +;; -------------------------- Native Comp Preferences -------------------------- +;; after async compiler starts, set preferences and warning level + +(with-eval-after-load 'comp-run + (setopt native-comp-async-jobs-number 8) ; parallel compile workers + (setopt native-comp-speed 3) ; highest optimization level + (setopt native-comp-always-compile t)) ; always native-compile + +;; -------------------------- Log Native Comp Warnings ------------------------- +;; log native comp warnings rather than cluttering the buffer + +(defvar comp-warnings-log + (expand-file-name "comp-warnings.log" user-emacs-directory) + "File where native-comp warnings will be appended.") + +(defun cj/log-comp-warning (type message &rest args) + "Log native-comp warnings of TYPE with MESSAGE & ARGS to 'comp-warnings-log'. +Suppress them from appearing in the *Warnings* buffer. If TYPE contains 'comp', +log the warning with a timestamp to the file specified by 'comp-warnings-log'. +Return non-nil to indicate the warning was handled." + (when (memq 'comp (if (listp type) type (list type))) + (with-temp-buffer + (insert (format-time-string "[%Y-%m-%d %H:%M:%S] ")) + (insert (if (stringp message) + (apply #'format message args) + (format "%S %S" message args))) + (insert "\n") + (append-to-file (point-min) (point-max) comp-warnings-log)) + ;; Return non-nil to tell `display-warning' “we handled it.” + t)) + +(advice-add 'display-warning :before-until #'cj/log-comp-warning) + +;; ---------------------------------- Unicode ---------------------------------- +;; unicode everywhere + +(set-locale-environment "en_US.UTF-8") +(prefer-coding-system 'utf-8) +(set-default-coding-systems 'utf-8) +(set-terminal-coding-system 'utf-8) +(set-keyboard-coding-system 'utf-8) +(set-selection-coding-system 'utf-8) +(setq locale-coding-system 'utf-8) +(set-charset-priority 'unicode) +(setq x-select-request-type + '(UTF8_STRING COMPOUND_TEXT TEXT STRING)) + +;; -------------------------- Disabling Functionality -------------------------- + +(defun cj/disabled () + "Do absolutely nothing and do it quickly. +Used to disable functionality with defalias 'somefunc 'cj/disabled)." + (interactive)) + +;; VIEW EMACS NEWS +;; no news is good news +(defalias 'view-emacs-news 'cj/disabled) +(global-unset-key (kbd "C-h n")) + +;; DESCRIBE GNU PROJECT +;; no gnus is good gnus +(defalias 'describe-gnu-project 'cj/disabled) +(global-unset-key (kbd "C-h g")) + +;; CUSTOMIZATIONS +;; All customizations should be declared in Emacs init files. +;; Add accidental customizations via the customization interface to a temp file that's never read. +(setq custom-file (make-temp-file + "emacs-customizations-trashbin-")) + +;; ------------------------- Re-Enabling Functionality ------------------------- + +(put 'narrow-to-region 'disabled nil) ;; narrow-to-region is extremely useful! +(put 'upcase-region 'disabled nil) ;; upcase region is useful +(put 'erase-buffer 'disabled nil) ;; and so is erase-buffer + +;; ------------------------------ Non UI Settings ------------------------------ + +(setq ring-bell-function 'ignore) ;; disable the bell ring. +(setq default-directory user-home-dir) ;; consider user home the default directory + +(global-auto-revert-mode) ;; update the buffer when the associated file has changed +(setq global-auto-revert-non-file-buffers t) ;; do so for all buffer types (e.g., ibuffer) +(setq bidi-display-reordering nil) ;; don't reorder bidirectional text for display +(setq bidi-paragraph-direction t) ;; forces directionality of text for performance. + +(setq system-time-locale "C") ;; use en_US locale to format time. + +;; --------------------------------- Clipboard --------------------------------- +;; keep the clipboard and kill ring in sync + +(setq select-enable-clipboard t) ;; cut and paste using clipboard +(setq yank-pop-change-selection t) ;; update system clipboard when yanking in emacs +(setq save-interprogram-paste-before-kill t) ;; save existing clipboard to kill ring before replacing + +;; Additional settings for better clipboard integration +(setq select-enable-primary nil) ;; don't use X11 primary selection (no middle-click paste) +(setq mouse-drag-copy-region nil) ;; don't copy region to clipboard by selecting with mouse + +;; -------------------------------- Tab Settings ------------------------------- +;; spaces, not tabs + +(setq-default tab-width 4) ;; if tab, make them 4 spaces default +(setq-default indent-tabs-mode nil) ;; but turn off tabs by default + +;; ------------------------------ Scroll Settings ------------------------------ + +(setq mouse-wheel-scroll-amount '(1 ((shift) . 1))) ;; one line at a time +(setq mouse-wheel-progressive-speed nil) ;; don't accelerate scrolling + +;; ----------------------------- Case Insensitivity ---------------------------- +;; make user interfaces case insensitive + +(setq case-fold-search t) ;; case-insensitive searches +(setq completion-ignore-case t) ;; case-insensitive completion +(setq read-file-name-completion-ignore-case t) ;; case-insensitive file completion + +;; ------------------------------- Async Commands ------------------------------ +;; always create new async command buffers silently + +(setq async-shell-command-buffer 'new-buffer) + +;; never automatically display async command output buffers +;; but keep them in the buffer list for later inspection +(add-to-list 'display-buffer-alist + '("*Async Shell Command*" display-buffer-no-window (nil))) + +;; ------------------------ Mouse And Trackpad Settings ------------------------ +;; provide smoothest scrolling and avoid accidental gestures + +(setq mouse-wheel-follow-mouse 't) ;; scroll window under mouse +(setq scroll-margin 3) ;; start scrolling at 3 lines from top/bottom +(setq scroll-step 1) ;; keyboard scroll one line at a time + +;; disable pasting with mouse-wheel click +(global-unset-key (kbd "<mouse-2>")) + +;; disable pinching gesture or mouse-wheel changing font size +(global-unset-key (kbd "<pinch>")) +(global-set-key [remap mouse-wheel-text-scale] 'cj/disabled) + +;; ------------------------------- Be Quiet(er)! ------------------------------- +;; reduces "helpful" instructions that distract Emacs power users. + +(setq-default vc-follow-symlinks) ;; don't ask to follow symlinks if target is version controlled +(setq kill-buffer-query-functions ;; don't ask about killing buffers with processes, just kill them + (remq 'process-kill-buffer-query-function + kill-buffer-query-functions)) +(setq confirm-kill-processes nil) ;; automatically kill running processes on exit +(setq confirm-nonexistent-file-or-buffer nil) ;; don't ask if a file I visit with C-x C-f or C-x b doesn't exist +(setq ad-redefinition-action 'accept) ;; silence warnings about advised functions getting redefined. +(setq large-file-warning-threshold nil) ;; open files regardless of size +(fset 'yes-or-no-p 'y-or-n-p) ;; require a single letter for binary answers +(setq use-short-answers t) ;; same as above with Emacs 28+ +(setq auto-revert-verbose nil) ;; turn off auto revert messages +(setq custom-safe-themes t) ;; treat all themes as safe (stop asking) +(setq server-client-instructions nil) ;; I already know what to do when done with the frame + +;; ------------------ Reduce Garbage Collections In Minibuffer ----------------- +;; triggers garbage collection when it won't impact user minibuffer entries + +(defun cj/minibuffer-setup-hook () + "Hook to prevent garbage collection while user's in minibuffer." + (setq gc-cons-threshold most-positive-fixnum)) + +(defun cj/minibuffer-exit-hook () + "Hook to trigger garbage collection when exiting minibuffer." + (setq gc-cons-threshold 800000)) + +(add-hook 'minibuffer-setup-hook #'cj/minibuffer-setup-hook) +(add-hook 'minibuffer-exit-hook #'cj/minibuffer-exit-hook) + +;; ----------------------------- Bookmark Settings ----------------------------- +;; keep bookmarks in sync location, and save the file whenever a mark is added + +;; place bookmark file sync'd org files +(setq bookmark-default-file (concat sync-dir "emacs_bookmarks")) + +;; save bookmarks each (1) time it's modified. +(setq bookmark-save-flag 1) + +;; -------------------------------- Recent Files ------------------------------- +;; don't suggest bookmarks, packages, indexes, or recentf in recent files. + +(use-package recentf + :init + (recentf-mode 1) + :ensure nil ;;built-in + :config + (setq recentf-max-saved-items 1000) + (setq recentf-max-menu-items 50) + (add-to-list 'recentf-exclude "emacs_bookmarks") + (add-to-list 'recentf-exclude "\\.emacs\\.d/elpa") + (add-to-list 'recentf-exclude "\\.emacs\\.d/recentf") + (add-to-list 'recentf-exclude "\\ElfeedDB/index")) + +;; -------------------------- Autosave And Lock Files -------------------------- +;; don't create lockfiles or autosave (i.e., filename~) files. + +(setq auto-save-default nil) +(setq create-lockfiles nil) + +;; ------------------------------ Backup Settings ------------------------------ +;; per-save backups can be invaluable, so create them in ~/.emacs.d/backups + +;; BACKUP DIRECTORY CREATION +(defvar cj/backup-directory (concat user-emacs-directory "backups")) +(if (not (file-exists-p cj/backup-directory)) + (make-directory cj/backup-directory t)) + +;; BACKUP SETTINGS +(setq make-backup-files t) ;; do make backup files +(setq backup-directory-alist `(("." . ,cj/backup-directory))) ;; put all originals in backup directory +(setq backup-by-copying t) ;; don't clobber symlinks +(setq version-control t) ;; make numeric backup versions +(setq delete-old-versions t) ;; delete excess backup files w/o asking +(setq kept-new-versions 25) ;; keep 25 of the newest backups made (default: 2) +(setq vc-make-backup-files t) ;; also backup any files in version control + +;; ------------------------------- GNU 'ls' On BSD ------------------------------- +;; when on BSD use the ls from FSF sysutils/coreutils: pkg install coreutils + +(when (env-bsd-p) + (setq insert-directory-program "/usr/local/bin/gls")) + +(provide 'system-defaults) +;;; system-defaults.el ends here diff --git a/modules/system-utils.el b/modules/system-utils.el new file mode 100644 index 00000000..c02947b8 --- /dev/null +++ b/modules/system-utils.el @@ -0,0 +1,202 @@ +;;; system-utils --- System-Wide Utilities -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; +;; A "system-util" is an enhancement to common a Emacs command, or the extension +;; of an existing command. Perhaps this group can be better named. +;; +;; Eval-Buffer : This is a command I use frequently that's not mapped and +;; doesn't give confirmation. Fixed it. +;; Sudo-Edit : If you're using Emacs to edit system files, you'll want +;; this so you don't have to run Emacs as root. +;; Open-File-With : This set of methods will open a given file using the default application +;; for the OS and detached from Emacs. Leveraged in Dired/Dirvish. +;; Server Shutdown : Closes the running Emacs Server. Will close all attached Emacs clients. +;; savehist : Persistence for recently opened files, and minibuffer history +;; ibuffer : Better replacement for list-buffer with icons for easier recognition +;; scratch-buffer : A little joy in the scratch buffer can go a long way. +;; dictionary : look up words via sdcv +;; log-silently : for debugging messages where you don't want to see activity in the echo area. +;; proced : I was astonished to discover a process monitor application built into Emacs. +;; Then again, given all that's here, how surprising is it really? +;; +;;; Code: + +(require 'cl-lib) +(require 'host-environment) +(require 'user-constants) + +;;; -------------------------------- Eval Buffer -------------------------------- + +(defun cj/eval-buffer-with-confirmation-or-error-message () + "Evaluate the buffer and display a message." + (interactive) + (let ((result (eval-buffer))) + (if (not (eq result 'error)) + (message "Buffer evaluated.") + (message "error occurred during evaluation: %s" result)))) +(global-set-key (kbd "C-c b") 'cj/eval-buffer-with-confirmation-or-error-message) + +;;; ---------------------------- Edit A File With Sudo ---------------------------- + +(use-package sudo-edit + :defer 1 + :bind ("C-x M-f" . sudo-edit)) + +;;; ------------------------------- Open File With ------------------------------ +;; TASK: Favor this method over cj/open-this-file-with and add to custom buffer funcs + +(defun cj/open-file-with-command (command) + "Open the current file with COMMAND. +Works in both Dired buffers and regular file buffers. The command runs +fully detached from Emacs." + (interactive "MOpen with command: ") + (let* ((file (cond + ;; In dired/dirvish mode, get file at point + ((derived-mode-p 'dired-mode) + (require 'dired) + (dired-get-file-for-visit)) + ;; In a regular file buffer + (buffer-file-name + buffer-file-name) + ;; Fallback - prompt for file + (t + (read-file-name "File to open: ")))) + ;; For xdg-open and similar launchers, we need special handling + (is-launcher (member command '("xdg-open" "open" "start")))) + ;; Validate file exists + (unless (and file (file-exists-p file)) + (error "No valid file found or selected")) + ;; Use different approaches for launchers vs regular commands + (if is-launcher + ;; For launchers, use call-process with 0 to fully detach + (progn + (call-process command nil 0 nil file) + (message "Opening %s with %s..." (file-name-nondirectory file) command)) + ;; For other commands, use start-process-shell-command for potential output + (let* ((output-buffer-name (format "*Open with %s: %s*" + command + (file-name-nondirectory file))) + (output-buffer (generate-new-buffer output-buffer-name))) + (start-process-shell-command + command + output-buffer + (format "%s %s" command (shell-quote-argument file))) + (message "Running %s on %s..." command (file-name-nondirectory file)))))) + + +(defun cj/identify-external-open-command () + "Return the OS-default \"open\" command for this host. +Signals an error if the host is unsupported." + (cond + ((env-linux-p) "xdg-open") + ((env-macos-p) "open") + ((env-windows-p) "start") + (t (error "external-open: unsupported host environment")))) + +(defun cj/xdg-open (&optional filename) + "Open FILENAME (or the file at point) with the OS default handler. +Logs output and exit code to buffer *external-open.log*." + (interactive) + (let* ((file (expand-file-name (or filename (dired-file-name-at-point)))) + (cmd (cj/identify-external-open-command)) + (logbuf (get-buffer-create "*external-open.log*"))) + (with-current-buffer logbuf + (goto-char (point-max)) + (insert (format-time-string "[%Y-%m-%d %H:%M:%S] ")) + (insert (format "Opening: %s\n" file))) + (cond + ;; Windows: let the shell handle association; fully detached. + ((env-windows-p) + (w32-shell-execute "open" file)) + ;; macOS/Linux: run the opener synchronously; it returns immediately. + (t + (call-process cmd nil 0 nil file) + (with-current-buffer logbuf + (insert " → Launched asynchronously\n")))) + nil)) + +;;; ------------------------------ Server Shutdown ------------------------------ + +(defun server-shutdown () + "Save buffers, kill Emacs and shutdown the server." + (interactive) + (save-some-buffers) + (kill-emacs)) +(global-set-key (kbd "C-<f10>") #'server-shutdown) + +;;; ---------------------------- History Persistence ---------------------------- +;; Persist history over Emacs restarts + +(use-package savehist + :ensure nil ; built-in + :init + (savehist-mode) + :config + (setq savehist-file "~/.emacs.d/.emacs-history")) + +;;; ------------------------ List Buffers With Nerd Icons ----------------------- + +(global-set-key [remap list-buffers] #'ibuffer) +(use-package nerd-icons-ibuffer + :defer 0.5 + :after nerd-icons + :hook (ibuffer-mode . nerd-icons-ibuffer-mode) + :config + (setq nerd-icons-ibuffer-icon t + nerd-icons-ibuffer-color-icon t + nerd-icons-ibuffer-human-readable-size t)) + +;;; -------------------------- Scratch Buffer Happiness ------------------------- + +(defvar scratch-emacs-version-and-system + (concat ";; Emacs " emacs-version + " on " system-configuration ".\n")) +(defvar scratch-greet + (concat ";; Emacs ♥ you, " user-login-name ". Happy Hacking!\n\n")) +(setq initial-scratch-message + (concat scratch-emacs-version-and-system scratch-greet)) +(setq initial-major-mode 'org-mode) + +;;; --------------------------------- Dictionary -------------------------------- + +(use-package quick-sdcv + :defer 1 + :bind + ("C-h d" . quick-sdcv-search-input) + :custom + (quick-sdcv-dictionary-prefix-symbol "►") + (quick-sdcv-ellipsis " ▼")) + +;;; -------------------------------- Log Silently ------------------------------- + +(defun cj/log-silently (format-string &rest args) + "Append formatted message (FORMAT-STRING with ARGS) to *Messages* buffer. +This does so without echoing in the minibuffer." + (let ((inhibit-read-only t)) + (with-current-buffer (get-buffer-create "*Messages*") + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert (apply #'format format-string args)) + (unless (bolp) (insert "\n"))))) + +;;; ------------------------------ Process Monitor ------------------------------ + +(use-package proced + :ensure nil ;; built-in + :defer 0.5 + :commands proced + :bind ("C-M-p" . proced) + :custom + (proced-auto-update-flag t) + (proced-show-remote-processes t) + (proced-enable-color-flag t) + (proced-format 'custom) + :config + (add-to-list 'proced-format-alist + '(custom user pid ppid sess tree pcpu pmem rss start time + state (args comm)))) + +(provide 'system-utils) +;;; system-utils.el ends here diff --git a/modules/test-runner.el b/modules/test-runner.el new file mode 100644 index 00000000..73c4063c --- /dev/null +++ b/modules/test-runner.el @@ -0,0 +1,270 @@ +;;; test-runner.el --- Test Runner for Emacs Configuration -*- lexical-binding: t; -*- +;; author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Provides utilities for running ERT tests with focus/unfocus workflow +;; +;; Tests should be located in the Projectile project test directories, +;; typically "test" or "tests" under the project root. +;; Falls back to =~/.emacs.d/tests= if not in a Projectile project. +;; +;; The default mode is to load and run all tests. +;; +;; To focus on running a specific set of test files: +;; - Toggle the mode to "focus" mode +;; - Add specific test files to the list of tests in "focus" +;; - Running tests (smartly) will now just run those tests +;; +;; Don't forget to run all tests again in default mode at least once before finishing. +;; +;;; Code: + +(require 'ert) +(require 'cl-lib) + +;;; Variables + +(defvar cj/test-global-directory nil + "Fallback global test directory when not in a Projectile project.") + +(defvar cj/test-focused-files '() + "List of test files for focused test execution. + +Each element is a filename (without path) to run.") + +(defvar cj/test-mode 'all + "Current test execution mode. + +Either 'all (run all tests) or 'focused (run only focused tests).") + +(defvar cj/test-last-results nil + "Results from the last test run.") + +;;; Core Functions + +;;;###autoload +(defun cj/test--get-test-directory () + "Return the test directory path for the current project. + +If in a Projectile project, prefers a 'test' or 'tests' directory inside the project root. +Falls back to =cj/test-global-directory= if not found or not in a project." + (require 'projectile) + (let ((project-root (ignore-errors (projectile-project-root)))) + (if (not (and project-root (file-directory-p project-root))) + ;; fallback global test directory + cj/test-global-directory + (let ((test-dir (expand-file-name "test" project-root)) + (tests-dir (expand-file-name "tests" project-root))) + (cond + ((file-directory-p test-dir) test-dir) + ((file-directory-p tests-dir) tests-dir) + (t cj/test-global-directory)))))) + +;;;###autoload +(defun cj/test--get-test-files () + "Return a list of test file names (without path) in the appropriate test directory." + (let ((dir (cj/test--get-test-directory))) + (when (file-directory-p dir) + (mapcar #'file-name-nondirectory + (directory-files dir t "^test-.*\\.el$"))))) + +;;;###autoload +(defun cj/test-load-all () + "Load all test files from the appropriate test directory." + (interactive) + (cj/test--ensure-test-dir-in-load-path) + (let ((dir (cj/test--get-test-directory))) + (unless (file-directory-p dir) + (user-error "Test directory %s does not exist" dir)) + (let ((test-files (directory-files dir t "^test-.*\\.el$")) + (loaded-count 0)) + (dolist (file test-files) + (condition-case err + (progn + (load-file file) + (setq loaded-count (1+ loaded-count)) + (message "Loaded test file: %s" (file-name-nondirectory file))) + (error + (message "Error loading %s: %s" + (file-name-nondirectory file) + (error-message-string err))))) + (message "Loaded %d test file(s)" loaded-count)))) + +;;;###autoload +(defun cj/test-focus-add () + "Select test file(s) to add to the focused list." + (interactive) + (cj/test--ensure-test-dir-in-load-path) + (let* ((dir (cj/test--get-test-directory)) + (available-files (when (file-directory-p dir) + (mapcar #'file-name-nondirectory + (directory-files dir t "^test-.*\\.el$"))))) + (if (null available-files) + (user-error "No test files found in %s" dir) + (let* ((unfocused-files (cl-set-difference available-files + cj/test-focused-files + :test #'string=)) + (selected (if unfocused-files + (completing-read "Add test file to focus: " + unfocused-files + nil t) + (user-error "All test files are already focused")))) + (push selected cj/test-focused-files) + (message "Added to focus: %s" selected) + (when (called-interactively-p 'interactive) + (cj/test-view-focused)))))) + +;;;###autoload +(defun cj/test-focus-add-this-buffer-file () + "Add the current buffer's file to the focused test list." + (interactive) + (let ((file (buffer-file-name)) + (dir (cj/test--get-test-directory))) + (unless file + (user-error "Current buffer is not visiting a file")) + (unless (string-prefix-p (file-truename dir) (file-truename file)) + (user-error "File is not inside the test directory: %s" dir)) + (let ((relative (file-relative-name file dir))) + (if (member relative cj/test-focused-files) + (message "Already focused: %s" relative) + (push relative cj/test-focused-files) + (message "Added to focus: %s" relative) + (when (called-interactively-p 'interactive) + (cj/test-view-focused)))))) + +;;;###autoload +(defun cj/test-focus-remove () + "Remove a test file from the focused list." + (interactive) + (if (null cj/test-focused-files) + (user-error "No focused files to remove") + (let ((selected (completing-read "Remove from focus: " + cj/test-focused-files + nil t))) + (setq cj/test-focused-files + (delete selected cj/test-focused-files)) + (message "Removed from focus: %s" selected) + (when (called-interactively-p 'interactive) + (cj/test-view-focused))))) + +;;;###autoload +(defun cj/test-focus-clear () + "Clear all focused test files." + (interactive) + (setq cj/test-focused-files '()) + (message "Cleared all focused test files")) + +(defun cj/test--extract-test-names (file) + "Extract test names from FILE. + +Returns a list of test name symbols defined in the file." + (let ((test-names '())) + (with-temp-buffer + (insert-file-contents file) + (goto-char (point-min)) + ;; Find all (ert-deftest NAME ...) forms +;; (while (re-search-forward "^\s-*(ert-deftest\s-+\\(\\(?:\\sw\\|\\s_\\)+\\)" nil t) + (while (re-search-forward "^[[:space:]]*(ert-deftest[[:space:]]+\\(\\(?:\\sw\\|\\s_\\)+\\)" nil t) + (push (match-string 1) test-names))) + test-names)) + +;;;###autoload +(defun cj/test-run-focused () + "Run only the focused test files." + (interactive) + (if (null cj/test-focused-files) + (user-error "No focused files set. Use =cj/test-focus-add' first") + (let ((all-test-names '()) + (loaded-count 0) + (dir (cj/test--get-test-directory))) + ;; Load the focused files and collect their test names + (dolist (file cj/test-focused-files) + (let ((full-path (expand-file-name file dir))) + (when (file-exists-p full-path) + (load-file full-path) + (setq loaded-count (1+ loaded-count)) + ;; Extract test names from this file + (let ((test-names (cj/test--extract-test-names full-path))) + (setq all-test-names (append all-test-names test-names)))))) + (if (null all-test-names) + (message "No tests found in focused files") + ;; Build a regexp that matches any of our test names + (let ((pattern (regexp-opt all-test-names))) + (message "Running %d test(s) from %d focused file(s)" + (length all-test-names) loaded-count) + ;; Run only the tests we found + (ert (concat "^" pattern "$"))))))) + +(defun cj/test--ensure-test-dir-in-load-path () + "Ensure the directory returned by cj/test--get-test-directory is in `load-path`." + (let ((dir (cj/test--get-test-directory))) + (when (and dir (file-directory-p dir)) + (add-to-list 'load-path dir)))) + +;;;###autoload +(defun cj/run-test-at-point () + "Run the ERT test at point. +If point is inside an `ert-deftest` definition, run that test only. +Otherwise, message that no test is found." + (interactive) + (let ((original-point (point))) + (save-excursion + (beginning-of-defun) + (condition-case nil + (let ((form (read (current-buffer)))) + (if (and (listp form) + (eq (car form) 'ert-deftest) + (symbolp (cadr form))) + (ert (cadr form)) + (message "Not in an ERT test method."))) + (error (message "No ERT test methods found at point.")))) + (goto-char original-point))) + +;;;###autoload +(defun cj/test-run-all () + "Load and run all tests." + (interactive) + (cj/test-load-all) + (ert t)) + +;;;###autoload +(defun cj/test-toggle-mode () + "Toggle between 'all and 'focused test execution modes." + (interactive) + (setq cj/test-mode (if (eq cj/test-mode 'all) 'focused 'all)) + (message "Test mode: %s" cj/test-mode)) + +;;;###autoload +(defun cj/test-view-focused () + "Display test files in focus." + (interactive) + (if (null cj/test-focused-files) + (message "No focused test files") + (message "Focused files: %s" + (mapconcat 'identity cj/test-focused-files ", ")))) + +;;;###autoload +(defun cj/test-run-smart () + "Run tests based on current mode (all or focused)." + (interactive) + (if (eq cj/test-mode 'all) + (cj/test-run-all) + (cj/test-run-focused))) + +;; Test runner operations prefix and keymap +(define-prefix-command 'cj/test-map nil + "Keymap for test-runner operations.") +(define-key cj/custom-keymap "t" 'cj/test-map) + +(define-key cj/test-map "L" 'cj/test-load-all) +(define-key cj/test-map "R" 'cj/test-run-all) +(define-key cj/test-map "." 'cj/run-test-at-point) +(define-key cj/test-map "r" 'cj/test-run-smart) +(define-key cj/test-map "a" 'cj/test-focus-add) +(define-key cj/test-map "b" 'cj/test-focus-add-this-buffer-file) +(define-key cj/test-map "c" 'cj/test-focus-clear) +(define-key cj/test-map "v" 'cj/test-view-focused) +(define-key cj/test-map "t" 'cj/test-toggle-mode) + +(provide 'test-runner) +;;; test-runner.el ends here diff --git a/modules/text-config.el b/modules/text-config.el new file mode 100644 index 00000000..45f0e8b2 --- /dev/null +++ b/modules/text-config.el @@ -0,0 +1,121 @@ +;;; text-config --- Text Settings and Functionality -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; Configuration for text editing features including: +;; - Basic text mode settings (visual line mode, indentation, spacing) +;; - Text manipulation (move-text, expand-region, change-inner) +;; - Selection behavior (delete-selection-mode) +;; - Editing tools (edit-indirect, olivetti, accent) +;; - Visual enhancements (prettify-symbols, visual-fill-column) + +;;; Code: + +;; ------------------------------- Text Settings ------------------------------- + +;; Global text settings +(setq-default indent-tabs-mode nil) ;; indentation should not insert tabs +(setq require-final-newline nil) ;; don't require newlines at the end of files +(setq sentence-end-double-space nil) ;; in the 21st century, sentences end w/ a single space + +(defun cj/text-mode-settings () + "Personal settings for `text-mode'." + (turn-on-visual-line-mode)) ;; wrap text in text modes +(add-hook 'text-mode-hook 'cj/text-mode-settings) + +;; --------------------------------- Move Text --------------------------------- +;; move the current line or selected region up or down in the buffer + +(use-package move-text + :defer 0.5 + :bind + (("C-<up>" . move-text-up) + ("C-<down>" . move-text-down))) + +;; ------------------------------- Expand Region ------------------------------- +;; increase the region by semantic units + +(use-package expand-region + :defer 0.5 + :bind + (("M-=" . er/expand-region) + ("C->" . er/expand-region) + ("M--" . er/contract-region) + ("C-<" . er/contract-region))) + +;; ---------------------------- Change Inner / Outer --------------------------- +;; change inner and outer, just like in vim. + +(use-package change-inner + :defer 0.5 + :bind (("C-c i" . change-inner) + ("C-c o" . change-outer))) + +;; ------------------------------ Delete Selection ----------------------------- +;; delete the region on character insertion + +(use-package delsel + :ensure nil ;; built-in + :defer 0.5 + :config + (delete-selection-mode t)) + +;; ------------------------------- Edit Indirect ------------------------------- +;; edit selection in new buffer, C-c to finish; replaces with modifications + +(use-package edit-indirect + :defer 1 + :bind ("M-I" . edit-indirect-region)) + +;; ------------------------------ Prettify Symbols ----------------------------- +;; replacing the word l-a-m-b-d-a with a symbol, just because + +(setq-default prettify-symbols-alist + (let ((mapping (lambda (pair) + (let ((k (car pair)) + (v (cdr pair))) + (list (cons (downcase k) v) + (cons (upcase k) v)))))) + (apply #'append + (mapcar mapping + '( + ("#+begin_src" . "λ") + ("#+begin_src" . "λ") + ("#+end_src" . "λ") + ("#+begin_quote" . "") + ("#+end_quote" . "") + ("lambda" . "λ")))))) + + +(add-hook 'prog-mode-hook 'turn-on-prettify-symbols-mode) +(add-hook 'org-mode-hook 'turn-on-prettify-symbols-mode) + +;; ---------------------------------- Olivetti --------------------------------- +;; center text in the middle of the screen. + +(use-package olivetti + :defer 1 + :config + (setq-default olivetti-body-width 100)) + +;; --------------------------- Accent (Diacriticals) --------------------------- +;; an easy way to enter diacritical marks + +(use-package accent + :defer 1 + :bind ("C-`" . accent-company)) + +;; ----------------------------- Visual Fill Column ---------------------------- +;; text wrapping + +;; (use-package visual-fill-column +;; :defer 0.5 +;; :config +;; (setq-default visual-fill-column-center-text nil) +;; (setq-default visual-fill-column-width 100) +;; :hook +;; (visual-line-mode . visual-fill-column-mode)) + +(provide 'text-config) +;;; text-config.el ends here diff --git a/modules/tramp-config.el b/modules/tramp-config.el new file mode 100644 index 00000000..5f58678a --- /dev/null +++ b/modules/tramp-config.el @@ -0,0 +1,135 @@ +;;; tramp-config.el --- Tramp Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; TRAMP (Transparent Remote Access, Multiple Protocol) +;; +;; To handle fancy prompts on remote servers, add this to your shell configuration: +;; +;; [[ $TERM == "dumb" ]] && PS1='$ ' && return +;; [[ $TERM == "tramp" ]] && PS1='$ ' && return +;; +;; For zsh users: +;; [[ $TERM == "dumb" || $TERM == "tramp" ]] && unsetopt zle && PS1='$ ' && return + +;;; Code: + +(use-package tramp + :defer .5 + :ensure nil ;; built-in + :config + ;; Debugging (uncomment when needed) + ;; (setq tramp-debug-buffer t) + ;; (setq tramp-verbose 10) + + ;; Basic Settings + ;; Terminal type reported by tramp to host + (setq tramp-terminal-type "dumb") + + ;; Use the path assigned to the remote user by the remote host + (add-to-list 'tramp-remote-path 'tramp-own-remote-path) + + ;; Also check for a ~/.ssh/config host entry + (tramp-set-completion-function "ssh" + '((tramp-parse-sconfig "/etc/ssh/ssh_config") + (tramp-parse-sconfig "~/.ssh/config"))) + + ;; File Handling + ;; Store auto-save files locally + (setq tramp-auto-save-directory + (expand-file-name "tramp-auto-save" user-emacs-directory)) + + ;; Create directory if it doesn't exist + (unless (file-exists-p tramp-auto-save-directory) + (make-directory tramp-auto-save-directory t)) + + ;; Turn off the backup "$filename~" feature for remote files + (setq remote-file-name-inhibit-auto-save-visited t) + (add-to-list 'backup-directory-alist + (cons tramp-file-name-regexp nil)) + + ;; Performance Settings + ;; Set a more representative name for the persistency file + (setq tramp-persistency-file-name + (expand-file-name "tramp-connection-history" user-emacs-directory)) + + ;; Always use external program to copy (more efficient) + (setq tramp-copy-size-limit nil) + + ;; Cache remote file attributes for better performance + (setq remote-file-name-inhibit-cache nil) + + ;; Don't check for modified buffers before revert + ;; to avoid unnecessary remote operations + (setq revert-without-query '(".*")) + + ;; Refresh buffers when needed rather than automatically + (setq auto-revert-remote-files nil) + + ;; Security & Authentication + ;; Cache and don't expire passwords + (setq password-cache t) + (setq password-cache-expiry nil) + + ;; Use SSH control connections for better performance + (setq tramp-use-ssh-controlmaster-options t) + + ;; Connection Settings + ;; Set tramp-direct-async-process locally in all ssh connections + (connection-local-set-profile-variables + 'remote-direct-async-process + '((tramp-direct-async-process . t))) + (connection-local-set-profiles + '(:application tramp :protocol "ssh") + 'remote-direct-async-process) + (connection-local-set-profiles + '(:application tramp :protocol "sshx") + 'remote-direct-async-process) + + ;; Set sane defaults for frequently used methods + (connection-local-set-profile-variables + 'remote-bash + '((shell-file-name . "/bin/bash") + (shell-command-switch . "-c"))) + (connection-local-set-profiles + '(:application tramp) + 'remote-bash) + + ;; Don't determine remote files VC status (for a performance gain) + (setq vc-ignore-dir-regexp (concat vc-ignore-dir-regexp "\\|" tramp-file-name-regexp)) + + ;; Method-specific settings + ;; Default transfer method (use scp for most efficient transfer) + (setq tramp-default-method "scp") + + ;; Use different methods based on host/domain patterns + (add-to-list 'tramp-methods + '("sshfast" + (tramp-login-program "ssh") + (tramp-login-args (("-l" "%u") ("-p" "%p") ("%c") + ("-e" "none") ("-t" "-t") ("%h"))) + (tramp-async-args (("-q"))) + (tramp-remote-shell "/bin/sh") + (tramp-remote-shell-login ("-l")) + (tramp-remote-shell-args ("-c")) + (tramp-connection-timeout 10))) + + ;; Remote shell and project settings + ;; Support for Docker containers + (add-to-list 'tramp-remote-path 'tramp-own-remote-path) + (add-to-list 'tramp-remote-path "/usr/local/bin") + (add-to-list 'tramp-remote-path "/usr/local/sbin") + + ;; Enable directory tracking (useful for eshell) + (setq dirtrack-list '("^.*?\\([a-zA-Z]:.*\\|/.*\\)" 1)) + + ;; Avoid problems with Git on TRAMP + (setq magit-git-executable "/usr/bin/git") + + ;; Cleanup settings + ;; Cleanup TRAMP buffers when idle (every 15 min) + (setq tramp-cleanup-idle-time 900)) + +(provide 'tramp-config) +;;; tramp-config.el ends here diff --git a/modules/ui-config.el b/modules/ui-config.el new file mode 100644 index 00000000..022a0574 --- /dev/null +++ b/modules/ui-config.el @@ -0,0 +1,141 @@ +;;; ui-config --- User Interface Preferences -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; This file centralizes user interface preferences, including: + +;; • Frame and window behavior +;; – Start all frames maximized +;; – Disable file‐ and dialog‐boxes +;; – Pixel scroll precision +;; – Show column numbers in the mode-line + +;; • Transparency controls +;; – Customizable variables 'cj/enable-transparency' and 'cj/transparency-level' +;; – Interactive 'cj/toggle-transparency' command + +;; • Cursor appearance +;; – Cursor color changes on the buffer's write and insertion state +;; (i.e., read-only, overwrite, normal) +;; – Option to customize cursor shape with 'cj/set-cursor-type' + +;; • Icons +;; – Load and enable 'nerd-icons' for UI glyphs + +;; Customize the transparency and cursor color options at the top of this file. + +;;; Code: + +;; -------------------------------- UI Constants ------------------------------- + +(defcustom cj/enable-transparency nil + "Non-nil means use `cj/transparency-level' for frame transparency." + :type 'boolean + :group 'ui-config) + +(defcustom cj/transparency-level 84 + "Opacity level for Emacs frames when `cj/enable-transparency' is non-nil. + +100 = fully opaque, 0 = fully transparent." + :type 'integer + :group 'ui-config) + +(defconst cj/cursor-colors + '((read-only . "#f06a3f") ; red – buffer is read-only + (overwrite . "#c48702") ; gold – overwrite mode + (normal . "#64aa0f")) ; green – insert & read/write + "Alist mapping cursor states to their colors.") + +;; ----------------------------- System UI Settings ---------------------------- + +(add-to-list 'initial-frame-alist '(fullscreen . maximized)) ;; start the initial frame maximized +(add-to-list 'default-frame-alist '(fullscreen . maximized)) ;; start every frame maximized +(setq pixel-scroll-precision-mode nil) ;; smooth scroll past images - enabled if nil! + +(setq-default frame-inhibit-implied-resize t) ;; don't resize frames when setting ui-elements +(setq frame-title-format '("Emacs " emacs-version" : %b")) ;; the title is emacs with version and buffer name + +(setq use-file-dialog nil) ;; no file dialog +(setq use-dialog-box nil) ;; no dialog boxes either +(column-number-mode 1) ;; show column number in the modeline +(setq switch-to-buffer-obey-display-actions t) ;; manual buffer switching obeys display action rules + +;; -------------------------------- Transparency ------------------------------- + +(defun cj/apply-transparency () + "Apply `cj/transparency-level' to the selected frame and future frames. + +When `cj/enable-transparency' is nil, reset alpha to fully opaque." + (let ((alpha (if cj/enable-transparency + (cons cj/transparency-level cj/transparency-level) + '(100 . 100)))) + ;; apply to current frame + (set-frame-parameter nil 'alpha alpha) + ;; update default for new frames + (setq default-frame-alist + (assq-delete-all 'alpha default-frame-alist)) + (add-to-list 'default-frame-alist `(alpha . ,alpha)))) + +;; apply once at startup +(cj/apply-transparency) + +(defun cj/toggle-transparency () + "Toggle `cj/enable-transparency' and re-apply." + (interactive) + (setq cj/enable-transparency (not cj/enable-transparency)) + (cj/apply-transparency) + (message "Transparency %s" + (if cj/enable-transparency "enabled" "disabled"))) + +;; ----------------------------------- Cursor ---------------------------------- +;; set cursor color according to mode +;; +;; #f06a3f indicates a read-only document +;; #c48702 indicates overwrite mode +;; #64aa0f indicates insert and read/write mode + +;; ----------------------------------- Cursor ---------------------------------- + +(defvar cj/-cursor-last-color nil + "Last color applied by `cj/set-cursor-color-according-to-mode'.") +(defvar cj/-cursor-last-buffer nil + "Last buffer name where cursor color was applied.") + +(defun cj/set-cursor-color-according-to-mode () + "Change cursor color according to \\='buffer-read-only or \\='overwrite state." + (let* ((state (cond + (buffer-read-only 'read-only) + (overwrite-mode 'overwrite) + (t 'normal))) + (color (alist-get state cj/cursor-colors))) + (unless (and (string= color cj/-cursor-last-color) + (string= (buffer-name) cj/-cursor-last-buffer)) + (set-cursor-color color) + (setq cj/-cursor-last-color color + cj/-cursor-last-buffer (buffer-name))))) + +(add-hook 'post-command-hook #'cj/set-cursor-color-according-to-mode) + +;; Don’t show a cursor in non-selected windows: +(setq cursor-in-non-selected-windows nil) + +;; Initialize to box cursor (or any type you prefer) +(defun cj/set-cursor-type (new-cursor-type) + "Set the cursor type of the selected frame to NEW-CURSOR-TYPE." + (interactive + (list (intern (completing-read + "Cursor type: " + (mapcar #'list '("box" "hollow" "bar" "hbar" nil)))))) + (modify-frame-parameters nil `((cursor-type . ,new-cursor-type)))) + +(cj/set-cursor-type 'box) + +;; --------------------------------- Nerd Icons -------------------------------- +;; use icons from nerd fonts in the Emacs UI + +(use-package nerd-icons + :demand t) + +(provide 'ui-config) +;;; ui-config.el ends here diff --git a/modules/ui-navigation.el b/modules/ui-navigation.el new file mode 100644 index 00000000..3dd0eeeb --- /dev/null +++ b/modules/ui-navigation.el @@ -0,0 +1,154 @@ +;;; ui-navigation --- Managing Cursor Placement, Buffers, and Windows -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; Window Navigation + +;; This section handles situations where we're navigating or arranging windows + +;; Shift + arrow keys = move the cursors around the windows/buffers +;; Control + Shift + arrow keys = resize the windows +;; Meta + Shift + arrow keys = move the windows around + +;; M-H - split windows, creating a new window horizontally to the right +;; M-V - split windows, creating a new window vertically to the bottom +;; M-T - toggle the orientation of the split between horizontal and vertical +;; M-S - swap window positions +;; M-C - kill the current window +;; M-O - kill the other window +;; M-Z - undo kill buffer +;; M-U - winner undo (revert to the previous layout) + +;; Adjusting Window Sizes +;; Note: C-s is pressing Control + Super keys +;; C-s-<left> move window left +;; C-s-<right> move window right +;; C-s-<up> move window up +;; C-s-<down> move window down + +;;; Code: + +;; ------------------------------ Window Placement ----------------------------- + +(use-package windmove + :defer .5 + :config + (windmove-default-keybindings)) ; move cursor around with shift+arrows + +;; ------------------------------ Window Resizing ------------------------------ + +(use-package windsize + :defer .5 + :bind + ("C-s-<left>" . windsize-left) + ("C-s-<right>" . windsize-right) + ("C-s-<up>" . windsize-up) + ("C-s-<down>" . windsize-down)) + +;; M-shift = to balance multiple split windows +(global-set-key (kbd "M-+") 'balance-windows) + +;; ------------------------------ Window Splitting ----------------------------- + +(defun cj/split-and-follow-right () + "Split window horizontally and select a buffer to display." + (interactive) + (split-window-right) + (other-window 1) + (consult-buffer)) +(global-set-key (kbd "M-V") 'cj/split-and-follow-right) + +(defun cj/split-and-follow-below () + "Split window vertically and select a buffer to display." + (interactive) + (split-window-below) + (other-window 1) + (consult-buffer)) +(global-set-key (kbd "M-H") 'cj/split-and-follow-below) + +;; ------------------------- Split Window Reorientation ------------------------ + +(defun toggle-window-split () + "Toggle the orientation of the current window split. + +If the window is split horizontally, change the split to vertical. +If it's vertical, change the split to horizontal. +This function won't work with more than one split window." + (interactive) + (if (= (count-windows) 2) + (let* ((this-win-buffer (window-buffer)) + (next-win-buffer (window-buffer (next-window))) + (this-win-edges (window-edges (selected-window))) + (next-win-edges (window-edges (next-window))) + (this-win-2nd (not (and (<= (car this-win-edges) + (car next-win-edges)) + (<= (cadr this-win-edges) + (cadr next-win-edges))))) + (splitter + (if (= (car this-win-edges) + (car (window-edges (next-window)))) + 'split-window-horizontally + 'split-window-vertically))) + (delete-other-windows) + (let ((first-win (selected-window))) + (funcall splitter) + (if this-win-2nd (other-window 1)) + (set-window-buffer (selected-window) this-win-buffer) + (set-window-buffer (next-window) next-win-buffer) + (select-window first-win) + (if this-win-2nd (other-window 1)))))) +(global-set-key (kbd "M-T") 'toggle-window-split) + +;; SWAP WINDOW POSITIONS +(global-set-key (kbd "M-S") 'window-swap-states) + +;; ---------------------------- Buffer Manipulation ---------------------------- + +;; MOVE BUFFER +;; allows changing buffer positions in a layout. +(use-package buffer-move + :bind + ("C-M-<down>" . buf-move-down) + ("C-M-<up>" . buf-move-up) + ("C-M-<left>" . buf-move-left) + ("C-M-<right>" . buf-move-right)) + + +;; UNDO KILL BUFFER +(defun cj/undo-kill-buffer (arg) + "Re-open the last buffer killed. With ARG, re-open the nth buffer." + (interactive "p") + (require 'recentf) + (unless recentf-mode + (recentf-mode 1)) + (let ((recently-killed-list (copy-sequence recentf-list)) + (buffer-files-list + (delq nil (mapcar (lambda (buf) + (when (buffer-file-name buf) + (expand-file-name (buffer-file-name buf)))) + (buffer-list))))) + (mapc + (lambda (buf-file) + (setq recently-killed-list + (delq buf-file recently-killed-list))) + buffer-files-list) + (when recently-killed-list + (find-file + (if arg (nth arg recently-killed-list) + (car recently-killed-list)))))) +(global-set-key (kbd "M-Z") 'cj/undo-kill-buffer) + +;; ---------------------------- Undo Layout Changes ---------------------------- +;; allows you to restore your window setup with C-c left-arrow +;; or redo a window change with C-c right-arrow if you change your mind + +(use-package winner + :ensure nil ;; built-in + :defer .5 + :bind ("M-U" . winner-undo) + :config + (winner-mode 1)) + +(provide 'ui-navigation) +;;; ui-navigation.el ends here diff --git a/modules/ui-theme.el b/modules/ui-theme.el new file mode 100644 index 00000000..d754b554 --- /dev/null +++ b/modules/ui-theme.el @@ -0,0 +1,135 @@ +;;; ui-theme.el --- UI Theme Configuration and Persistence -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: + +;; This module provides a theme management system with persistence across +;; Emacs sessions. +;; +;; - Theme selection via interactive interface (M-L) +;; - Automatic persistence of theme preference to a configurable file +;; - Safe theme loading with fallback mechanism +;; - Support for custom themes in the "themes" subdirectory +;; +;; The persistence mechanism saves theme choices to a file that can be +;; synchronized across machines, ensuring consistent appearance across +;; multiple computers. + +;;; Code: + +(require 'user-constants) ;; For sync-dir + +;; ----------------------------------- Themes ---------------------------------- +;; theme choices and settings + +;; downloaded custom themes go in themes subdirectory +(setq custom-safe-themes t) ;; trust all custom themes +(add-to-list 'custom-theme-load-path + (concat user-emacs-directory "themes")) + +;; ------------------------------- Switch Themes ------------------------------- +;; loads themes in completing read, then persists via the functions below + +(defun cj/switch-themes () + "Function to switch themes and save chosen theme name for persistence. + +Unloads any other applied themes before applying the chosen theme." + (interactive) + (let ((chosentheme "")) + (setq chosentheme + (completing-read "Load custom theme: " + (mapcar #'symbol-name + (custom-available-themes)))) + (mapc #'disable-theme custom-enabled-themes) + (load-theme (intern chosentheme) t)) + (cj/save-theme-to-file)) + +(global-set-key (kbd "M-L") 'cj/switch-themes) + +;; ----------------------------- Theme Persistence ----------------------------- +;; persistence utility functions used by switch themes. + +(defvar theme-file (concat sync-dir "emacs-theme.persist") + "The location of the file to persist the theme name. + +If you want your theme change to persist across instances, put this in a +directory that is sync'd across machines with this configuration.") + +(defvar fallback-theme-name "modus-vivendi" + "The name of the theme to fallback on. + +This is used then there's no file, or the theme name doesn't match +any of the installed themes. This should be a built-in theme. If theme name is +'nil', there will be no theme.") + +(defun cj/read-file-contents (filename) + "Read FILENAME and return its content as a string. + +If FILENAME isn't readable, return nil." + (when (file-readable-p filename) + (with-temp-buffer + (insert-file-contents filename) + (string-trim (buffer-string))))) + +(defun cj/write-file-contents (content filename) + "Write CONTENT to FILENAME. + +If FILENAME isn't writeable, return nil. If successful, return t." + (when (file-writable-p filename) + (condition-case err + (progn + (with-temp-buffer + (insert content) + (write-file filename)) + t) + (error + (message "Error writing to %s: %s" filename (error-message-string err)) + nil)))) + +(defun cj/get-active-theme-name () + "Return the name of the active UI theme as a string. + +Returns fallback-theme-name if no theme is active." + (if custom-enabled-themes + (symbol-name (car custom-enabled-themes)) + fallback-theme-name)) + +(defun cj/save-theme-to-file () + "Save the string representing the current theme to the theme-file." + (if (equal (cj/write-file-contents (cj/get-active-theme-name) theme-file) nil) + (message "Cannot save theme: %s is unwriteable" theme-file) + (message "%s theme saved to %s" (cj/get-active-theme-name) theme-file))) + +(defun cj/load-fallback-theme (msg) + "Display MSG and load ui-theme fallback-theme-name. + +Used to handle errors with loading persisted theme." + (message "%s Loading fallback theme %s" msg fallback-theme-name) + (load-theme (intern fallback-theme-name) t)) + +(defun cj/load-theme-from-file () + "Apply the theme name contained in theme-file as the active UI theme. + +If the theme is nil, it disables all current themes. If an error occurs +loading the file name, the fallback-theme-name is applied and saved." + (let ((theme-name (cj/read-file-contents theme-file))) + ;; if theme-name is nil, unload all themes and load fallback theme + (if (not theme-name) + (progn + (mapc #'disable-theme custom-enabled-themes) + (cj/load-fallback-theme "Theme file not found or empty.")) + ;; Check if theme is 'nil' string + (if (string= theme-name "nil") + (mapc #'disable-theme custom-enabled-themes) + ;; apply theme name or if error, load fallback theme + (condition-case err + (load-theme (intern theme-name) t) + (error + (cj/load-fallback-theme + (format "Error loading theme %s: %s." + theme-name (error-message-string err))))))))) + +(cj/load-theme-from-file) + +(provide 'ui-theme) +;;; ui-theme.el ends here diff --git a/modules/undead-buffers.el b/modules/undead-buffers.el new file mode 100644 index 00000000..711b657b --- /dev/null +++ b/modules/undead-buffers.el @@ -0,0 +1,181 @@ +;;; undead-buffers.el --- Bury Rather Than Kill These Buffers -*- lexical-binding: t; coding: utf-8; -*- + +;;; Commentary: +;; +;; This library allows for “burying” selected buffers instead of killing them. +;; Since they won't be killed, I'm calling them "undead buffers". +;; The main function cj/kill-buffer-or-bury-alive replaces kill-buffer. +;; +;; Additional helper commands and key bindings: +;; - M-C (=cj/kill-buffer-and-window=): delete this window and bury/kill its buffer. +;; - M-O (=cj/kill-other-window=): delete the next window and bury/kill its buffer. +;; - M-M (=cj/kill-all-other-buffers-and-windows=): kill or bury all buffers except +;; the current one and delete all other windows. +;; +;; Add to the list of "undead buffers" by adding to the cj/buffer-bury-alive-list +;; variable. +;; +;;; Code: + +(defvar cj/buffer-bury-alive-list + '("*dashboard*" "*scratch*" "*EMMS Playlist*" "*Messages*" "*ert*" "*AI-Assistant*") + "Buffers to bury instead of killing.") + +(defun cj/kill-buffer-or-bury-alive (buffer) + "Kill BUFFER or bury it if it's in `cj/buffer-bury-alive-list'." + (interactive "bBuffer to kill or bury: ") + (with-current-buffer buffer + (if current-prefix-arg + (progn + (add-to-list 'cj/buffer-bury-alive-list (buffer-name)) + (message "Added %s to bury-alive-list" (buffer-name))) + (if (member (buffer-name) cj/buffer-bury-alive-list) + (bury-buffer) + (kill-buffer))))) +(global-set-key [remap kill-buffer] #'cj/kill-buffer-or-bury-alive) + +(defun cj/undead-buffer-p () + "Predicate for =save-some-buffers= that skips buffers in =cj/buffer-bury-alive-list=." + (let* ((buf (current-buffer)) + (name (buffer-name buf))) + (and + (not (member name cj/buffer-bury-alive-list)) + (buffer-file-name buf) + (buffer-modified-p buf)))) + +(defun cj/save-some-buffers (&optional arg) + "Save some buffers, omitting those in =cj/buffer-bury-alive-list=. +ARG is passed to =save-some-buffers=." + (interactive "P") + (save-some-buffers arg #'cj/undead-buffer-p)) + +(defun cj/kill-buffer-and-window () + "Delete window and kill or bury its buffer." + (interactive) + (let ((buf (current-buffer))) + (delete-window) + (cj/kill-buffer-or-bury-alive buf))) +(global-set-key (kbd "M-C") #'cj/kill-buffer-and-window) + +(defun cj/kill-other-window () + "Delete the next window and kill or bury its buffer." + (interactive) + (other-window 1) + (let ((buf (current-buffer))) + (unless (one-window-p) + (delete-window)) + (cj/kill-buffer-or-bury-alive buf))) +(global-set-key (kbd "M-O") #'cj/kill-other-window) + +(defun cj/kill-all-other-buffers-and-windows () + "Kill or bury all other buffers, then delete other windows." + (interactive) + (cj/save-some-buffers) + (delete-other-windows) + (mapc #'cj/kill-buffer-or-bury-alive + (delq (current-buffer) (buffer-list)))) +(global-set-key (kbd "M-M") #'cj/kill-all-other-buffers-and-windows) + +(provide 'undead-buffers) +;;; undead-buffers.el ends here. + +;; --------------------------------- ERT Tests --------------------------------- +;; Run these tests with M-x ert RET t RET + +(require 'ert) +(require 'cl-lib) + +(ert-deftest undead-buffers/kill-or-bury-when-not-in-list-kills () + "cj/kill-buffer-or-bury-alive should kill a buffer not in `cj/buffer-bury-alive-list'." + (let* ((buf (generate-new-buffer "test-not-in-list")) + (orig (copy-sequence cj/buffer-bury-alive-list))) + (unwind-protect + (progn + (should (buffer-live-p buf)) + (cj/kill-buffer-or-bury-alive (buffer-name buf)) + (should-not (buffer-live-p buf))) + (setq cj/buffer-bury-alive-list orig) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(ert-deftest undead-buffers/kill-or-bury-when-in-list-buries () + "cj/kill-buffer-or-bury-alive should bury (not kill) a buffer in the list." + (let* ((name "*dashboard*") ; an element already in the default list + (buf (generate-new-buffer name)) + (orig (copy-sequence cj/buffer-bury-alive-list)) + win-was) + (unwind-protect + (progn + (add-to-list 'cj/buffer-bury-alive-list name) + ;; show it in a temporary window so we can detect bury + (setq win-was (display-buffer buf)) + (cj/kill-buffer-or-bury-alive name) + ;; bury should leave it alive + (should (buffer-live-p buf)) + ;; note: Emacs’s `bury-buffer` does not delete windows by default, + ;; so we no longer assert that no window shows it. + ) + ;; cleanup + (setq cj/buffer-bury-alive-list orig) + (delete-windows-on buf) + (kill-buffer buf)))) + +(ert-deftest undead-buffers/kill-or-bury-adds-to-list-with-prefix () + "Calling `cj/kill-buffer-or-bury-alive' with a prefix arg should add the buffer to the list." + (let* ((buf (generate-new-buffer "test-add-prefix")) + (orig (copy-sequence cj/buffer-bury-alive-list))) + (unwind-protect + (progn + (let ((current-prefix-arg '(4))) + (cj/kill-buffer-or-bury-alive (buffer-name buf))) + (should (member (buffer-name buf) cj/buffer-bury-alive-list))) + (setq cj/buffer-bury-alive-list orig) + (kill-buffer buf)))) + +(ert-deftest undead-buffers/kill-buffer-and-window-removes-window () + "cj/kill-buffer-and-window should delete the current window and kill/bury its buffer." + (let* ((buf (generate-new-buffer "test-kill-and-win")) + (orig (copy-sequence cj/buffer-bury-alive-list))) + (split-window) ; now two windows + (let ((win (next-window))) + (set-window-buffer win buf) + (select-window win) + (cj/kill-buffer-and-window) + (should-not (window-live-p win)) + (unless (member (buffer-name buf) orig) + (should-not (buffer-live-p buf)))) + (setq cj/buffer-bury-alive-list orig))) + +(ert-deftest undead-buffers/kill-other-window-deletes-that-window () + "cj/kill-other-window should delete the *other* window and kill/bury its buffer." + (let* ((buf1 (current-buffer)) + (buf2 (generate-new-buffer "test-other-window")) + (orig (copy-sequence cj/buffer-bury-alive-list))) + (split-window) + (let* ((win1 (selected-window)) + (win2 (next-window win1))) + (set-window-buffer win2 buf2) + ;; stay on the original window + (select-window win1) + (cj/kill-other-window) + (should-not (window-live-p win2)) + (unless (member (buffer-name buf2) orig) + (should-not (buffer-live-p buf2)))) + (setq cj/buffer-bury-alive-list orig))) + +(ert-deftest undead-buffers/kill-all-other-buffers-and-windows-keeps-only-current () + "cj/kill-all-other-buffers-and-windows should delete other windows and kill/bury all other buffers." + (let* ((main (current-buffer)) + (extra (generate-new-buffer "test-all-others")) + (orig (copy-sequence cj/buffer-bury-alive-list))) + (split-window) + (set-window-buffer (next-window) extra) + (cj/kill-all-other-buffers-and-windows) + (should (one-window-p)) + ;; main buffer still exists + (should (buffer-live-p main)) + ;; extra buffer either buried or killed + (unless (member (buffer-name extra) orig) + (should-not (buffer-live-p extra))) + ;; cleanup + (setq cj/buffer-bury-alive-list orig) + (when (buffer-live-p extra) (kill-buffer extra)))) diff --git a/modules/user-constants.el b/modules/user-constants.el new file mode 100644 index 00000000..fd48ffe3 --- /dev/null +++ b/modules/user-constants.el @@ -0,0 +1,180 @@ +;;; user-constants.el --- User Constants -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;;; Commentary: + +;; This module defines important file and directory paths used throughout the +;; Emacs configuration, and ensures they exist during startup. +;; +;; WHY THIS EXISTS: +;; 1. Centralizes all path definitions for easy reference and maintenance +;; 2. Prevents startup errors when required directories or files are missing +;; 3. Makes the configuration more portable across different machines +;; +;; The module first defines constants and variables for directories and files, +;; then provides functions that verify their existence, creating them if needed. +;; This happens automatically when the module loads. +;; +;; The paths are designed with a hierarchical structure, allowing child paths +;; to reference their parents (e.g., roam-dir is inside sync-dir) for better +;; maintainability. +;; +;;; Code: + +;; -------------------------------- Contact Info ------------------------------- + +(defvar user-whole-name "Craig Jennings" + "The user's full name.") +(defconst user-name (getenv "USER") + "The user's name retrieved from the environment variable.") +(defvar user-mail-address "c@cjennings.net" + "The user's email address.") + +;; ------------------------ Directory And File Constants ----------------------- + +;; DIRECTORIES +(defconst emacs-init-file (expand-file-name "init.el" user-emacs-directory) + "The location of Emacs's main init file.") + +(defconst emacs-early-init-file (expand-file-name "early-init.el" user-emacs-directory) + "The location of Emacs's early init file.") + +(defconst user-home-dir (getenv "HOME") + "The user's home directory per the environment variable.") + +(defconst books-dir (expand-file-name "sync/books/" user-home-dir) + "The location of book files for CalibreDB.") + +(defconst code-dir (expand-file-name "code/" user-home-dir) + "Code repositories are located in this directory.") + +(defconst dl-dir (expand-file-name "downloads/" user-home-dir) + "Location of the general downloads directory.") + +(defconst pix-dir (expand-file-name "pictures/" user-home-dir) + "Location of where pictures and images are stored.") + +(defconst projects-dir (expand-file-name "projects/" user-home-dir) + "Non-code projects and repositories are located in this directory.") + +(defconst videos-dir (expand-file-name "videos/" user-home-dir) + "Location of where videos are stored.") + +(defconst mail-dir (expand-file-name ".mail/" user-home-dir) + "Root directory where the mail folders are located.") + +(defconst sync-dir (expand-file-name "sync/org/" user-home-dir) + "This directory is synchronized across machines.") + +(defconst roam-dir (expand-file-name "roam/" sync-dir) + "The location of org-roam files.") + +(defconst journals-dir (expand-file-name "journal/" roam-dir) + "The location of org-roam dailies or journals files.") + +(defconst drill-dir (expand-file-name "drill/" sync-dir) + "The location of org-drill org files.") + +(defconst snippets-dir (expand-file-name "snippets/" sync-dir) + "The location of ya-snippet snippets.") + +(defvar sounds-dir (expand-file-name "assets/sounds/" user-emacs-directory) + "Directory containing sound files for notifications and timers.") + +(defconst video-recordings-dir (expand-file-name "sync/recordings/" user-home-dir) + "The location to save video recordings.") + +(defconst audio-recordings-dir (expand-file-name "sync/recordings/" user-home-dir) + "The location to save audio recordings.") + +(defconst music-dir (expand-file-name "music/" user-home-dir) + "The location to save your music files.") + + +;; FILES +(defvar authinfo-file (expand-file-name ".authinfo.gpg" user-home-dir) + "The location of the encrypted .authinfo or .netrc file.") + +(defvar schedule-file (expand-file-name "schedule.org" sync-dir) + "The location of the org file containing scheduled events.") + +(defvar gcal-file (expand-file-name "gcal.org" sync-dir) + "The location of the org file containing Google Calendar information.") + +(defvar reference-file (expand-file-name "reference.org" sync-dir) + "The location of the org file containing reference information.") + +(defvar article-archive (expand-file-name "article-archive.org" sync-dir) + "The location of the org file that stores saved articles to keep.") + +(defvar inbox-file (expand-file-name "inbox.org" roam-dir) + "The location of the org file that serves as the task inbox.") + +(defvar reading-notes-file (expand-file-name "reading_notes.org" roam-dir) + "The default notes file for org-noter.") + +(defvar macros-file (concat sync-dir "macros.el") + "The location of the macros file for recorded saved macros via M-f3.") + +(defvar contacts-file (expand-file-name "contacts.org" sync-dir) + "The location of the org file containing contact information.") + +(defvar notification-sound (expand-file-name "BitWave.opus" sounds-dir) + "The location of the audio file to use as the default notification.") + +(defvar webclipped-file (expand-file-name "webclipped.org" sync-dir) + "The location of the org file that keeps webclips to read. + +For more information, see org webclipper section of org-capture-config.el") + +;; ------------------------- Verify Or Create Functions ------------------------ + +(defun cj/directory-writable-p (dir) + "Check if DIR is writable." + (and (file-directory-p dir) + (file-writable-p dir))) + +(defun cj/verify-or-create-dir (dir) + "Verify the directory DIR exists; create it if it doesn't." + (condition-case err + (unless (file-directory-p dir) + (make-directory dir t) + (message "Created directory: %s" dir)) + (error (message "Error creating directory %s: %s" dir (error-message-string err))))) + +(defun cj/verify-or-create-file (file) + "Verify the file FILE exists; create it if it doesn't." + (condition-case err + (let ((dir (file-name-directory file))) + (when dir (cj/verify-or-create-dir dir)) + (unless (file-exists-p file) + (write-region "" nil file) + (message "Created file: %s" file))) + (error (message "Error creating file %s: %s" file (error-message-string err))))) + +(defun cj/initialize-user-directories-and-files () + "Initialize all necessary directories and files. + +This ensures that all directories and files required by the Emacs configuration +exist, creating them if necessary. This makes the configuration more robust +and portable across different machines." + (interactive) + (mapc 'cj/verify-or-create-dir (list drill-dir + journals-dir + roam-dir + snippets-dir + video-recordings-dir + audio-recordings-dir + sync-dir)) + (mapc 'cj/verify-or-create-file (list schedule-file + inbox-file + article-archive + reading-notes-file + contacts-file + webclipped-file + reference-file))) + +;; Initialize directories and files when this module is loaded +(cj/initialize-user-directories-and-files) + +(provide 'user-constants) +;;; user-constants.el ends here diff --git a/modules/vc-config.el b/modules/vc-config.el new file mode 100644 index 00000000..4a556085 --- /dev/null +++ b/modules/vc-config.el @@ -0,0 +1,131 @@ +;;; vc-config.el --- Version Control Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;;; Commentary: +;; C-x g is my general entry to Magit's version control via the status page. + +;; Navigating changes in file happens via git gutter +;; - C-v d will allow jumping to changes +;; - C-v n and p will bring to next/previous changes + +;; Reviewing previous versions happens through git timemahine +;; - C-v t allows viewing this file by selecting a previous commit +;; - Once in timemachine, n and p will take you to previous/next commits +;; - To exit timemachine, press q in the read-only timemachine buffer + +;;; Code: + +;; ---------------------------- Magit Configuration ---------------------------- + +(use-package magit + :defer 0.5 + :bind ("C-x g" . magit-status) + :hook + (magit-log-mode . display-line-numbers-mode) + :custom + (magit-define-global-key-bindings 'default) + :config + (setq magit-bury-buffer-function 'magit-restore-window-configuration) + (setq git-commit-major-mode 'org-mode) ;; edit commit messages in org-mode + (setq magit-display-buffer-function + 'magit-display-buffer-fullframe-status-topleft-v1) + + ;; CLONING + (setq magit-clone-default-directory code-dir) ;; cloned repositories go here by default + (setq magit-clone-set-remote-head t) ;; do as git does for remote heads + (setq magit-clone-set-remote.pushDefault 'ask) ;; ask if origin is default + ) ;; end use-package magit + +;; --------------------------------- Git Gutter -------------------------------- +;; mark changed lines since last commit in the margin + +(use-package git-gutter + :defer t + :hook (prog-mode . git-gutter-mode) + :custom + (git-gutter:modified-sign "~") + (git-gutter:added-sign "+") + (git-gutter:deleted-sign "-") + (git-gutter:update-interval 0.05)) + +;; ------------------------------ Git Timemachine ------------------------------ + +(defun cj/git-timemachine () + "Open git snapshot with the selected version." + (interactive) + (unless (featurep 'git-timemachine) + (require 'git-timemachine)) + (git-timemachine--start #'cj/git-timemachine-show-selected-revision)) + +(use-package git-timemachine + :commands (git-timemachine + git-timemachine-show-revision + git-timemachine-show-selected-revision) + :init + (defun cj/git-timemachine-show-selected-revision () + "Displays git revisions of file in chronological order adding metadata." + (interactive) + (let* ((revisions (git-timemachine--revisions)) + (candidates (mapcar + (lambda (rev) + (concat (substring-no-properties (nth 0 rev) 0 7) + " | " + (or (nth 3 rev) "No date") + " | " + (nth 5 rev) + " | " + (nth 6 rev))) + revisions)) + ;; Create completion table with metadata to prevent sorting + (completion-table + (lambda (string pred action) + (if (eq action 'metadata) + ;; Tell vertico not to sort these candidates + '(metadata (display-sort-function . identity) + (cycle-sort-function . identity)) + (complete-with-action action candidates string pred))))) + (let* ((selected (completing-read "Select revision: " completion-table nil t)) + (index (cl-position selected candidates :test #'string=))) + (when index + (git-timemachine-show-revision (nth index revisions)))))) + + :bind (:map cj/vc-map + ("t" . cj/git-timemachine))) + +;; -------------------------------- Magit Forge -------------------------------- +;; GitHub/GitLab/etc integration for Magit + +(use-package forge + :after magit + :init + ;; Set up forge database location + (setq forge-database-file + (expand-file-name "forge-database.sqlite" user-emacs-directory)) + + :config + (setq forge-pull-notifications nil) ;; Don't pull notifications by default + (setq forge-topic-list-limit '(60 . 10))) ;; Show 60 open and 10 closed items + +(defun cj/forge-create-issue () + "Create a new issue in the current repository." + (interactive) + (if (forge-current-repository) + (forge-create-issue) + (user-error "Not in a forge repository"))) + +;; --------------------------------- VC Keymap --------------------------------- + +;; Ordering & sorting prefix and keymap +(define-prefix-command 'cj/vc-map nil + "Keymap for version control operations.") +(define-key cj/custom-keymap "v" 'cj/vc-map) +(define-key cj/vc-map "d" 'cj/goto-git-gutter-diff-hunks) +(define-key cj/vc-map "c" 'cj/forge-create-issue) +(define-key cj/vc-map "f" 'forge-pull) +(define-key cj/vc-map "i" 'forge-list-issues) +(define-key cj/vc-map "n" 'git-gutter:next-hunk) +(define-key cj/vc-map "p" 'git-gutter:previous-hunk) +(define-key cj/vc-map "r" 'forge-list-pullreqs) +(define-key cj/vc-map "t" 'cj/git-timemachine) + +(provide 'vc-config) +;;; vc-config.el ends here. diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el new file mode 100644 index 00000000..e2238949 --- /dev/null +++ b/modules/video-audio-recording.el @@ -0,0 +1,184 @@ +;;; video-audio-recording.el --- Video and Audio Recording -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Use ffmpeg to record desktop video or just audio. +;; with audio from mic and audio from default audio sink +;; Also supports audio-only recording in Opus format. +;; +;; Note: video-recordings-dir and audio-recordings-dir are defined +;; (and directory created) in user-constants.el +;; +;; +;; To adjust volumes: +;; - Use =M-x cj/recording-adjust-volumes= (or your keybinding =r l=) +;; - Or customize permanently: =M-x customize-group RET cj-recording RET= +;; - Or in your config: +;; #+begin_src emacs-lisp +;; (setq cj/recording-mic-boost 1.5) ; 50% louder +;; (setq cj/recording-system-volume 0.7) ; 30% quieter +;; +;;; Code: + +(require 'user-constants) + +(defgroup cj-recording nil + "Settings for video and audio recording." + :group 'multimedia) + +(defcustom cj/recording-mic-boost 2.0 + "Volume multiplier for microphone in recordings. + +1.0 = normal volume, 2.0 = double volume (+6dB), 0.5 = half volume (-6dB)." + :type 'number + :group 'cj-recording) + +(defcustom cj/recording-system-volume 0.5 + "Volume multiplier for system audio in recordings. + +1.0 = normal volume, 2.0 = double volume (+6dB), 0.5 = half volume (-6dB)." + :type 'number + :group 'cj-recording) + +(defvar cj/video-recording-ffmpeg-process nil + "Variable to store the process of the ffmpeg video recording.") + +(defvar cj/audio-recording-ffmpeg-process nil + "Variable to store the process of the ffmpeg audio recording.") + +(defun cj/video-recording-start (arg) + "Starts the ffmpeg video recording. + +If called with a prefix arg C-u, choose the location on where to save the +recording, otherwise use the default location in =video-recordings-dir'." + (interactive "P") + (let* ((location (if arg + (read-directory-name "Enter recording location: ") + video-recordings-dir)) + (directory (file-name-directory location))) + (unless (file-directory-p directory) + (make-directory directory t)) + (cj/ffmpeg-record-video location))) + +(defun cj/audio-recording-start (arg) + "Starts the ffmpeg audio recording. + +If called with a prefix arg C-u, choose the location on where to save the +recording, otherwise use the default location in =video-recordings-dir'." + (interactive "P") + (let* ((location (if arg + (read-directory-name "Enter recording location: ") + video-recordings-dir)) + (directory (file-name-directory location))) + (unless (file-directory-p directory) + (make-directory directory t)) + (cj/ffmpeg-record-audio location))) + +(defun cj/ffmpeg-record-video (directory) + "Start an ffmpeg video recording. Save output to DIRECTORY." + (unless cj/video-recording-ffmpeg-process + (let* ((location (expand-file-name directory)) + (name (format-time-string "%Y-%m-%d-%H-%M-%S")) + (filename (expand-file-name (concat name ".mkv") location)) + (ffmpeg-command + (format (concat "ffmpeg -framerate 30 -f x11grab -i :0.0+ " + "-f pulse -i " + "alsa_input.pci-0000_00_1b.0.analog-stereo " + "-ac 1 " + "-f pulse -i " + "alsa_output.pci-0000_00_1b.0.analog-stereo.monitor " + "-ac 2 " + "-filter_complex \"[1:a]volume=%.1f[mic];[2:a]volume=%.1f[sys];[mic][sys]amerge=inputs=2[out]\" " + "-map 0:v -map \"[out]\" " + "%s") + cj/recording-mic-boost + cj/recording-system-volume + filename))) + ;; start the recording + (setq cj/video-recording-ffmpeg-process + (start-process-shell-command "ffmpeg-video-recording" + "*ffmpeg-video-recording*" + ffmpeg-command)) + (set-process-query-on-exit-flag cj/video-recording-ffmpeg-process nil) + (message "Started video recording process (mic boost: %.1fx, system volume: %.1fx)." + cj/recording-mic-boost cj/recording-system-volume)))) + +(defun cj/ffmpeg-record-audio (directory) + "Start an ffmpeg audio recording. Save output to DIRECTORY." + (unless cj/audio-recording-ffmpeg-process + (let* ((location (expand-file-name directory)) + (name (format-time-string "%Y-%m-%d-%H-%M-%S")) + (filename (expand-file-name (concat name ".opus") location)) + (ffmpeg-command + (format (concat "ffmpeg " + "-f pulse -i " + "alsa_input.pci-0000_00_1b.0.analog-stereo " + "-ac 1 " + "-f pulse -i " + "alsa_output.pci-0000_00_1b.0.analog-stereo.monitor " + "-ac 2 " + "-filter_complex \"[0:a]volume=%.1f[mic];[1:a]volume=%.1f[sys];[mic][sys]amerge=inputs=2\" " + "-c:a libopus " + "-b:a 96k " + "%s") + cj/recording-mic-boost + cj/recording-system-volume + filename))) + ;; start the recording + (setq cj/audio-recording-ffmpeg-process + (start-process-shell-command "ffmpeg-audio-recording" + "*ffmpeg-audio-recording*" + ffmpeg-command)) + (set-process-query-on-exit-flag cj/audio-recording-ffmpeg-process nil) + (message "Started audio recording process (mic boost: %.1fx, system volume: %.1fx)." + cj/recording-mic-boost cj/recording-system-volume)))) + +(defun cj/video-recording-stop () + "Stop the ffmpeg video recording process." + (interactive) + (if cj/video-recording-ffmpeg-process + (progn + ;; Use interrupt-process to send SIGINT (graceful termination) + (interrupt-process cj/video-recording-ffmpeg-process) + ;; Give ffmpeg a moment to finalize the file + (sit-for 1) + (setq cj/video-recording-ffmpeg-process nil) + (message "Stopped video recording.")) + (message "No video recording in progress."))) + +(defun cj/audio-recording-stop () + "Stop the ffmpeg audio recording process." + (interactive) + (if cj/audio-recording-ffmpeg-process + (progn + ;; Use interrupt-process to send SIGINT (graceful termination) + (interrupt-process cj/audio-recording-ffmpeg-process) + ;; Give ffmpeg a moment to finalize the file + (sit-for 1) + (setq cj/audio-recording-ffmpeg-process nil) + (message "Stopped audio recording.")) + (message "No audio recording in progress."))) + +(defun cj/recording-adjust-volumes () + "Interactively adjust recording volume levels." + (interactive) + (let ((mic (read-number "Microphone boost (1.0 = normal, 2.0 = double): " + cj/recording-mic-boost)) + (sys (read-number "System audio level (1.0 = normal, 0.5 = half): " + cj/recording-system-volume))) + (customize-set-variable 'cj/recording-mic-boost mic) + (customize-set-variable 'cj/recording-system-volume sys) + (message "Recording levels updated - Mic: %.1fx, System: %.1fx" mic sys))) + +;; Recording operations prefix and keymap +(define-prefix-command 'cj/record-map nil + "Keymap for video/audio recording operations.") +(define-key cj/custom-keymap "r" 'cj/record-map) +(define-key cj/record-map "V" 'cj/video-recording-stop) +(define-key cj/record-map "v" 'cj/video-recording-start) +(define-key cj/record-map "A" 'cj/audio-recording-stop) +(define-key cj/record-map "a" 'cj/audio-recording-start) +(define-key cj/record-map "l" 'cj/recording-adjust-volumes) ; 'l' for levels + +(provide 'video-audio-recording) +;;; video-audio-recording.el ends here. diff --git a/modules/weather-config.el b/modules/weather-config.el new file mode 100644 index 00000000..526a0b41 --- /dev/null +++ b/modules/weather-config.el @@ -0,0 +1,40 @@ +;;; weather-config.el --- -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;;; Commentary: +;; +;; Call M-W to open wttrin with your preferred location list immediately. +;; Adjust the city list by editing `wttrin-default-locations` or answering wttrin prompts when asked. +;; Forecasts arrive in an Emacs buffer, so you can stay keyboard-only while checking weather. +;; +;;; Code: + +;; ----------------------------------- Wttrin ---------------------------------- + +(use-package wttrin + :defer t + :load-path ("~/code/wttrin") + :ensure nil ;; local package + :preface + ;; dependency for wttrin + (use-package xterm-color + :demand t) + :bind + ("M-W" . wttrin) + :custom + (wttrin-unit-system "u") + :config + (setq wttrin-default-locations '( + "New Orleans, LA" + "Athens, GR" + "Berkeley, CA" + "Bury St Edmunds, UK" + "Kyiv, UA" + "Littlestown, PA" + "Soufrière, St Lucia" + "London, GB" + "Naples, IT" + "New York, NY" + ))) + +(provide 'weather-config) +;;; weather-config.el ends here. diff --git a/modules/wip.el b/modules/wip.el new file mode 100644 index 00000000..0fae57e3 --- /dev/null +++ b/modules/wip.el @@ -0,0 +1,121 @@ +;;; wip.el --- test code -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; This is where to put config code you're working on before it's tested and stable. +;; Include this at the very end of your init.el. This way, if something does break, +;; and it will, most of your Emacs config is loaded. + +;; Once you've tested (and time-tested) the code here, graduate it into the proper +;; section of your config above. + +;;; Code: + +(require 'user-constants) + + +;; --------------------------- Org Upcoming Modeline --------------------------- + +;; (use-package org-upcoming-modeline +;; :after org +;; :load-path "~/code/org-upcoming-modeline/org-upcoming-modeline.el" +;; :config +;; (setq org-upcoming-modeline-keep-late 300) +;; (setq org-upcoming-modeline-ignored-keywords '("DONE" "CANCELLED" "FAILED")) +;; (setq org-upcoming-modeline-trim 30) +;; (setq org-upcoming-modeline-days-ahead 5) +;; (setq org-upcoming-modeline-format (lambda (ms mh) (format "📅 %s %s" ms mh))) +;; (org-upcoming-modeline-mode)) + +;; ----------------------------------- Efrit ----------------------------------- +;; not working as of Wednesday, September 03, 2025 at 12:44:09 AM CDT + +;; (add-to-list 'load-path "~/code/efrit/lisp") +;; (require 'efrit) + +;; ------------------------------ Buffer Same Mode ----------------------------- + +(defun cj/buffer-same-mode (&rest modes) + "Pop to a buffer with a mode among MODES, or the current one if not given." + (interactive) + (let* ((modes (or modes (list major-mode))) + (pred (lambda (b) + (let ((b (get-buffer (if (consp b) (car b) b)))) + (member (buffer-local-value 'major-mode b) modes))))) + (pop-to-buffer (read-buffer "Buffer: " nil t pred)))) +(global-set-key (kbd "C-x B") 'cj/buffer-same-mode) + +;; ;; --------------------------------- Easy Hugo --------------------------------- + +;; (use-package easy-hugo +;; :defer .5 +;; :init +;; (setq easy-hugo-basedir "~/code/cjennings-net/") +;; (setq easy-hugo-url "https://cjennings.net") +;; (setq easy-hugo-sshdomain "cjennings.net") +;; (setq easy-hugo-root "/var/www/cjennings/") +;; (setq easy-hugo-previewtime "300") +;; (setq easy-hugo-postdir "content") +;; (setq easy-hugo-server-flags "-D --noHTTPCache --disableFastRender") +;; (setq easy-hugo-default-ext ".md") +;; :bind ("C-c H" . easy-hugo) +;; :config +;; (easy-hugo-enable-menu)) + +;; ------------------------------------ Pomm ----------------------------------- + +(use-package pomm + :defer .5 + :bind ("M-p" . pomm) + :commands (pomm pomm-third-time)) + +;; ------------------------------ Mouse Trap Mode ------------------------------ + +(defvar mouse-trap-mode-map + (let* ((prefixes '("" "C-" "M-" "S-" "C-M-" "C-S-" "M-S-" "C-M-S-")) ; modifiers + (buttons (number-sequence 1 5)) ; mouse-1..5 + (types '("mouse" "down-mouse" "drag-mouse" + "double-mouse" "triple-mouse")) + (wheel '("wheel-up" "wheel-down" "wheel-left" "wheel-right")) + (map (make-sparse-keymap))) + ;; clicks, drags, double, triple + (dolist (type types) + (dolist (pref prefixes) + (dolist (n buttons) + (define-key map (kbd (format "<%s%s-%d>" pref type n)) #'ignore)))) + ;; wheel + (dolist (evt wheel) + (dolist (pref prefixes) + (define-key map (kbd (format "<%s%s>" pref evt)) #'ignore))) + map) + "Keymap for `mouse-trap-mode'. Unbinds almost every mouse event. + +Disabling mouse prevents accidental mouse moves modifying text.") + +(define-minor-mode mouse-trap-mode + "Globally disable most mouse and trackpad events. + +When active, <mouse-*>, <down-mouse-*>, <drag-mouse-*>, +<double-mouse-*>, <triple-mouse-*>, and wheel events are bound to `ignore', +with or without C-, M-, S- modifiers." + :global t + :lighter " 🐭" + :keymap mouse-trap-mode-map) +(global-set-key (kbd "C-c M") #'mouse-trap-mode) +(mouse-trap-mode 1) + +;; --------------------- Debug Code For Package Signatures --------------------- +;; from https://emacs.stackexchange.com/questions/233/how-to-proceed-on-package-el-signature-check-failure + + +;; Set package-check-signature to nil, e.g., M-: (setq package-check-signature nil) RET. +;; Download the package gnu-elpa-keyring-update and run the function with the same name, e.g., M-x package-install RET gnu-elpa-keyring-update RET. +;; Reset package-check-signature to the default value allow-unsigned, e.g., M-: (setq package-check-signature 'allow-unsigned) RET. + +;; (setq package-check-signature nil) +;; (setq package-check-signature 'allow-unsigned) + + +(provide 'wip) +;;; wip.el ends here. diff --git a/modules/wrap-up.el b/modules/wrap-up.el new file mode 100644 index 00000000..f1841189 --- /dev/null +++ b/modules/wrap-up.el @@ -0,0 +1,30 @@ +;;; wrapup --- Functions Run Before Init Completion -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;;; Code: + +;; -------------------------------- Bury Buffers ------------------------------- +;; wait a few seconds then bury compile-related buffers. + +(defun cj/bury-buffers () + "Bury comint and compilation buffers." + (dolist (buf (buffer-list)) + (with-current-buffer buf + (when (or (derived-mode-p 'comint-mode) + (derived-mode-p 'compilation-mode) + (derived-mode-p 'debugger-mode) + (derived-mode-p 'elisp-compile-mode) + (derived-mode-p 'messages-buffer-mode) + ) ;; byte-compilations + (bury-buffer))))) + +(defun cj/bury-buffers-after-delay () + "Run cj/bury-buffers after a delay." + (run-with-timer 1 nil 'cj/bury-buffers)) + +(add-hook 'emacs-startup-hook 'cj/bury-buffers-after-delay) + +(provide 'wrap-up) +;;; wrap-up.el ends here |
