diff options
Diffstat (limited to 'modules')
42 files changed, 1051 insertions, 442 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el index 004750b6..3b89faca 100644 --- a/modules/ai-config.el +++ b/modules/ai-config.el @@ -415,5 +415,22 @@ Works for any buffer, whether it's visiting a file or not." "x" #'cj/gptel-clear-buffer) ;; clears the assistant buffer (keymap-set cj/custom-keymap "a" cj/ai-keymap) +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; a" "AI assistant menu" + "C-; a B" "switch backend" + "C-; a M" "gptel menu" + "C-; a d" "delete conversation" + "C-; a ." "add buffer" + "C-; a f" "add file" + "C-; a l" "load conversation" + "C-; a m" "change model" + "C-; a p" "change prompt" + "C-; a &" "rewrite region" + "C-; a r" "clear context" + "C-; a s" "save conversation" + "C-; a t" "toggle window" + "C-; a x" "clear buffer")) + (provide 'ai-config) ;;; ai-config.el ends here. diff --git a/modules/browser-config.el b/modules/browser-config.el index fddc02e6..52c3b8a6 100644 --- a/modules/browser-config.el +++ b/modules/browser-config.el @@ -80,19 +80,44 @@ Returns the browser plist if found, nil otherwise." cj/saved-browser-choice)) (error nil)))) -(defun cj/apply-browser-choice (browser-plist) - "Apply the browser settings from BROWSER-PLIST." - (when browser-plist +(defun cj/--do-apply-browser-choice (browser-plist) + "Apply the browser settings from BROWSER-PLIST. +Returns: \\='success if applied successfully, + \\='invalid-plist if browser-plist is nil or missing required keys." + (if (null browser-plist) + 'invalid-plist (let ((browse-fn (plist-get browser-plist :function)) (executable (plist-get browser-plist :executable)) (path (plist-get browser-plist :path)) (program-var (plist-get browser-plist :program-var))) - ;; Set the main browse-url function - (setq browse-url-browser-function browse-fn) - ;; Set the specific browser program variable if it exists - (when program-var - (set program-var (or path executable))) - (message "Default browser set to: %s" (plist-get browser-plist :name))))) + (if (null browse-fn) + 'invalid-plist + ;; Set the main browse-url function + (setq browse-url-browser-function browse-fn) + ;; Set the specific browser program variable if it exists + (when program-var + (set program-var (or path executable))) + 'success)))) + +(defun cj/apply-browser-choice (browser-plist) + "Apply the browser settings from BROWSER-PLIST." + (pcase (cj/--do-apply-browser-choice browser-plist) + ('success (message "Default browser set to: %s" (plist-get browser-plist :name))) + ('invalid-plist (message "Invalid browser configuration")))) + +(defun cj/--do-choose-browser (browser-plist) + "Save and apply BROWSER-PLIST as the default browser. +Returns: \\='success if browser was saved and applied, + \\='save-failed if save operation failed, + \\='invalid-plist if browser-plist is invalid." + (condition-case _err + (progn + (cj/save-browser-choice browser-plist) + (let ((result (cj/--do-apply-browser-choice browser-plist))) + (if (eq result 'success) + 'success + 'invalid-plist))) + (error 'save-failed))) (defun cj/choose-browser () "Interactively choose a browser from available options. @@ -107,21 +132,39 @@ Persists the choice for future sessions." (string= (plist-get b :name) choice)) browsers))) (when selected - (cj/save-browser-choice selected) - (cj/apply-browser-choice selected)))))) + (pcase (cj/--do-choose-browser selected) + ('success (message "Default browser set to: %s" (plist-get selected :name))) + ('save-failed (message "Failed to save browser choice")) + ('invalid-plist (message "Invalid browser configuration")))))))) ;; Initialize: Load saved choice or use first available browser -(defun cj/initialize-browser () - "Initialize browser configuration on startup." +(defun cj/--do-initialize-browser () + "Initialize browser configuration. +Returns: (cons \\='loaded browser-plist) if saved choice was loaded, + (cons \\='first-available browser-plist) if using first discovered browser, + (cons \\='no-browsers nil) if no browsers found." (let ((saved-choice (cj/load-browser-choice))) (if saved-choice - (cj/apply-browser-choice saved-choice) - ;; No saved choice - try to set first available browser silently + (cons 'loaded saved-choice) + ;; No saved choice - try to set first available browser (let ((browsers (cj/discover-browsers))) - (when browsers - (cj/apply-browser-choice (car browsers)) - (message "No browser configured. Using %s. Run M-x cj/choose-browser to change." - (plist-get (car browsers) :name))))))) + (if browsers + (cons 'first-available (car browsers)) + (cons 'no-browsers nil)))))) + +(defun cj/initialize-browser () + "Initialize browser configuration on startup." + (let ((result (cj/--do-initialize-browser))) + (pcase (car result) + ('loaded + (cj/--do-apply-browser-choice (cdr result))) + ('first-available + (let ((browser (cdr result))) + (cj/--do-apply-browser-choice browser) + (message "No browser configured. Using %s. Run M-x cj/choose-browser to change." + (plist-get browser :name)))) + ('no-browsers + (message "No supported browsers found"))))) ;; Run initialization (cj/initialize-browser) diff --git a/modules/config-utilities.el b/modules/config-utilities.el index ea92f19a..32018371 100644 --- a/modules/config-utilities.el +++ b/modules/config-utilities.el @@ -17,12 +17,27 @@ (keymap-global-set "C-c d" cj/debug-config-keymap) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-c d" "config debugging utils")) + (which-key-add-key-based-replacements + "C-c d" "config debugging utils" + "C-c d p" "profiler menu" + "C-c d p s" "start profiler" + "C-c d p h" "stop profiler" + "C-c d p r" "profiler report" + "C-c d t" "toggle debug-on-error" + "C-c d b" "benchmark method" + "C-c d c" "compilation menu" + "C-c d c h" "compile home" + "C-c d c d" "delete compiled" + "C-c d c ." "compile buffer" + "C-c d i" "info menu" + "C-c d i b" "info build" + "C-c d i p" "info packages" + "C-c d i f" "info features" + "C-c d r" "reload init" + "C-c d a" "reset auth cache")) ;;; --------------------------------- Profiling --------------------------------- -(with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-c d p" "profiler menu.")) (keymap-set cj/debug-config-keymap "p s" #'profiler-start) (keymap-set cj/debug-config-keymap "p h" #'profiler-stop) (keymap-set cj/debug-config-keymap "p r" #'profiler-report) @@ -92,8 +107,6 @@ Recompile natively when supported, otherwise fall back to byte compilation." (message "Cancelled recompilation of %s" user-emacs-directory)))) (keymap-set cj/debug-config-keymap "c h" 'cj/recompile-emacs-home) -(with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-c d c" "config compilation options.")) (defun cj/delete-emacs-home-compiled-files () "Delete all compiled files recursively in \='user-emacs-directory\='." @@ -214,8 +227,6 @@ Recompile natively when supported, otherwise fall back to byte compilation." (pop-to-buffer buf))) (keymap-set cj/debug-config-keymap "i b" 'cj/info-emacs-build) -(with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-c d i" "info on build/features/packages.")) (defvar cj--loaded-file-paths nil "All file paths that are loaded.") diff --git a/modules/custom-file-buffer.el b/modules/custom-buffer-file.el index e0224a32..9438e8ed 100644 --- a/modules/custom-file-buffer.el +++ b/modules/custom-buffer-file.el @@ -1,4 +1,4 @@ -;;; custom-file-buffer.el --- Custom Buffer and File Operations -*- coding: utf-8; lexical-binding: t; -*- +;;; custom-buffer-file.el --- Custom Buffer and File Operations -*- coding: utf-8; lexical-binding: t; -*- ;; ;;; Commentary: ;; This module provides custom buffer and file operations including PostScript @@ -240,8 +240,21 @@ Do not save the deleted text in the kill ring." (keymap-set cj/custom-keymap "b" cj/buffer-and-file-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; b" "buffer and file menu")) + (which-key-add-key-based-replacements + "C-; b" "buffer and file menu" + "C-; b m" "move file" + "C-; b r" "rename file" + "C-; b p" "print to PS" + "C-; b d" "delete file" + "C-; b c" "copy buffer" + "C-; b n" "copy buffer name" + "C-; b t" "clear to top" + "C-; b b" "clear to bottom" + "C-; b x" "erase buffer" + "C-; b s" "save as" + "C-; b l" "copy file link" + "C-; b P" "copy file path")) -(provide 'custom-file-buffer) -;;; custom-file-buffer.el ends here. +(provide 'custom-buffer-file) +;;; custom-buffer-file.el ends here. diff --git a/modules/custom-case.el b/modules/custom-case.el index 4fd9ac05..59250ddb 100644 --- a/modules/custom-case.el +++ b/modules/custom-case.el @@ -118,7 +118,11 @@ short prepositions, and all articles are considered minor words." (keymap-set cj/custom-keymap "c" cj/case-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; c" "case change menu")) + (which-key-add-key-based-replacements + "C-; c" "case change menu" + "C-; c t" "title case" + "C-; c u" "upcase" + "C-; c l" "downcase")) (provide 'custom-case) ;;; custom-case.el ends here. diff --git a/modules/custom-comments.el b/modules/custom-comments.el index b4e51b2c..0d83d31b 100644 --- a/modules/custom-comments.el +++ b/modules/custom-comments.el @@ -619,7 +619,18 @@ Leverages cj/comment-inline-border." (keymap-set cj/custom-keymap "C" cj/comment-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; C" "code comment menu")) + (which-key-add-key-based-replacements + "C-; C" "code comment menu" + "C-; C r" "reformat comment" + "C-; C d" "delete comments" + "C-; C c" "inline border" + "C-; C -" "hyphen divider" + "C-; C s" "simple divider" + "C-; C p" "padded divider" + "C-; C b" "box" + "C-; C h" "heavy box" + "C-; C u" "unicode box" + "C-; C n" "block banner")) (provide 'custom-comments) ;;; custom-comments.el ends here. diff --git a/modules/custom-datetime.el b/modules/custom-datetime.el index c195ebc2..5b06d81a 100644 --- a/modules/custom-datetime.el +++ b/modules/custom-datetime.el @@ -117,7 +117,14 @@ Use `readable-date-format' for formatting." (keymap-set cj/custom-keymap "d" cj/datetime-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; d" "date/time insertion menu")) + (which-key-add-key-based-replacements + "C-; d" "date/time insertion menu" + "C-; d r" "readable date-time" + "C-; d s" "sortable date-time" + "C-; d t" "sortable time" + "C-; d T" "readable time" + "C-; d d" "sortable date" + "C-; d D" "readable date")) (provide 'custom-datetime) ;;; custom-datetime.el ends here. diff --git a/modules/custom-line-paragraph.el b/modules/custom-line-paragraph.el index 7f0baef9..32f9aaa1 100644 --- a/modules/custom-line-paragraph.el +++ b/modules/custom-line-paragraph.el @@ -139,8 +139,15 @@ If the line is empty or contains only whitespace, abort with a message." (keymap-set cj/custom-keymap "l" cj/line-and-paragraph-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; l" "line and paragraph menu") - (which-key-add-key-based-replacements "C-; l c" "duplicate and comment")) + (which-key-add-key-based-replacements + "C-; l" "line and paragraph menu" + "C-; l j" "join lines" + "C-; l J" "join paragraph" + "C-; l d" "duplicate" + "C-; l c" "duplicate and comment" + "C-; l R" "remove duplicates" + "C-; l r" "remove matching" + "C-; l u" "underscore line")) (provide 'custom-line-paragraph) ;;; custom-line-paragraph.el ends here. diff --git a/modules/custom-misc.el b/modules/custom-misc.el index 2af9c244..be1f26bb 100644 --- a/modules/custom-misc.el +++ b/modules/custom-misc.el @@ -152,5 +152,15 @@ to nil." (keymap-set cj/custom-keymap "SPC" #'cj/switch-to-previous-buffer) (keymap-set cj/custom-keymap "|" #'display-fill-column-indicator-mode) +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; )" "jump to paren" + "C-; f" "format buffer" + "C-; W" "count words" + "C-; /" "fraction glyphs" + "C-; A" "align regexp" + "C-; SPC" "prev buffer" + "C-; |" "fill column")) + (provide 'custom-misc) ;;; custom-misc.el ends here diff --git a/modules/custom-ordering.el b/modules/custom-ordering.el index abc9995a..7d906e75 100644 --- a/modules/custom-ordering.el +++ b/modules/custom-ordering.el @@ -253,7 +253,16 @@ Returns the transformed string without modifying the buffer." (keymap-set cj/custom-keymap "o" cj/ordering-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; o" "ordering/sorting menu")) + (which-key-add-key-based-replacements + "C-; o" "ordering/sorting menu" + "C-; o l" "listify" + "C-; o j" "JSON array" + "C-; o p" "Python list" + "C-; o q" "toggle quotes" + "C-; o r" "reverse lines" + "C-; o n" "number lines" + "C-; o A" "alphabetize" + "C-; o L" "comma to lines")) (provide 'custom-ordering) ;;; custom-ordering.el ends here. diff --git a/modules/custom-text-enclose.el b/modules/custom-text-enclose.el index ccacdd2d..e93e6dea 100644 --- a/modules/custom-text-enclose.el +++ b/modules/custom-text-enclose.el @@ -264,11 +264,23 @@ Works on region if active, otherwise entire buffer." "a" #'cj/append-to-lines-in-region-or-buffer "p" #'cj/prepend-to-lines-in-region-or-buffer "i" #'cj/indent-lines-in-region-or-buffer - "d" #'cj/dedent-lines-in-region-or-buffer) + "d" #'cj/dedent-lines-in-region-or-buffer + "I" #'change-inner + "O" #'change-outer) (keymap-set cj/custom-keymap "s" cj/enclose-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; s" "text enclose menu")) + (which-key-add-key-based-replacements + "C-; s" "text enclose menu" + "C-; s s" "surround text" + "C-; s w" "wrap text" + "C-; s u" "unwrap text" + "C-; s a" "append to lines" + "C-; s p" "prepend to lines" + "C-; s i" "indent lines" + "C-; s d" "dedent lines" + "C-; s I" "change inner" + "C-; s O" "change outer")) (provide 'custom-text-enclose) ;;; custom-text-enclose.el ends here. diff --git a/modules/custom-whitespace.el b/modules/custom-whitespace.el index df93459a..d5f8d80c 100644 --- a/modules/custom-whitespace.el +++ b/modules/custom-whitespace.el @@ -217,7 +217,15 @@ Operate on the active region designated by START and END." (keymap-set cj/custom-keymap "w" cj/whitespace-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; w" "whitespace menu")) + (which-key-add-key-based-replacements + "C-; w" "whitespace menu" + "C-; w c" "collapse whitespace" + "C-; w l" "delete blank lines" + "C-; w 1" "single blank line" + "C-; w d" "delete all whitespace" + "C-; w -" "hyphenate whitespace" + "C-; w t" "untabify" + "C-; w T" "tabify")) (provide 'custom-whitespace) ;;; custom-whitespace.el ends here. diff --git a/modules/diff-config.el b/modules/diff-config.el index 382b2250..45c2a778 100644 --- a/modules/diff-config.el +++ b/modules/diff-config.el @@ -48,6 +48,14 @@ (add-hook 'ediff-mode-hook #'cj/ediff-hook) (add-hook 'ediff-after-quit-hook-internal #'winner-undo)) +;; which-key labels +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c D" "ediff menu" + "C-c D f" "ediff files" + "C-c D b" "ediff buffers" + "C-c D r" "ediff revision" + "C-c D D" "ediff directories")) (provide 'diff-config) ;;; diff-config.el ends here diff --git a/modules/erc-config.el b/modules/erc-config.el index 1c189fa3..e7efb33f 100644 --- a/modules/erc-config.el +++ b/modules/erc-config.el @@ -183,7 +183,14 @@ Auto-adds # prefix if missing. Offers completion from configured channels." (keymap-set cj/custom-keymap "E" cj/erc-keymap) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; E" "ERC chat menu")) + (which-key-add-key-based-replacements + "C-; E" "ERC chat menu" + "C-; E C" "connect server" + "C-; E c" "join channel" + "C-; E b" "switch buffer" + "C-; E l" "list servers" + "C-; E q" "quit channel" + "C-; E Q" "quit server")) ;; Main ERC configuration (use-package erc diff --git a/modules/external-open.el b/modules/external-open.el index 41d842fb..8c4db810 100644 --- a/modules/external-open.el +++ b/modules/external-open.el @@ -111,6 +111,11 @@ (keymap-global-set "C-c x o" #'cj/open-this-file-with) +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c x" "external open menu" + "C-c x o" "open file with")) + ;; -------------------- Open Files With Default File Handler ------------------- (defun cj/find-file-auto (orig-fun &rest args) diff --git a/modules/flycheck-config.el b/modules/flycheck-config.el index d7f1ad39..ea19f08f 100644 --- a/modules/flycheck-config.el +++ b/modules/flycheck-config.el @@ -94,5 +94,8 @@ Runs flycheck-prose-on-demand if in an org-buffer." ;; trigger immediate check (flycheck-buffer))) +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements "C-; ?" "list errors")) + (provide 'flycheck-config) ;;; flycheck-config.el ends here diff --git a/modules/flyspell-and-abbrev.el b/modules/flyspell-and-abbrev.el index 379fc7b2..d12a1794 100644 --- a/modules/flyspell-and-abbrev.el +++ b/modules/flyspell-and-abbrev.el @@ -239,5 +239,11 @@ Press C-' repeatedly to step through misspellings one at a time." ;;;###autoload (keymap-set global-map "C-c f" #'cj/flyspell-toggle) ;;;###autoload (keymap-set global-map "C-'" #'cj/flyspell-then-abbrev) +;; which-key labels +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c f" "flyspell toggle" + "C-'" "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 index eea09da6..ddd4497f 100644 --- a/modules/font-config.el +++ b/modules/font-config.el @@ -284,5 +284,12 @@ If FRAME is nil, uses the selected frame." "<~" "<~~" "</" "</>" "~@" "~-" "~>" "~~" "~~>" "%%")) (global-ligature-mode t)) +;; which-key labels +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c E" "emojify menu" + "C-c E i" "insert emoji" + "C-c E l" "list emojis")) + (provide 'font-config) ;;; font-config.el ends here diff --git a/modules/jumper.el b/modules/jumper.el index 7a3991d0..67d930aa 100644 --- a/modules/jumper.el +++ b/modules/jumper.el @@ -10,24 +10,76 @@ ;; Jumper provides a simple way to store and jump between locations ;; in your codebase without needing to remember register assignments. +;; +;; PURPOSE: +;; +;; When working on large codebases, you often need to jump between +;; multiple related locations: a function definition, its tests, its +;; callers, configuration files, etc. Emacs registers are perfect for +;; this, but require you to remember which register you assigned to +;; which location. Jumper automates register management, letting you +;; focus on your work instead of bookkeeping. +;; +;; WORKFLOW: +;; +;; 1. Navigate to an important location in your code +;; 2. Press M-SPC SPC to store it (automatically assigned to register 0) +;; 3. Continue working, storing more locations as needed (registers 1-9) +;; 4. Press M-SPC j to jump back to any stored location +;; 5. Select from the list using completion (shows file, line, context) +;; 6. Press M-SPC d to remove locations you no longer need +;; +;; RECOMMENDED USAGE: +;; +;; Store locations temporarily while working on a feature: +;; - Store the main function you're implementing +;; - Store the test file where you're writing tests +;; - Store the caller that needs updating +;; - Store the documentation that needs changes +;; - Jump between them freely as you work +;; - Clear them when done with the feature +;; +;; SPECIAL BEHAVIORS: +;; +;; - Duplicate prevention: Storing the same location twice shows a message +;; instead of wasting a register slot. +;; +;; - Single location toggle: When only one location is stored, M-SPC j +;; toggles between that location and your current position. Perfect for +;; rapid back-and-forth between two related files. +;; +;; - Last location tracking: The last position before each jump is saved +;; in register 'z', allowing quick "undo" of navigation. +;; +;; - Smart selection: With multiple locations, completing-read shows +;; helpful context: "[0] filename.el:42 - function definition..." +;; +;; KEYBINDINGS: +;; +;; M-SPC SPC Store current location in next available register +;; M-SPC j Jump to a stored location (with completion) +;; M-SPC d Delete a stored location from the list +;; +;; CONFIGURATION: +;; +;; You can customize the prefix key and maximum locations: +;; +;; (setq jumper-prefix-key "C-c j") ; Change prefix key +;; (setq jumper-max-locations 20) ; Store up to 20 locations +;; +;; Note: Changing jumper-max-locations requires restarting Emacs or +;; manually reinitializing jumper--registers. ;;; Code: -(defgroup jumper nil - "Quick navigation between stored locations." - :group 'convenience) +(require 'cl-lib) -(defcustom jumper-prefix-key "M-SPC" +(defvar jumper-prefix-key "M-SPC" "Prefix key for jumper commands. +Note that using M-SPC will override the default binding to just-one-space.") -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) +(defvar jumper-max-locations 10 + "Maximum number of locations to store.") ;; Internal variables (defvar jumper--registers (make-vector jumper-max-locations nil) @@ -50,12 +102,10 @@ Note that using M-SPC will override the default binding to just-one-space." "Check if current location is already stored." (let ((key (jumper--location-key)) (found nil)) - (dotimes (i - jumper--next-index found) + (dotimes (i jumper--next-index found) (let* ((reg (aref jumper--registers i)) - (pos (get-register reg)) - (marker (and pos (registerv-data pos)))) - (when marker + (marker (get-register reg))) + (when (and marker (markerp marker)) (save-current-buffer (set-buffer (marker-buffer marker)) (save-excursion @@ -70,9 +120,8 @@ Note that using M-SPC will override the default binding to just-one-space." (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 + (marker (get-register reg))) + (when (and marker (markerp marker)) (save-current-buffer (set-buffer (marker-buffer marker)) (save-excursion @@ -86,49 +135,83 @@ Note that using M-SPC will override the default binding to just-one-space." (min (+ (line-beginning-position) 40) (line-end-position))))))))) +(defun jumper--do-store-location () + "Store current location in the next free register. +Returns: \\='already-exists if location is already stored, + \\='no-space if all registers are full, + register character if successfully stored." + (cond + ((jumper--location-exists-p) 'already-exists) + ((not (jumper--register-available-p)) 'no-space) + (t + (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)) + reg)))) + (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!")))) + (pcase (jumper--do-store-location) + ('already-exists (message "Location already stored")) + ('no-space (message "Sorry - all jump locations are filled!")) + (reg (message "Location stored in register %c" reg)))) + +(defun jumper--do-jump-to-location (target-idx) + "Jump to location at TARGET-IDX. +TARGET-IDX: -1 for last location, 0-9 for stored locations, nil for toggle. +Returns: \\='no-locations if no locations stored, + \\='already-there if at the only location (toggle case), + \\='jumped if successfully jumped." + (cond + ((= jumper--next-index 0) 'no-locations) + ;; Toggle behavior when target-idx is nil and only 1 location + ((and (null target-idx) (= jumper--next-index 1)) + (if (jumper--location-exists-p) + 'already-there + (let ((reg (aref jumper--registers 0))) + (point-to-register jumper--last-location-register) + (jump-to-register reg) + 'jumped))) + ;; Jump to specific target + (t + (if (= target-idx -1) + ;; Jumping to last location - don't overwrite it + (jump-to-register jumper--last-location-register) + ;; Jumping to stored location - save current for "last" + (progn + (point-to-register jumper--last-location-register) + (jump-to-register (aref jumper--registers target-idx)))) + 'jumped))) (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"))))) + (cond + ;; No locations + ((= jumper--next-index 0) + (message "No locations stored")) + ;; Single location - toggle + ((= jumper--next-index 1) + (pcase (jumper--do-jump-to-location nil) + ('already-there (message "You're already at the stored location")) + ('jumped (message "Jumped to location")))) + ;; Multiple locations - prompt user + (t + (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)))) + (jumper--do-jump-to-location idx) + (message "Jumped to location"))))) (defun jumper--reorder-registers (removed-idx) "Reorder registers after removing the one at REMOVED-IDX." @@ -139,30 +222,39 @@ Note that using M-SPC will override the default binding to just-one-space." (aset jumper--registers i next-reg)))) (setq jumper--next-index (1- jumper--next-index))) +(defun jumper--do-remove-location (index) + "Remove location at INDEX. +Returns: \\='no-locations if no locations stored, + \\='cancelled if index is -1, + t if successfully removed." + (cond + ((= jumper--next-index 0) 'no-locations) + ((= index -1) 'cancelled) + (t + (jumper--reorder-registers index) + t))) + (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.") + (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)))) + (pcase (jumper--do-remove-location idx) + ('cancelled (message "Operation cancelled")) + ('t (message "Location removed")))))) + +(defvar-keymap jumper-map + :doc "Keymap for jumper commands" + "SPC" #'jumper-store-location + "j" #'jumper-jump-to-location + "d" #'jumper-remove-location) (defun jumper-setup-keys () "Setup default keybindings for jumper." @@ -172,5 +264,13 @@ Note that using M-SPC will override the default binding to just-one-space." ;; Call jumper-setup-keys when the package is loaded (jumper-setup-keys) +;; which-key integration +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "M-SPC" "jumper menu" + "M-SPC SPC" "store location" + "M-SPC j" "jump to location" + "M-SPC d" "remove location")) + (provide 'jumper) ;;; jumper.el ends here. diff --git a/modules/keybindings.el b/modules/keybindings.el index 1f8867ef..1eff621c 100644 --- a/modules/keybindings.el +++ b/modules/keybindings.el @@ -68,12 +68,22 @@ Errors if VAR is unbound, not a non-empty string, or the file does not exist." ;; Bind it under the prefix map. (keymap-set cj/jump-map key fn)))) -;; Bind the prefix globally (user-reserved prefix). -(keymap-global-set "C-c j" cj/jump-map) +;; Bind the prefix to custom keymap +(keymap-set cj/custom-keymap "j" cj/jump-map) -;; nicer prefix label in which-key +;; which-key labels (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-c j" "Jump to common files.")) + (which-key-add-key-based-replacements + "C-; j" "jump to files menu" + "C-; j r" "jump to reference" + "C-; j s" "jump to schedule" + "C-; j i" "jump to inbox" + "C-; j c" "jump to contacts" + "C-; j m" "jump to macros" + "C-; j n" "jump to reading notes" + "C-; j w" "jump to webclipped" + "C-; j g" "jump to gcal" + "C-; j I" "jump to emacs init")) ;; ---------------------------- Keybinding Discovery --------------------------- diff --git a/modules/lorem-generator.el b/modules/lorem-optimum.el index 6f0520c6..6ccca55f 100644 --- a/modules/lorem-generator.el +++ b/modules/lorem-optimum.el @@ -1,4 +1,4 @@ -;;; lorem-generator.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*- +;;; lorem-optimum.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*- ;; ;; Author: Craig Jennings ;; Version: 0.5 @@ -24,6 +24,19 @@ (require 'cl-lib) +;;; Configuration + +(defvar cj/lipsum-training-file "assets/liber-primus.txt" + "Default training file name (relative to `user-emacs-directory`).") + +(defvar cj/lipsum-default-file + (expand-file-name cj/lipsum-training-file user-emacs-directory) + "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 file specified in +`cj/lipsum-training-file` relative to `user-emacs-directory`.") + (cl-defstruct (cj/markov-chain (:constructor cj/markov-chain-create)) "An order-two Markov chain." @@ -31,25 +44,45 @@ (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]+"))) - + "Split TEXT into tokens: words and punctuation separately. +Returns a list of words and punctuation marks as separate tokens." + (let ((tokens '()) + (pos 0) + (len (length text))) + (while (< pos len) + (cond + ;; Skip whitespace + ((string-match-p "[[:space:]]" (substring text pos (1+ pos))) + (setq pos (1+ pos))) + ;; Match word (sequence of alphanumeric characters) + ((string-match "\\`\\([[:alnum:]]+\\)" (substring text pos)) + (let ((word (match-string 1 (substring text pos)))) + (push word tokens) + (setq pos (+ pos (length word))))) + ;; Match punctuation (single character) + ((string-match "\\`\\([[:punct:]]\\)" (substring text pos)) + (let ((punct (match-string 1 (substring text pos)))) + (push punct tokens) + (setq pos (+ pos (length punct))))) + ;; Skip any other character + (t (setq pos (1+ pos))))) + (nreverse tokens))) (defun cj/markov-learn (chain text) "Add TEXT into the Markov CHAIN with tokenized input." - (let* ((words (cj/markov-tokenize text)) + (let* ((word-list (cj/markov-tokenize text)) + ;; Convert to vector for O(1) access instead of O(n) with nth + (words (vconcat word-list)) (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) + for a = (aref words i) + for b = (aref words (1+ i)) + for c = (aref words (+ i 2)) 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))) + ;; Invalidate cached keys after learning new data + (setf (cj/markov-chain-keys chain) nil)) (defun cj/markov-fix-capitalization (sentence) "Capitalize the first word and the first word after .!? in SENTENCE." @@ -94,7 +127,7 @@ (defun cj/markov-generate (chain n &optional start) "Generate a sentence of N tokens from CHAIN." - (when (cj/markov-chain-keys chain) + (when (> (hash-table-count (cj/markov-chain-map chain)) 0) (let* ((state (or (and start (gethash start (cj/markov-chain-map chain)) start) @@ -116,8 +149,16 @@ (cj/markov-join-tokens tokens)))) (defun cj/markov-random-key (chain) - (nth (random (length (cj/markov-chain-keys chain))) - (cj/markov-chain-keys chain))) + "Return a random bigram key from CHAIN. +Builds and caches the keys list lazily if not already cached." + (unless (cj/markov-chain-keys chain) + ;; Lazily build keys list only when needed + (setf (cj/markov-chain-keys chain) + (cl-loop for k being the hash-keys of (cj/markov-chain-map chain) + collect k))) + (let ((keys (cj/markov-chain-keys chain))) + (when keys + (nth (random (length keys)) keys)))) (defun cj/markov-next-word (chain bigram) (let ((candidates (gethash bigram (cj/markov-chain-map chain)))) @@ -182,6 +223,7 @@ (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)))) + ;; Filter empty strings from generated words (setq words (cl-remove-if #'string-empty-p words)) (mapconcat (lambda (word idx) @@ -204,23 +246,6 @@ Defaults: MIN=30, MAX=80." (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`." @@ -231,5 +256,5 @@ or sentences. By default it points to the bundled `latin.txt`." (cj/lipsum--init) -(provide 'lorem-generator) -;;; lorem-generator.el ends here. +(provide 'lorem-optimum) +;;; lorem-optimum.el ends here. diff --git a/modules/mail-config.el b/modules/mail-config.el index c65e5342..1d5a14ea 100644 --- a/modules/mail-config.el +++ b/modules/mail-config.el @@ -283,9 +283,9 @@ Prompts user for the action when executing." ;; user composes org mode; recipient receives html (use-package org-msg - :ensure nil ;; loading locally for fixes + ;; :vc (:url "https://github.com/cjennings/org-msg" :rev :newest) + :load-path "/home/cjennings/code/org-msg" :defer 1 - :load-path "~/code/org-msg/" :after (org mu4e) :preface (defvar-keymap cj/email-map @@ -294,7 +294,10 @@ Prompts user for the action when executing." "d" #'org-msg-attach-delete) (keymap-set cj/custom-keymap "e" cj/email-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; e" "email menu")) + (which-key-add-key-based-replacements + "C-; e" "email menu" + "C-; e a" "attach file" + "C-; e d" "delete attachment")) :bind ;; more intuitive keybinding for attachments (:map org-msg-edit-mode-map @@ -342,5 +345,9 @@ Prompts user for the action when executing." (advice-add #'mu4e-compose-wide-reply :after (lambda (&rest _) (org-msg-edit-mode))) +;; which-key labels +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements "C-c m" "mu4e email")) + (provide 'mail-config) ;;; mail-config.el ends here diff --git a/modules/mousetrap-mode.el b/modules/mousetrap-mode.el index fa9ee6dd..76c08c79 100644 --- a/modules/mousetrap-mode.el +++ b/modules/mousetrap-mode.el @@ -62,5 +62,8 @@ with or without C-, M-, S- modifiers." (keymap-global-set "C-c M" #'mouse-trap-mode) +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements "C-c M" "mouse trap mode")) + (provide 'mousetrap-mode) ;;; mousetrap-mode.el ends here. diff --git a/modules/music-config.el b/modules/music-config.el index 90feb7eb..902fbd9c 100644 --- a/modules/music-config.el +++ b/modules/music-config.el @@ -366,7 +366,16 @@ Dirs added recursively." (keymap-set cj/custom-keymap "m" cj/music-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; m" "music menu")) + (which-key-add-key-based-replacements + "C-; m" "music menu" + "C-; m m" "toggle playlist" + "C-; m M" "show playlist" + "C-; m a" "add music" + "C-; m r" "create radio" + "C-; m SPC" "pause" + "C-; m s" "stop" + "C-; m p" "playlist mode" + "C-; m x" "shuffle")) (use-package emms :defer t diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el index c86ac6a3..211ff4fe 100644 --- a/modules/org-agenda-config.el +++ b/modules/org-agenda-config.el @@ -54,7 +54,7 @@ (window-height . fit-window-to-buffer))) ;; reset s-left/right each time org-agenda is enabled - (add-hook 'org-agenda-mode-hook (lambda () +s (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))) @@ -68,7 +68,6 @@ (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 @@ -81,10 +80,9 @@ DIRECTORY is a string of the path to begin the search." ;; 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. - + "Rebuilds the org agenda list. Begins with the inbox-file, schedule-file, and contacts-file. -Then adds all todo.org files from projects-dir and code-dir. +Then adds all todo.org files from projects-dir. Reports elapsed time in the messages buffer." (interactive) (let ((start-time (current-time))) @@ -105,7 +103,6 @@ Reports elapsed time in the messages buffer." (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) @@ -118,7 +115,6 @@ files that have project in their filetag." (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) @@ -153,7 +149,6 @@ If the current buffer isn't an org buffer, inform the user." (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))) @@ -176,7 +171,6 @@ before today, is not marked as done, and is not a habit." (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))) @@ -187,7 +181,6 @@ PRIORITY may be one of the characters ?A, ?B, or ?C." (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) @@ -224,12 +217,10 @@ KEYWORDS must be a list of strings." (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\" @@ -263,9 +254,11 @@ This allows a line to show in an agenda without being scheduled or a deadline." ;; Install CHIME from GitHub using use-package :vc (Emacs 29+) (use-package chime - :vc (:url "https://github.com/cjennings/chime.el" :rev :newest) - :after (alert org-agenda) :demand t + ;; :vc (:url "https://github.com/cjennings/chime.el" :rev :newest) ;; using latest on github + :after (alert org-agenda) + :ensure nil ;; using local version + :load-path "~/code/chime.el" :bind ("C-c A" . chime-check) :config @@ -273,13 +266,29 @@ This allows a line to show in an agenda without being scheduled or a deadline." ;; This gives two notifications per event without any after-event notifications (setq chime-alert-time '(5 0)) - ;; Modeline display: show upcoming events within 60 minutes - (setq chime-modeline-lookahead 120) + ;; Modeline display: show upcoming events within 2 hours + (setq chime-enable-modeline t) + (setq chime-modeline-lookahead 180) (setq chime-modeline-format " ⏰ %s") - ;; Chime sound: plays when notifications appear - (setq chime-play-sound t) - ;; Uses bundled chime.wav by default + ;; Tooltip settings: show up to 20 upcoming events (regardless of how far in future) + ;; chime-modeline-tooltip-lookahead defaults to 525600 (1 year) - effectively unlimited + (setq chime-modeline-tooltip-max-events 20) + + ;; Modeline content: show title and countdown only (omit event time) + (setq chime-notification-text-format "%t (%u)") + + ;; Time-until format: compact style like " in 10m" or " in 1h 37m" + (setq chime-time-left-format-short " in %mm") ; Under 1 hour: " in 10m" + (setq chime-time-left-format-long " in %hh %mm") ; 1 hour+: " in 1h 37m" + (setq chime-time-left-format-at-event "right now") + + ;; Title truncation: limit long event titles to 15 characters + ;; This affects only the title, not the icon or countdown + (setq chime-max-title-length 25) ; "Very Long Me... ( in 10m)" + + ;; Chime sound: disabled + (setq chime-play-sound nil) ;; Notification settings (setq chime-notification-title "Reminder") @@ -296,6 +305,9 @@ This allows a line to show in an agenda without being scheduled or a deadline." ;; Enable chime-mode automatically (chime-mode 1)) +;; which-key labels +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements "C-c A" "chime check")) (provide 'org-agenda-config) ;;; org-agenda-config.el ends here diff --git a/modules/org-config.el b/modules/org-config.el index 0249973f..753b1092 100644 --- a/modules/org-config.el +++ b/modules/org-config.el @@ -16,7 +16,7 @@ :init (defvar-keymap cj/org-table-map :doc "org table operations.") - (keymap-global-set "C-c t" cj/org-table-map) + (keymap-set cj/custom-keymap "T" cj/org-table-map) :bind ("C-c c" . org-capture) ("C-c a" . org-agenda) @@ -266,5 +266,20 @@ the current buffer's cache. Useful when encountering parsing errors like (message "Cleared org-element cache for current buffer")) (user-error "Current buffer is not in org-mode")))) +;; which-key labels for org-table-map +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; T" "org table menu" + "C-; T r" "table row" + "C-; T r i" "insert row" + "C-; T r d" "delete row" + "C-; T c" "table column" + "C-; T c i" "insert column" + "C-; T c d" "delete column" + ;; org global bindings + "C-c a" "org agenda" + "C-c c" "org capture" + "C-c l" "org store link")) + (provide 'org-config) ;;; org-config.el ends here diff --git a/modules/org-contacts-config.el b/modules/org-contacts-config.el index df4e18f1..adb99db4 100644 --- a/modules/org-contacts-config.el +++ b/modules/org-contacts-config.el @@ -212,5 +212,14 @@ module provides more sophisticated completion." ;; Bind the org-contacts map to the C-c C prefix (keymap-global-set "C-c C" cj/org-contacts-map) +;; which-key labels +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c C" "contacts menu" + "C-c C f" "find contact" + "C-c C n" "new contact" + "C-c C e" "insert email" + "C-c C v" "view all contacts")) + (provide 'org-contacts-config) ;;; org-contacts-config.el ends here diff --git a/modules/org-drill-config.el b/modules/org-drill-config.el index f18760c7..08047e3a 100644 --- a/modules/org-drill-config.el +++ b/modules/org-drill-config.el @@ -70,7 +70,13 @@ (keymap-set cj/custom-keymap "D" cj/drill-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; D" "org-drill menu"))) + (which-key-add-key-based-replacements + "C-; D" "org-drill menu" + "C-; D s" "start drill" + "C-; D e" "edit drill file" + "C-; D c" "capture question" + "C-; D r" "refile to drill" + "C-; D R" "resume drill"))) (provide 'org-drill-config) ;;; org-drill-config.el ends here. diff --git a/modules/org-gcal-config.el b/modules/org-gcal-config.el index ed0831b8..0c309a0e 100644 --- a/modules/org-gcal-config.el +++ b/modules/org-gcal-config.el @@ -10,6 +10,9 @@ ;; - Automatic removal of cancelled events, but with TODOs added for visibility ;; - System timezone configuration via functions in host-environment ;; - No notifications on syncing +;; - Events are managed by Org (changes in org file push back to Google Calendar) +;; This is controlled by org-gcal-managed-newly-fetched-mode and +;; org-gcal-managed-update-existing-mode set to "org" ;; - 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 @@ -35,6 +38,11 @@ (require 'host-environment) (require 'user-constants) +;; Forward declare org-gcal internal variables and functions +(eval-when-compile + (defvar org-gcal--sync-lock)) +(declare-function org-gcal-reload-client-id-secret "org-gcal") + (defun cj/org-gcal-clear-sync-lock () "Clear the org-gcal sync lock. Useful when a sync fails and leaves the lock in place, preventing future syncs." @@ -42,7 +50,24 @@ Useful when a sync fails and leaves the lock in place, preventing future syncs." (setq org-gcal--sync-lock nil) (message "org-gcal sync lock cleared")) +(defun cj/org-gcal-convert-all-to-org-managed () + "Convert all org-gcal events in current buffer to Org-managed. + +Changes all events with org-gcal-managed property from `gcal' to `org', +enabling bidirectional sync so changes push back to Google Calendar." + (interactive) + (let ((count 0)) + (save-excursion + (goto-char (point-min)) + (while (re-search-forward "^:org-gcal-managed: gcal$" nil t) + (replace-match ":org-gcal-managed: org") + (setq count (1+ count)))) + (when (> count 0) + (save-buffer)) + (message "Converted %d event(s) to Org-managed" count))) + (use-package org-gcal + :vc (:url "https://github.com/cjennings/org-gcal" :rev :newest) :defer t ;; unless idle timer is set below :bind (("C-; g" . org-gcal-sync) ("C-; G" . cj/org-gcal-clear-sync-lock)) @@ -71,6 +96,10 @@ Useful when a sync fails and leaves the lock in place, preventing future syncs." (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 + ;; Enable bidirectional sync - treat events as Org-managed so changes push back + (setq org-gcal-managed-newly-fetched-mode "org") ;; New events from GCal are Org-managed + (setq org-gcal-managed-update-existing-mode "org") ;; Existing events become Org-managed + :config ;; Enable plstore passphrase caching after org-gcal loads (require 'plstore) @@ -80,7 +109,21 @@ Useful when a sync fails and leaves the lock in place, preventing future syncs." (setq org-gcal-local-timezone (cj/detect-system-timezone)) ;; Reload client credentials (should already be loaded by org-gcal, but ensure it's set) - (org-gcal-reload-client-id-secret)) + (org-gcal-reload-client-id-secret) + + ;; Auto-save gcal files after sync completes + (defun cj/org-gcal-save-files-after-sync (&rest _) + "Save all org-gcal files after sync completes." + (dolist (entry org-gcal-fetch-file-alist) + (let* ((file (cdr entry)) + (buffer (get-file-buffer file))) + (when (and buffer (buffer-modified-p buffer)) + (with-current-buffer buffer + (save-buffer) + (message "Saved %s after org-gcal sync" (file-name-nondirectory file))))))) + + ;; Advise org-gcal--sync-unlock which is called when sync completes + (advice-add 'org-gcal--sync-unlock :after #'cj/org-gcal-save-files-after-sync)) ;; Set up automatic initial sync on boot with error handling ;;(run-with-idle-timer @@ -90,5 +133,11 @@ Useful when a sync fails and leaves the lock in place, preventing future syncs." ;; (org-gcal-sync) ;; (error (message "org-gcal: Initial sync failed: %s" err))))) +;; which-key labels +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; g" "gcal sync" + "C-; G" "clear sync lock")) + (provide 'org-gcal-config) ;;; org-gcal-config.el ends here diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el index 07098743..a6b42ce7 100644 --- a/modules/org-roam-config.el +++ b/modules/org-roam-config.el @@ -19,6 +19,7 @@ ;; ---------------------------------- Org Roam --------------------------------- (use-package org-roam + :defer 1 :commands (org-roam-node-find org-roam-node-insert org-roam-db-autosync-mode) :config ;; Enable autosync mode after org-roam loads @@ -85,7 +86,9 @@ (add-to-list 'org-after-todo-state-change-hook (lambda () (when (and (member org-state org-done-keywords) - (not (member org-last-state org-done-keywords))) + (not (member org-last-state org-done-keywords)) + ;; Don't run for gcal.org - it's managed by org-gcal + (not (string= (buffer-file-name) (expand-file-name gcal-file)))) (cj/org-roam-copy-todo-to-today))))) ;; ------------------------- Org Roam Insert Immediate ------------------------- @@ -294,5 +297,19 @@ title." ;; Message to user (message "'%s' added as an org-roam node." title))) +;; which-key labels +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c n" "org-roam menu" + "C-c n l" "roam buffer toggle" + "C-c n f" "roam find node" + "C-c n p" "roam find project" + "C-c n r" "roam find recipe" + "C-c n t" "roam find topic" + "C-c n i" "roam insert node" + "C-c n w" "roam find webclip" + "C-c n I" "roam insert immediate" + "C-c n d" "roam dailies menu")) + (provide 'org-roam-config) ;;; org-roam-config.el ends here. diff --git a/modules/popper-config.el b/modules/popper-config.el index b0f503e8..d9a9d9b0 100644 --- a/modules/popper-config.el +++ b/modules/popper-config.el @@ -26,6 +26,7 @@ '("\\*Messages\\*" "Output\\*$" "\\*Async Shell Command\\*" + "\\*Async-native-compile-log\\*" help-mode compilation-mode)) (add-to-list 'display-buffer-alist diff --git a/modules/prog-general.el b/modules/prog-general.el index f6ebfe09..d8d9627d 100644 --- a/modules/prog-general.el +++ b/modules/prog-general.el @@ -264,12 +264,8 @@ If no such file exists there, display a message." ("C-c s n" . yas-new-snippet) ("C-c s e" . yas-visit-snippet-file) :config - (setq yas-snippet-dirs '(snippets-dir))) - -(use-package ivy-yasnippet - :after yasnippet - :bind - ("C-c s i" . ivy-yasnippet)) + (setq yas-snippet-dirs (list snippets-dir)) + (yas-reload-all)) ;; --------------------- Display Color On Color Declaration -------------------- ;; display the actual color as highlight to color hex code @@ -400,6 +396,15 @@ If no such file exists there, display a message." "1.5 sec" nil 'delete-windows-on (get-buffer-create "*compilation*")))))) +;; which-key labels +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c s" "snippets menu" + "C-c s n" "new snippet" + "C-c s e" "edit snippet" + "C-c s i" "insert snippet" + "C-c p" "projectile menu" + "C-c C-s" "symbol overlay")) (provide 'prog-general) ;;; prog-general.el ends here diff --git a/modules/prog-lisp.el b/modules/prog-lisp.el index 7693c253..cfa015ae 100644 --- a/modules/prog-lisp.el +++ b/modules/prog-lisp.el @@ -97,9 +97,12 @@ :commands (with-mock mocklet mocklet-function)) ;; mock/stub framework ;; --------------------------------- Elisp Lint -------------------------------- +;; Comprehensive linting for Emacs Lisp code (indentation, whitespace, etc.) +;; Used by chime.el 'make lint' target for code quality checks (use-package elisp-lint - :commands (elisp-lint-file elisp-lint-directory)) + :ensure t + :commands (elisp-lint-file elisp-lint-directory elisp-lint-files-batch)) ;; ------------------------------ Package Tooling ------------------------------ diff --git a/modules/selection-framework.el b/modules/selection-framework.el index 66ca1cbd..a89afc02 100644 --- a/modules/selection-framework.el +++ b/modules/selection-framework.el @@ -27,7 +27,6 @@ (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 @@ -128,7 +127,7 @@ ;; Use Consult for completion-at-point (setq completion-in-region-function #'consult-completion-in-region)) -(global-unset-key (kbd "C-s")) +;; Override default search with consult-line (keymap-global-set "C-s" #'consult-line) ;; Consult integration with Embark @@ -152,10 +151,10 @@ (use-package orderless :demand t :custom - (completion-styles '(orderless)) + (completion-styles '(orderless basic)) (completion-category-defaults nil) - (completion-category-overrides '((file (styles partial-completion)) - (multi-category (styles orderless)))) + (completion-category-overrides '((file (styles partial-completion orderless basic)) + (multi-category (styles orderless basic)))) (orderless-matching-styles '(orderless-literal orderless-regexp orderless-initialism @@ -183,16 +182,10 @@ 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 +;; Yasnippet integration (use-package consult-yasnippet :after yasnippet :bind ("C-c s i" . consult-yasnippet)) @@ -204,7 +197,7 @@ ("C-c ! c" . consult-flycheck))) ;; ---------------------------------- Company ---------------------------------- -;; In-buffer completion (retained from original configuration) +;; In-buffer completion for text and code (use-package company :demand t @@ -259,5 +252,13 @@ :config (company-prescient-mode)) +;; which-key labels +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-c h" "consult history" + "C-c s i" "insert snippet" + "M-g" "goto menu" + "M-s" "search menu")) + (provide 'selection-framework) ;;; selection-framework.el ends here diff --git a/modules/system-commands.el b/modules/system-commands.el new file mode 100644 index 00000000..fb8c0611 --- /dev/null +++ b/modules/system-commands.el @@ -0,0 +1,138 @@ +;;; system-commands.el --- System power and session management -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; +;; System commands for logout, lock, suspend, shutdown, reboot, and Emacs +;; exit/restart. Provides both a keymap (C-; !) and a completing-read menu. +;; +;; Commands include: +;; - Logout (terminate user session) +;; - Lock screen (slock) +;; - Suspend (systemctl suspend) +;; - Shutdown (systemctl poweroff) +;; - Reboot (systemctl reboot) +;; - Exit Emacs (kill-emacs) +;; - Restart Emacs (via systemctl --user restart emacs.service) +;; +;; Dangerous commands (logout, suspend, shutdown, reboot) require confirmation. +;; +;;; Code: + +(eval-when-compile (require 'keybindings)) +(eval-when-compile (require 'subr-x)) +(require 'rx) + +;; ------------------------------ System Commands ------------------------------ + +(defun cj/system-cmd--resolve (cmd) + "Return (values symbol-or-nil command-string label) for CMD." + (cond + ((symbolp cmd) + (let ((val (and (boundp cmd) (symbol-value cmd)))) + (unless (and (stringp val) (not (string-empty-p val))) + (user-error "Variable %s is not a non-empty string" cmd)) + (list cmd val (symbol-name cmd)))) + ((stringp cmd) + (let ((s (string-trim cmd))) + (when (string-empty-p s) (user-error "Command string is empty")) + (list nil s "command"))) + (t (user-error "Error: cj/system-cmd expects a string or a symbol")))) + +(defun cj/system-cmd (cmd) + "Run CMD (string or symbol naming a string) detached via the shell. +Shell expansions like $(...) are supported. Output is silenced. +If CMD is deemed dangerous, ask for confirmation." + (interactive (list (read-shell-command "System command: "))) + (pcase-let ((`(,sym ,cmdstr ,label) (cj/system-cmd--resolve cmd))) + (when (and sym (get sym 'cj/system-confirm) + (memq (read-char-choice + (format "Run %s now (%s)? (Y/n) " label cmdstr) + '(?y ?Y ?n ?N ?\r ?\n ?\s)) + '(?n ?N))) + (user-error "Aborted")) + (let ((proc (start-process-shell-command "cj/system-cmd" nil + (format "nohup %s >/dev/null 2>&1 &" cmdstr)))) + (set-process-query-on-exit-flag proc nil) + (set-process-sentinel proc #'ignore) + (message "Running %s..." label)))) + +(defmacro cj/defsystem-command (name var cmdstr &optional confirm) + "Define VAR with CMDSTR and interactive command NAME to run it. +If CONFIRM is non-nil, mark VAR to always require confirmation." + (declare (indent defun)) + `(progn + (defvar ,var ,cmdstr) + ,(when confirm `(put ',var 'cj/system-confirm t)) + (defun ,name () + ,(format "Run %s via `cj/system-cmd'." var) + (interactive) + (cj/system-cmd ',var)))) + +;; Define system commands +(cj/defsystem-command cj/system-cmd-logout logout-cmd "loginctl terminate-user $(whoami)" t) +(cj/defsystem-command cj/system-cmd-lock lockscreen-cmd "slock") +(cj/defsystem-command cj/system-cmd-suspend suspend-cmd "systemctl suspend" t) +(cj/defsystem-command cj/system-cmd-shutdown shutdown-cmd "systemctl poweroff" t) +(cj/defsystem-command cj/system-cmd-reboot reboot-cmd "systemctl reboot" t) + +(defun cj/system-cmd-exit-emacs () + "Exit Emacs server and all clients." + (interactive) + (when (memq (read-char-choice + "Exit Emacs? (Y/n) " + '(?y ?Y ?n ?N ?\r ?\n ?\s)) + '(?n ?N)) + (user-error "Aborted")) + (kill-emacs)) + +(defun cj/system-cmd-restart-emacs () + "Restart Emacs server after saving buffers." + (interactive) + (when (memq (read-char-choice + "Restart Emacs? (Y/n) " + '(?y ?Y ?n ?N ?\r ?\n ?\s)) + '(?n ?N)) + (user-error "Aborted")) + (save-some-buffers) + ;; Start the restart process before killing Emacs + (run-at-time 0.5 nil + (lambda () + (call-process-shell-command + "systemctl --user restart emacs.service && emacsclient -c" + nil 0))) + (run-at-time 1 nil #'kill-emacs) + (message "Restarting Emacs...")) + +(defvar-keymap cj/system-command-map + :doc "Keymap for system commands." + "L" #'cj/system-cmd-logout + "r" #'cj/system-cmd-reboot + "s" #'cj/system-cmd-shutdown + "S" #'cj/system-cmd-suspend + "l" #'cj/system-cmd-lock + "E" #'cj/system-cmd-exit-emacs + "e" #'cj/system-cmd-restart-emacs) +(keymap-set cj/custom-keymap "!" cj/system-command-map) + +(defun cj/system-command-menu () + "Present system commands via \='completing-read\='." + (interactive) + (let* ((commands '(("Logout System" . cj/system-cmd-logout) + ("Lock Screen" . cj/system-cmd-lock) + ("Suspend System" . cj/system-cmd-suspend) + ("Shutdown System" . cj/system-cmd-shutdown) + ("Reboot System" . cj/system-cmd-reboot) + ("Exit Emacs" . cj/system-cmd-exit-emacs) + ("Restart Emacs" . cj/system-cmd-restart-emacs))) + (choice (completing-read "System command: " commands nil t))) + (when-let ((cmd (alist-get choice commands nil nil #'equal))) + (call-interactively cmd)))) + +(keymap-set cj/custom-keymap "!" #'cj/system-command-menu) + +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements "C-; !" "system commands")) + +(provide 'system-commands) +;;; system-commands.el ends here diff --git a/modules/system-utils.el b/modules/system-utils.el index 6e51c32c..eef20718 100644 --- a/modules/system-utils.el +++ b/modules/system-utils.el @@ -43,6 +43,9 @@ (message "Error occurred during evaluation: %s" (error-message-string err))))) (keymap-global-set "C-c b" #'cj/eval-buffer-with-confirmation-or-error-message) +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements "C-c b" "eval buffer")) + ;;; ---------------------------- Edit A File With Sudo ---------------------------- (use-package sudo-edit diff --git a/modules/test-runner.el b/modules/test-runner.el index b66efc81..125a8d20 100644 --- a/modules/test-runner.el +++ b/modules/test-runner.el @@ -2,26 +2,75 @@ ;; author: Craig Jennings <c@cjennings.net> ;; ;;; Commentary: -;; Provides utilities for running ERT tests with focus/unfocus workflow + +;; This module provides a powerful ERT test runner with focus/unfocus workflow +;; for efficient test-driven development in Emacs Lisp projects. +;; +;; PURPOSE: +;; +;; When working on large Emacs Lisp projects with many test files, you often +;; want to focus on running just the tests relevant to your current work without +;; waiting for the entire suite to run. This module provides a smart test runner +;; that supports both running all tests and focusing on specific test files. +;; +;; WORKFLOW: +;; +;; 1. Run all tests initially to establish baseline (C-; t R) +;; 2. Add test files to focus while working on a feature (C-; t a) +;; 3. Run focused tests repeatedly as you develop (C-; t r) +;; 4. Add more test files as needed (C-; t b from within test buffer) +;; 5. View your focused test list at any time (C-; t v) +;; 6. Clear focus and run all tests before finishing (C-; t c, then C-; t R) +;; +;; PROJECT INTEGRATION: ;; -;; 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. +;; - Automatically discovers test directories in Projectile projects +;; (looks for "test" or "tests" under project root) +;; - Falls back to ~/.emacs.d/tests if not in a Projectile project +;; - Test files must match pattern: test-*.el ;; -;; The default mode is to load and run all tests. +;; SPECIAL BEHAVIORS: ;; -;; 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 +;; - Smart test running: Automatically runs all or focused tests based on mode +;; - Test extraction: Discovers test names via regex to run specific tests +;; - At-point execution: Run individual test at cursor position (C-; t .) +;; - Error handling: Continues loading tests even if individual files fail ;; -;; Don't forget to run all tests again in default mode at least once before finishing. +;; KEYBINDINGS: +;; +;; C-; t L Load all test files +;; C-; t R Run all tests (full suite) +;; C-; t r Run tests smartly (all or focused based on mode) +;; C-; t . Run test at point +;; C-; t a Add test file to focus (with completion) +;; C-; t b Add current buffer's test file to focus +;; C-; t c Clear all focused test files +;; C-; t v View list of focused test files +;; C-; t t Toggle mode between 'all and 'focused +;; +;; RECOMMENDED USAGE: +;; +;; While implementing a feature: +;; - Add the main test file for the feature you're working on +;; - Add any related test files that might be affected +;; - Use C-; t r to repeatedly run just those focused tests +;; - This provides fast feedback during development +;; +;; Before committing: +;; - Clear the focus with C-; t c +;; - Run the full suite with C-; t R to ensure nothing broke +;; - Verify all tests pass before pushing changes ;; ;;; Code: (require 'ert) (require 'cl-lib) +;;; External Variables and Functions + +(defvar cj/custom-keymap) ; Defined in init.el +(declare-function projectile-project-root "projectile" ()) + ;;; Variables (defvar cj/test-global-directory nil @@ -35,7 +84,7 @@ 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).") +Either \\='all (run all tests) or \\='focused (run only focused tests).") (defvar cj/test-last-results nil "Results from the last test run.") @@ -45,8 +94,9 @@ Either 'all (run all tests) or 'focused (run only focused tests).") (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." +If in a Projectile project, prefers \\='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))) @@ -60,12 +110,31 @@ Falls back to =cj/test-global-directory= if not found or not in a project." (t cj/test-global-directory)))))) (defun cj/test--get-test-files () - "Return a list of test file names (without path) in the appropriate test directory." + "Return list of test file names (without path) in test directory." (let ((dir (cj/test--get-test-directory))) (when (file-directory-p dir) (mapcar #'file-name-nondirectory (directory-files dir t "^test-.*\\.el$"))))) +(defun cj/test--do-load-files (_dir files) + "Load test FILES from DIR. +Returns: (cons \\='success loaded-count) on success, + (cons \\='error (list failed-files errors)) on errors." + (let ((loaded-count 0) + (errors '())) + (dolist (file files) + (condition-case err + (progn + (load-file file) + (setq loaded-count (1+ loaded-count))) + (error + (push (cons (file-name-nondirectory file) + (error-message-string err)) + errors)))) + (if (null errors) + (cons 'success loaded-count) + (cons 'error (list loaded-count (nreverse errors)))))) + (defun cj/test-load-all () "Load all test files from the appropriate test directory." (interactive) @@ -73,19 +142,26 @@ Falls back to =cj/test-global-directory= if not found or not in a project." (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)))) + (let ((test-files (directory-files dir t "^test-.*\\.el$"))) + (pcase (cj/test--do-load-files dir test-files) + (`(success . ,count) + (message "Loaded %d test file(s)" count)) + (`(error ,count ,errors) + (dolist (err errors) + (message "Error loading %s: %s" (car err) (cdr err))) + (message "Loaded %d test file(s) with %d error(s)" count (length errors))))))) + +(defun cj/test--do-focus-add (filename available-files focused-files) + "Add FILENAME to focused test files. +AVAILABLE-FILES is the list of all available test files. +FOCUSED-FILES is the current list of focused files. +Returns: \\='success if added successfully, + \\='already-focused if file is already focused, + \\='not-available if file is not in available-files." + (cond + ((not (member filename available-files)) 'not-available) + ((member filename focused-files) 'already-focused) + (t 'success))) (defun cj/test-focus-add () "Select test file(s) to add to the focused list." @@ -105,27 +181,63 @@ Falls back to =cj/test-global-directory= if not found or not in a project." 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)))))) + (pcase (cj/test--do-focus-add selected available-files cj/test-focused-files) + ('success + (push selected cj/test-focused-files) + (message "Added to focus: %s" selected) + (when (called-interactively-p 'interactive) + (cj/test-view-focused))) + ('already-focused + (message "Already focused: %s" selected)) + ('not-available + (user-error "File not available: %s" selected))))))) + +(defun cj/test--do-focus-add-file (filepath testdir focused-files) + "Validate and add FILEPATH to focused list. +TESTDIR is the test directory path. +FOCUSED-FILES is the current list of focused files. +Returns: \\='success if added successfully, + \\='no-file if filepath is nil, + \\='not-in-testdir if file is not inside test directory, + \\='already-focused if file is already focused. +Second value is the relative filename if successful." + (cond + ((null filepath) (cons 'no-file nil)) + ((not (string-prefix-p (file-truename testdir) (file-truename filepath))) + (cons 'not-in-testdir nil)) + (t + (let ((relative (file-relative-name filepath testdir))) + (if (member relative focused-files) + (cons 'already-focused relative) + (cons 'success relative)))))) (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)))))) + (pcase (cj/test--do-focus-add-file file dir cj/test-focused-files) + (`(no-file . ,_) + (user-error "Current buffer is not visiting a file")) + (`(not-in-testdir . ,_) + (user-error "File is not inside the test directory: %s" dir)) + (`(already-focused . ,relative) + (message "Already focused: %s" relative)) + (`(success . ,relative) + (push relative cj/test-focused-files) + (message "Added to focus: %s" relative) + (when (called-interactively-p 'interactive) + (cj/test-view-focused)))))) + +(defun cj/test--do-focus-remove (filename focused-files) + "Remove FILENAME from FOCUSED-FILES. +Returns: \\='success if removed successfully, + \\='empty-list if focused-files is empty, + \\='not-found if filename is not in focused-files." + (cond + ((null focused-files) 'empty-list) + ((not (member filename focused-files)) 'not-found) + (t 'success))) (defun cj/test-focus-remove () "Remove a test file from the focused list." @@ -135,11 +247,17 @@ Falls back to =cj/test-global-directory= if not found or not in a project." (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))))) + (pcase (cj/test--do-focus-remove selected cj/test-focused-files) + ('success + (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))) + ('not-found + (message "File not in focused list: %s" selected)) + ('empty-list + (user-error "No focused files to remove")))))) (defun cj/test-focus-clear () "Clear all focused test files." @@ -161,55 +279,69 @@ Returns a list of test name symbols defined in the file." (push (match-string 1) test-names))) test-names)) +(defun cj/test--do-get-focused-tests (focused-files test-dir) + "Get test names from FOCUSED-FILES in TEST-DIR. +Returns: (cons \\='success (list test-names loaded-count)) if successful, + (cons \\='no-tests nil) if no tests found, + (cons \\='empty-list nil) if focused-files is empty." + (if (null focused-files) + (cons 'empty-list nil) + (let ((all-test-names '()) + (loaded-count 0)) + (dolist (file focused-files) + (let ((full-path (expand-file-name file test-dir))) + (when (file-exists-p full-path) + (load-file full-path) + (setq loaded-count (1+ loaded-count)) + (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) + (cons 'no-tests nil) + (cons 'success (list all-test-names loaded-count)))))) + (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 "$"))))))) + (let ((dir (cj/test--get-test-directory))) + (pcase (cj/test--do-get-focused-tests cj/test-focused-files dir) + (`(empty-list . ,_) + (user-error "No focused files set. Use =cj/test-focus-add' first")) + (`(no-tests . ,_) + (message "No tests found in focused files")) + (`(success ,test-names ,loaded-count) + (let ((pattern (regexp-opt test-names))) + (message "Running %d test(s) from %d focused file(s)" + (length test-names) loaded-count) + (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`." + "Ensure 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)))) +(defun cj/test--extract-test-at-pos () + "Extract test name at current position. +Returns: test name symbol if found, nil otherwise." + (save-excursion + (beginning-of-defun) + (condition-case nil + (let ((form (read (current-buffer)))) + (when (and (listp form) + (eq (car form) 'ert-deftest) + (symbolp (cadr form))) + (cadr form))) + (error nil)))) + (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))) + (let ((test-name (cj/test--extract-test-at-pos))) + (if test-name + (ert test-name) + (message "Not in an ERT test method.")))) (defun cj/test-run-all () "Load and run all tests." @@ -218,7 +350,7 @@ Otherwise, message that no test is found." (ert t)) (defun cj/test-toggle-mode () - "Toggle between 'all and 'focused test execution modes." + "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)) @@ -252,8 +384,20 @@ Otherwise, message that no test is found." "t" #'cj/test-toggle-mode) (keymap-set cj/custom-keymap "t" cj/testrunner-map) + +;; which-key integration (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; t" "test runner menu")) + (which-key-add-key-based-replacements + "C-; t" "test runner menu" + "C-; t L" "load all tests" + "C-; t R" "run all tests" + "C-; t r" "run smart" + "C-; t ." "run test at point" + "C-; t a" "add to focus" + "C-; t b" "add buffer to focus" + "C-; t c" "clear focus" + "C-; t v" "view focused" + "C-; t t" "toggle mode")) (provide 'test-runner) ;;; test-runner.el ends here diff --git a/modules/text-config.el b/modules/text-config.el index 730e36a3..29db9e0b 100644 --- a/modules/text-config.el +++ b/modules/text-config.el @@ -46,8 +46,7 @@ ;; change inner and outer, just like in vim. (use-package change-inner - :bind (("C-c i" . change-inner) - ("C-c o" . change-outer))) + :commands (change-inner change-outer)) ;; ------------------------------ Delete Selection ----------------------------- ;; delete the region on character insertion diff --git a/modules/vc-config.el b/modules/vc-config.el index 3b116cc1..a936e890 100644 --- a/modules/vc-config.el +++ b/modules/vc-config.el @@ -131,7 +131,16 @@ (keymap-set cj/custom-keymap "v" cj/vc-map) (with-eval-after-load 'which-key - (which-key-add-key-based-replacements "C-; v" "version control menu")) + (which-key-add-key-based-replacements + "C-; v" "version control menu" + "C-; v d" "goto diff hunks" + "C-; v c" "create issue" + "C-; v f" "forge pull" + "C-; v i" "list issues" + "C-; v n" "next hunk" + "C-; v p" "previous hunk" + "C-; v r" "list pull requests" + "C-; v t" "git timemachine")) (provide 'vc-config) ;;; vc-config.el ends here. diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el index fa4c2926..73f782f6 100644 --- a/modules/video-audio-recording.el +++ b/modules/video-audio-recording.el @@ -227,5 +227,14 @@ Otherwise use the default location in `audio-recordings-dir'." (keymap-set cj/custom-keymap "r" cj/record-map) +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; r" "recording menu" + "C-; r v" "start video" + "C-; r V" "stop video" + "C-; r a" "start audio" + "C-; r A" "stop audio" + "C-; r l" "adjust levels")) + (provide 'video-audio-recording) ;;; video-audio-recording.el ends here. diff --git a/modules/weather-config.el b/modules/weather-config.el index 526a0b41..31fb1b70 100644 --- a/modules/weather-config.el +++ b/modules/weather-config.el @@ -11,9 +11,8 @@ ;; ----------------------------------- Wttrin ---------------------------------- (use-package wttrin + :vc (:url "https://github.com/cjennings/emacs-wttrin" :rev :newest) :defer t - :load-path ("~/code/wttrin") - :ensure nil ;; local package :preface ;; dependency for wttrin (use-package xterm-color diff --git a/modules/wip.el b/modules/wip.el index 80b3295d..93c799fb 100644 --- a/modules/wip.el +++ b/modules/wip.el @@ -14,133 +14,6 @@ ;; ;;; Code: -(eval-when-compile (require 'user-constants)) -(eval-when-compile (require 'keybindings)) -(eval-when-compile (require 'subr-x)) ;; for system commands -(require 'rx) ;; for system commands - -;; ------------------------------ System Commands ------------------------------ - -(defun cj/system-cmd--resolve (cmd) - "Return (values symbol-or-nil command-string label) for CMD." - (cond - ((symbolp cmd) - (let ((val (and (boundp cmd) (symbol-value cmd)))) - (unless (and (stringp val) (not (string-empty-p val))) - (user-error "Variable %s is not a non-empty string" cmd)) - (list cmd val (symbol-name cmd)))) - ((stringp cmd) - (let ((s (string-trim cmd))) - (when (string-empty-p s) (user-error "Command string is empty")) - (list nil s "command"))) - (t (user-error "Error: cj/system-cmd expects a string or a symbol")))) - -(defun cj/system-cmd (cmd) - "Run CMD (string or symbol naming a string) detached via the shell. -Shell expansions like $(...) are supported. Output is silenced. -If CMD is deemed dangerous, ask for confirmation." - (interactive (list (read-shell-command "System command: "))) - (pcase-let ((`(,sym ,cmdstr ,label) (cj/system-cmd--resolve cmd))) - (when (and sym (get sym 'cj/system-confirm) - (memq (read-char-choice - (format "Run %s now (%s)? (Y/n) " label camdstr) - '(?y ?Y ?n ?N ?\r ?\n ?\s)) - '(?n ?N))) - (user-error "Aborted")) - (let ((proc (start-process-shell-command "cj/system-cmd" nil - (format "nohup %s >/dev/null 2>&1 &" cmdstr)))) - (set-process-query-on-exit-flag proc nil) - (set-process-sentinel proc #'ignore) - (message "Running %s..." label)))) - -(defmacro cj/defsystem-command (name var cmdstr &optional confirm) - "Define VAR with CMDSTR and interactive command NAME to run it. -If CONFIRM is non-nil, mark VAR to always require confirmation." - (declare (indent defun)) - `(progn - (defvar ,var ,cmdstr) - ,(when confirm `(put ',var 'cj/system-confirm t)) - (defun ,name () - ,(format "Run %s via `cj/system-cmd'." var) - (interactive) - (cj/system-cmd ',var)))) - -;; Define system commands -(cj/defsystem-command cj/system-cmd-logout logout-cmd "loginctl terminate-user $(whoami)" t) -(cj/defsystem-command cj/system-cmd-lock lockscreen-cmd "slock") -(cj/defsystem-command cj/system-cmd-suspend suspend-cmd "systemctl suspend" t) -(cj/defsystem-command cj/system-cmd-shutdown shutdown-cmd "systemctl poweroff" t) -(cj/defsystem-command cj/system-cmd-reboot reboot-cmd "systemctl reboot" t) - -(defun cj/system-cmd-exit-emacs () - "Exit Emacs server and all clients." - (interactive) - (when (memq (read-char-choice - "Exit Emacs? (Y/n) " - '(?y ?Y ?n ?N ?\r ?\n ?\s)) - '(?n ?N)) - (user-error "Aborted")) - (kill-emacs)) - -(defun cj/system-cmd-restart-emacs () - "Restart Emacs server after saving buffers." - (interactive) - (when (memq (read-char-choice - "Restart Emacs? (Y/n) " - '(?y ?Y ?n ?N ?\r ?\n ?\s)) - '(?n ?N)) - (user-error "Aborted")) - (save-some-buffers) - ;; Start the restart process before killing Emacs - (run-at-time 0.5 nil - (lambda () - (call-process-shell-command - "systemctl --user restart emacs.service && emacsclient -c" - nil 0))) - (run-at-time 1 nil #'kill-emacs) - (message "Restarting Emacs...")) - -;; (defvar-keymap cj/system-command-map -;; :doc "Keymap for system commands." -;; "L" #'cj/system-cmd-logout -;; "r" #'cj/system-cmd-reboot -;; "s" #'cj/system-cmd-shutdown -;; "S" #'cj/system-cmd-suspend -;; "l" #'cj/system-cmd-lock -;; "E" #'cj/system-cmd-exit-emacs -;; "e" #'cj/system-cmd-restart-emacs) -;; (keymap-set cj/custom-keymap "!" cj/system-command-map) - -(defun cj/system-command-menu () - "Present system commands via \='completing-read\='." - (interactive) - (let* ((commands '(("Logout System" . cj/system-cmd-logout) - ("Lock Screen" . cj/system-cmd-lock) - ("Suspend System" . cj/system-cmd-suspend) - ("Shutdown System" . cj/system-cmd-shutdown) - ("Reboot System" . cj/system-cmd-reboot) - ("Exit Emacs" . cj/system-cmd-exit-emacs) - ("Restart Emacs" . cj/system-cmd-restart-emacs))) - (choice (completing-read "System command: " commands nil t))) - (when-let ((cmd (alist-get choice commands nil nil #'equal))) - (call-interactively cmd)))) - -(keymap-set cj/custom-keymap "!" #'cj/system-command-menu) - - -;; --------------------------- 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 @@ -183,30 +56,5 @@ If CONFIRM is non-nil, mark VAR to always require confirmation." :bind ("M-p" . pomm) :commands (pomm pomm-third-time)) -;; ----------------------------------- Popper ---------------------------------- - -;; (use-package popper -;; :bind (("C-`" . popper-toggle) -;; ("M-`" . popper-cycle) -;; ("C-M-`" . popper-toggle-type)) -;; :custom -;; (popper-display-control-nil) -;; :init -;; (setq popper-reference-buffers -;; '("\\*Messages\\*" -;; "Output\\*$" -;; "\\*Async Shell Command\\*" -;; ;; "\\*scratch\\*" -;; help-mode -;; compilation-mode)) -;; (add-to-list 'display-buffer-alist -;; '(popper-display-control-p ; Predicate to match popper buffers -;; (display-buffer-in-side-window) -;; (side . bottom) -;; (slot . 0) -;; (window-height . 0.5))) ; Half the frame height -;; (popper-mode +1) -;; (popper-echo-mode +1)) - (provide 'wip) ;;; wip.el ends here. |
