From 8b08c0d730303c960f2b76169e376e9a83ce4fb8 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 8 Nov 2025 16:11:58 -0600 Subject: feat: Fix modeline lag and add org multi-level sort with comprehensive tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance improvement and new feature with full test coverage. ## Changes ### 1. Fix modeline line/column position lag (#A priority) - Replace expensive line-number-at-pos with cached %l/%c format specifiers - Enable line-number-mode explicitly for caching - Result: Instant modeline updates, zero performance overhead - Files: modules/modeline-config.el:81-83, modules/ui-config.el:53 ### 2. Implement multi-level org sorting - New function: cj/org-sort-by-todo-and-priority - Sorts by TODO status (TODO before DONE) AND priority (A→B→C→D) - Uses stable sorting: priority first, then TODO state - Gracefully handles empty sections (no error) - Bound to C-; o o (ordering → org sort) - Files: modules/org-config.el:278-299, modules/custom-ordering.el:253,267 ### 3. Comprehensive ERT test suite (12/12 passing) - Normal cases: Mixed TODO/DONE, multiple of same type, same priority - Boundary cases: Empty sections, single entries, no priorities - Error cases: Non-org-mode buffer - Test file: tests/test-org-sort-by-todo-and-priority.el ### 4. Testing improvements discovered - Disable org-mode hooks to avoid package dependencies in batch mode - org-sort-entries must be called from parent heading - Preserve priority cookie in org-get-heading (t t nil t) - Add condition-case to handle "Nothing to sort" gracefully ### 5. Minor cleanup - Comment out chime-debug setting (org-agenda-config.el:267) - Mark modeline lag task as DONE in todo.org ## Technical Details Modeline optimization: - line-number-at-pos is O(n) where n = current line - %l and %c are O(1) lookups from cached values Org sorting algorithm uses stable sort: 1. Sort by priority (A, B, C, D, unprioritized) 2. Sort by TODO status (preserves priority order within groups) Result: TODO [#A], TODO [#B], DONE [#A], DONE [#B], etc. --- assets/abbrev_defs | 7 +- modules/auth-config.el | 6 +- modules/custom-ordering.el | 6 +- modules/modeline-config.el | 5 +- modules/org-agenda-config.el | 2 +- modules/org-config.el | 24 +++ modules/org-gcal-config.el | 2 +- modules/ui-config.el | 3 +- modules/weather-config.el | 4 +- tests/test-org-sort-by-todo-and-priority.el | 283 ++++++++++++++++++++++++++++ 10 files changed, 330 insertions(+), 12 deletions(-) create mode 100644 tests/test-org-sort-by-todo-and-priority.el diff --git a/assets/abbrev_defs b/assets/abbrev_defs index cd9c6818d..8fc58efd3 100644 --- a/assets/abbrev_defs +++ b/assets/abbrev_defs @@ -132,7 +132,7 @@ ("customizaton" "customization" nil :count 0) ("dacquiri" "daiquiri" nil :count 0) ("daneel" "Danneel" nil :count 0) - ("danneel" "Danneel" nil :count 25) + ("danneel" "Danneel" nil :count 26) ("daquiri" "daiquiri" nil :count 0) ("decieve" "deceive" nil :count 0) ("decisons" "decisions" nil :count 0) @@ -294,7 +294,7 @@ ("oppositiion" "opposition" nil :count 0) ("opppsite" "opposite" nil :count 0) ("orignal" "original" nil :count 0) - ("ot" "to" nil :count 42) + ("ot" "to" nil :count 43) ("otehr" "other" nil :count 3) ("otes" "notes" nil :count 0) ("outgoign" "outgoing" nil :count 0) @@ -393,7 +393,7 @@ ("takss" "tasks" nil :count 3) ("talekd" "talked" nil :count 0) ("talkign" "talking" nil :count 6) - ("teh" "the" nil :count 156) + ("teh" "the" nil :count 159) ("tehir" "their" nil :count 5) ("tehre" "there" nil :count 3) ("testimentary" "testamentary" nil :count 1) @@ -425,6 +425,7 @@ ("vehical" "vehicle" nil :count 0) ("visious" "vicious" nil :count 0) ("waht" "what" nil :count 4) + ("walkthrough" "walk" nil :count 0) ("warant" "warrant" nil :count 0) ("welfair" "welfare" nil :count 0) ("welomce" "welcome" nil :count 0) diff --git a/modules/auth-config.el b/modules/auth-config.el index 2b52087ec..c3000f7f8 100644 --- a/modules/auth-config.el +++ b/modules/auth-config.el @@ -40,7 +40,11 @@ :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) + (setq epg-gpg-program "gpg2") ;; force use gpg2 (not gpg v.1) + + ;; Update gpg-agent with current DISPLAY environment + ;; This ensures pinentry can open GUI windows when Emacs starts + (call-process "gpg-connect-agent" nil nil nil "updatestartuptty" "/bye")) ;; ---------------------------------- Plstore ---------------------------------- ;; Encrypted storage used by oauth2-auto for Google Calendar tokens. diff --git a/modules/custom-ordering.el b/modules/custom-ordering.el index 7d906e75a..f69729103 100644 --- a/modules/custom-ordering.el +++ b/modules/custom-ordering.el @@ -249,7 +249,8 @@ Returns the transformed string without modifying the buffer." "r" #'cj/reverse-lines "n" #'cj/number-lines "A" #'cj/alphabetize-region - "L" #'cj/comma-separated-text-to-lines) + "L" #'cj/comma-separated-text-to-lines + "o" #'cj/org-sort-by-todo-and-priority) (keymap-set cj/custom-keymap "o" cj/ordering-map) (with-eval-after-load 'which-key @@ -262,7 +263,8 @@ Returns the transformed string without modifying the buffer." "C-; o r" "reverse lines" "C-; o n" "number lines" "C-; o A" "alphabetize" - "C-; o L" "comma to lines")) + "C-; o L" "comma to lines" + "C-; o o" "org: sort by TODO+priority")) (provide 'custom-ordering) ;;; custom-ordering.el ends here. diff --git a/modules/modeline-config.el b/modules/modeline-config.el index b14035398..a1c85caaa 100644 --- a/modules/modeline-config.el +++ b/modules/modeline-config.el @@ -78,8 +78,9 @@ Green = writeable, Red = read-only, Gold = overwrite. Truncates in narrow windows. Click to switch buffers.") (defvar-local cj/modeline-position - '(:eval (format "L:%d C:%d" (line-number-at-pos) (current-column))) - "Line and column position as L:line C:col.") + '("L:" (:eval (format-mode-line "%l")) " C:" (:eval (format-mode-line "%c"))) + "Line and column position as L:line C:col. +Uses built-in cached values for performance.") (defvar cj/modeline-vc-faces '((added . vc-locally-added-state) diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el index 61e542f6b..70ca9d4a8 100644 --- a/modules/org-agenda-config.el +++ b/modules/org-agenda-config.el @@ -264,7 +264,7 @@ This allows a line to show in an agenda without being scheduled or a deadline." :load-path "~/code/chime.el" :init ;; Debug mode (keep set to nil, but available for troubleshooting) - (setq chime-debug nil) + ;; (setq chime-debug nil) :bind ("C-c A" . chime-check) :config diff --git a/modules/org-config.el b/modules/org-config.el index 75d4c7db1..5cae1d0e7 100644 --- a/modules/org-config.el +++ b/modules/org-config.el @@ -225,6 +225,7 @@ (use-package org-appear :hook (org-mode . org-appear-mode) + :disabled t :custom (org-appear-autoemphasis t) ;; Show * / _ when cursor is on them (org-appear-autolinks t) ;; Also works for links @@ -274,6 +275,29 @@ 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")))) +;; ----------------------- Org Multi-Level Sorting ----------------------------- + +(defun cj/org-sort-by-todo-and-priority () + "Sort org entries by TODO status (TODO before DONE) and priority (A to D). +Sorts the current level's entries. Within each TODO state group, entries are +sorted by priority. Uses stable sorting: sort by priority first, then by TODO +status to preserve priority ordering within TODO groups." + (interactive) + (unless (derived-mode-p 'org-mode) + (user-error "Current buffer is not in org-mode")) + (save-excursion + ;; First sort by priority (A, B, C, D, then no priority) + ;; Ignore "Nothing to sort" errors for empty sections + (condition-case nil + (org-sort-entries nil ?p) + (user-error nil)) + ;; Then sort by TODO status (TODO before DONE) + ;; This preserves the priority ordering within each TODO group + (condition-case nil + (org-sort-entries nil ?o) + (user-error nil))) + (message "Sorted entries by TODO status and priority")) + ;; which-key labels for org-table-map (with-eval-after-load 'which-key (which-key-add-key-based-replacements diff --git a/modules/org-gcal-config.el b/modules/org-gcal-config.el index 97e8446af..4eca5e7ea 100644 --- a/modules/org-gcal-config.el +++ b/modules/org-gcal-config.el @@ -190,7 +190,7 @@ Useful after changing `cj/org-gcal-sync-interval-minutes'." ;; Start automatic sync timer based on user configuration ;; Set cj/org-gcal-sync-interval-minutes to nil to disable -(cj/org-gcal-start-auto-sync) +;; (cj/org-gcal-start-auto-sync) ;; Google Calendar keymap and keybindings (defvar-keymap cj/gcal-map diff --git a/modules/ui-config.el b/modules/ui-config.el index 837d2169f..3922ce2a0 100644 --- a/modules/ui-config.el +++ b/modules/ui-config.el @@ -50,7 +50,8 @@ (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 +(line-number-mode 1) ;; show line number in the modeline (cached) +(column-number-mode 1) ;; show column number in the modeline (cached) (setq switch-to-buffer-obey-display-actions t) ;; manual buffer switching obeys display action rules ;; -------------------------------- Transparency ------------------------------- diff --git a/modules/weather-config.el b/modules/weather-config.el index 3a30aa17b..82589af00 100644 --- a/modules/weather-config.el +++ b/modules/weather-config.el @@ -14,7 +14,8 @@ (add-to-list 'load-path "/home/cjennings/code/wttrin") ;; Set debug flag BEFORE loading wttrin (checked at load time) -(setq wttrin-debug nil) +;; Change this to t to enable debug logging +(setq wttrin-debug t) (use-package wttrin ;; Uncomment the next line to use vc-install instead of local directory: @@ -27,6 +28,7 @@ :bind ("M-W" . wttrin) :custom + ;; wttrin-debug must be set BEFORE loading (see line 17 above) (wttrin-unit-system "u") (wttrin-mode-line-favorite-location "New Orleans, LA") (wttrin-mode-line-refresh-interval 900) ; 15 minutes diff --git a/tests/test-org-sort-by-todo-and-priority.el b/tests/test-org-sort-by-todo-and-priority.el new file mode 100644 index 000000000..873f37c27 --- /dev/null +++ b/tests/test-org-sort-by-todo-and-priority.el @@ -0,0 +1,283 @@ +;;; test-org-sort-by-todo-and-priority.el --- Tests for cj/org-sort-by-todo-and-priority -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Unit tests for cj/org-sort-by-todo-and-priority function. +;; Tests multi-level sorting: TODO status (TODO before DONE) and priority (A before B before C). +;; +;; Testing approach: +;; - Use real org-mode buffers (don't mock org-sort-entries) +;; - Trust org-mode framework works correctly +;; - Test OUR integration logic: calling org-sort-entries twice in correct order +;; - Verify final sort order matches expected TODO/priority combination +;; +;; The function uses stable sorting: +;; 1. First sort by priority (A, B, C, D, none) +;; 2. Then sort by TODO status (TODO before DONE) +;; Result: Priority order preserved within each TODO state group + +;;; Code: + +(require 'ert) +(require 'org) +(require 'org-config) ; Defines cj/org-sort-by-todo-and-priority + +;;; Test Helpers + +(defun test-org-sort-by-todo-and-priority--create-buffer (content) + "Create a temporary org-mode buffer with CONTENT. +Returns the buffer object. +Disables org-mode hooks to avoid missing package dependencies in batch mode." + (let ((buf (generate-new-buffer "*test-org-sort*"))) + (with-current-buffer buf + ;; Disable hooks to prevent org-superstar and other package loads + (let ((org-mode-hook nil)) + (org-mode)) + (insert content) + (goto-char (point-min))) + buf)) + +(defun test-org-sort-by-todo-and-priority--get-entry-order (buffer) + "Extract ordered list of TODO states and priorities from BUFFER. +Returns list of strings like \"TODO [#A]\" or \"DONE\" for each heading." + (with-current-buffer buffer + (goto-char (point-min)) + (let (entries) + (org-map-entries + (lambda () + (let* ((todo-state (org-get-todo-state)) + ;; Get heading: no-tags, no-todo, KEEP priority, no-comment + (heading (org-get-heading t t nil t)) + ;; Extract priority cookie from heading text + (priority (when (string-match "\\[#\\([A-Z]\\)\\]" heading) + (match-string 1 heading)))) + (push (if priority + (format "%s [#%s]" (or todo-state "") priority) + (or todo-state "")) + entries))) + nil 'tree) + (nreverse entries)))) + +(defun test-org-sort-by-todo-and-priority--sort-children (buffer) + "Position cursor on parent heading in BUFFER and sort its children. +Moves to first * heading (Parent) and calls sort function to sort children." + (with-current-buffer buffer + (goto-char (point-min)) + (when (re-search-forward "^\\* " nil t) + (beginning-of-line) + (cj/org-sort-by-todo-and-priority)))) + +;;; Normal Cases + +(ert-deftest test-org-sort-by-todo-and-priority-normal-mixed-todo-done-sorts-correctly () + "Test mixed TODO and DONE entries with various priorities sort correctly. + +Input: TODO [#A], DONE [#B], TODO [#C], DONE [#A] +Expected: TODO [#A], TODO [#C], DONE [#A], DONE [#B]" + (let* ((content "* Parent +** TODO [#A] First task +** DONE [#B] Second task +** TODO [#C] Third task +** DONE [#A] Fourth task +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]" "TODO [#C]" "DONE [#A]" "DONE [#B]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-normal-multiple-todos-sorts-by-priority () + "Test multiple TODO entries sort by priority A before B before C. + +Input: TODO [#C], TODO [#A], TODO [#B] +Expected: TODO [#A], TODO [#B], TODO [#C]" + (let* ((content "* Parent +** TODO [#C] Task C +** TODO [#A] Task A +** TODO [#B] Task B +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]" "TODO [#B]" "TODO [#C]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-normal-multiple-dones-sorts-by-priority () + "Test multiple DONE entries sort by priority A before B before C. + +Input: DONE [#C], DONE [#A], DONE [#B] +Expected: DONE [#A], DONE [#B], DONE [#C]" + (let* ((content "* Parent +** DONE [#C] Done C +** DONE [#A] Done A +** DONE [#B] Done B +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "DONE [#A]" "DONE [#B]" "DONE [#C]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-normal-same-priority-todo-before-done () + "Test entries with same priority sort TODO before DONE. + +Input: DONE [#A], TODO [#A] +Expected: TODO [#A], DONE [#A]" + (let* ((content "* Parent +** DONE [#A] Done task +** TODO [#A] Todo task +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]" "DONE [#A]"))))) + (kill-buffer buf)))) + +;;; Boundary Cases + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-empty-section-no-error () + "Test sorting empty section does not signal error. + +Input: Heading with no children +Expected: No error, no change" + (let* ((content "* Parent\n") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (with-current-buffer buf + (goto-char (point-min)) + (should-not (condition-case err + (progn + (cj/org-sort-by-todo-and-priority) + nil) + (error err)))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-single-todo-no-change () + "Test sorting single TODO entry does not change order. + +Input: Single TODO [#A] +Expected: Same order (no change)" + (let* ((content "* Parent +** TODO [#A] Only task +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-single-done-no-change () + "Test sorting single DONE entry does not change order. + +Input: Single DONE [#B] +Expected: Same order (no change)" + (let* ((content "* Parent +** DONE [#B] Only task +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "DONE [#B]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-all-todos-sorts-by-priority () + "Test all TODO entries sort by priority only. + +Input: TODO [#C], TODO [#A], TODO [#B] +Expected: TODO [#A], TODO [#B], TODO [#C]" + (let* ((content "* Parent +** TODO [#C] Task C +** TODO [#A] Task A +** TODO [#B] Task B +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]" "TODO [#B]" "TODO [#C]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-all-dones-sorts-by-priority () + "Test all DONE entries sort by priority only. + +Input: DONE [#B], DONE [#D], DONE [#A] +Expected: DONE [#A], DONE [#B], DONE [#D]" + (let* ((content "* Parent +** DONE [#B] Done B +** DONE [#D] Done D +** DONE [#A] Done A +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "DONE [#A]" "DONE [#B]" "DONE [#D]"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-no-priorities-sorts-by-todo () + "Test entries without priorities sort by TODO status only. + +Input: TODO (no priority), DONE (no priority), TODO (no priority) +Expected: TODO, TODO, DONE" + (let* ((content "* Parent +** TODO Task 1 +** DONE Task 2 +** TODO Task 3 +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO" "TODO" "DONE"))))) + (kill-buffer buf)))) + +(ert-deftest test-org-sort-by-todo-and-priority-boundary-unprioritized-after-prioritized () + "Test unprioritized entries appear after prioritized within TODO/DONE groups. + +Input: TODO (no priority), TODO [#A], DONE [#B], DONE (no priority) +Expected: TODO [#A], TODO (no priority), DONE [#B], DONE (no priority)" + (let* ((content "* Parent +** TODO Task no priority +** TODO [#A] Task A +** DONE [#B] Done B +** DONE Done no priority +") + (buf (test-org-sort-by-todo-and-priority--create-buffer content))) + (unwind-protect + (progn + (test-org-sort-by-todo-and-priority--sort-children buf) + (let ((order (test-org-sort-by-todo-and-priority--get-entry-order buf))) + (should (equal order '("" "TODO [#A]" "TODO" "DONE [#B]" "DONE"))))) + (kill-buffer buf)))) + +;;; Error Cases + +(ert-deftest test-org-sort-by-todo-and-priority-error-non-org-buffer-signals-error () + "Test calling in non-org-mode buffer signals user-error. + +Input: fundamental-mode buffer +Expected: user-error" + (let ((buf (generate-new-buffer "*test-non-org*"))) + (unwind-protect + (with-current-buffer buf + (fundamental-mode) + (should-error (cj/org-sort-by-todo-and-priority) :type 'user-error)) + (kill-buffer buf)))) + +(provide 'test-org-sort-by-todo-and-priority) +;;; test-org-sort-by-todo-and-priority.el ends here -- cgit v1.2.3