From 5c160bd5f33b0e27ecac32af99f650ea50d844fe Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 22 May 2026 19:00:36 -0500 Subject: refactor(dashboard): derive the navigator and keybindings from one launcher table The 12 dashboard launchers were inlined twice (once as navigator icon buttons, once as dashboard-mode-map keybindings), so adding or reordering one meant editing both lists, and the icon-row order could drift from the key order. I pulled them into a single cj/dashboard--launchers table of (KEY ICON-FN ICON-NAME LABEL TOOLTIP ACTION) tuples. cj/dashboard--navigator-rows chunks it four per row into the navigator buttons, and cj/dashboard--bind-launchers binds each key to its action. The icons and the keys now come from one place, with no behavior change: same icons, labels, order, and keys, locked by tests. --- modules/dashboard-config.el | 131 ++++++++++++------------------- tests/test-dashboard-config-launchers.el | 97 +++++++++++++++++++++++ 2 files changed, 147 insertions(+), 81 deletions(-) create mode 100644 tests/test-dashboard-config-launchers.el diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el index 803c249d..74c32bd5 100644 --- a/modules/dashboard-config.el +++ b/modules/dashboard-config.el @@ -56,6 +56,52 @@ Positive values shift left, negative values shift right. Adjust this if the title doesn't appear centered under the banner image.") +;; --------------------------- Launcher Definitions ---------------------------- +;; Single source of truth for the dashboard launchers. Both the navigator +;; icon rows and the dashboard-mode-map keybindings derive from this table, so +;; a launcher is added or reordered in exactly one place (no icon-row/keymap +;; drift). Each entry: (KEY ICON-FN ICON-NAME LABEL TOOLTIP ACTION); ACTION is +;; a zero-argument function run by both the icon button and the key. + +(defconst cj/dashboard--launchers + (list + (list "c" #'nerd-icons-faicon "nf-fa-code" "Code" "Switch Project" (lambda () (projectile-switch-project))) + (list "d" #'nerd-icons-faicon "nf-fa-folder_o" "Files" "Dirvish File Manager" (lambda () (dirvish user-home-dir))) + (list "t" #'nerd-icons-devicon "nf-dev-terminal" "Terminal" "Launch VTerm" (lambda () (vterm))) + (list "a" #'nerd-icons-mdicon "nf-md-calendar" "Agenda" "Main Org Agenda" (lambda () (cj/main-agenda-display))) + (list "r" #'nerd-icons-faicon "nf-fa-rss_square" "Feeds" "Elfeed Feed Reader" (lambda () (cj/elfeed-open))) + (list "b" #'nerd-icons-faicon "nf-fae-book_open_o" "Books" "Calibre Ebook Reader" (lambda () (calibredb))) + (list "f" #'nerd-icons-mdicon "nf-md-school" "Flashcards" "Org-Drill" (lambda () (cj/drill-start))) + (list "m" #'nerd-icons-mdicon "nf-md-music" "Music" "EMMS Music Player" (lambda () (cj/music-playlist-toggle) (cj/music-playlist-load))) + (list "e" #'nerd-icons-faicon "nf-fa-envelope" "Email" "Mu4e Email Client" (lambda () (mu4e))) + (list "i" #'nerd-icons-faicon "nf-fa-comments" "IRC" "Emacs Relay Chat" (lambda () (cj/erc-switch-to-buffer-with-completion))) + (list "s" #'nerd-icons-faicon "nf-fa-slack" "Slack" "Slack Client" (lambda () (cj/slack-start))) + (list "g" #'nerd-icons-faicon "nf-fa-telegram" "Telegram" "Telega Telegram Client" (lambda () (cj/telega)))) + "Dashboard launcher table: (KEY ICON-FN ICON-NAME LABEL TOOLTIP ACTION). +Drives both `dashboard-navigator-buttons' and the dashboard-mode-map keys.") + +(defun cj/dashboard--navigator-rows () + "Build `dashboard-navigator-buttons' rows from `cj/dashboard--launchers'. +Chunks the launchers four per row and maps each to a navigator button." + (let (rows row) + (dolist (l cj/dashboard--launchers) + (let ((icon-fn (nth 1 l)) (icon-name (nth 2 l)) + (label (nth 3 l)) (tooltip (nth 4 l)) (action (nth 5 l))) + (push (list (funcall icon-fn icon-name) label tooltip + (lambda (&rest _) (funcall action)) nil " " "") + row)) + (when (= (length row) 4) + (push (nreverse row) rows) + (setq row nil))) + (when row (push (nreverse row) rows)) + (nreverse rows))) + +(defun cj/dashboard--bind-launchers (map) + "Bind each launcher KEY in MAP to a command that runs its ACTION." + (dolist (l cj/dashboard--launchers) + (let ((key (nth 0 l)) (action (nth 5 l))) + (define-key map (kbd key) (lambda () (interactive) (funcall action)))))) + ;; ----------------------------- Display Dashboard ----------------------------- ;; convenience function to redisplay dashboard and kill all other windows @@ -124,69 +170,7 @@ window." ;; == navigation (setq dashboard-set-navigator t) - (setq dashboard-navigator-buttons - `(;; Row 1 — Work tools - ((,(nerd-icons-faicon "nf-fa-code") - "Code" "Switch Project" - (lambda (&rest _) (projectile-switch-project)) - nil " " "") - - (,(nerd-icons-faicon "nf-fa-folder_o") - "Files" "Dirvish File Manager" - (lambda (&rest _) (dirvish user-home-dir)) - nil " " "") - - (,(nerd-icons-devicon "nf-dev-terminal") - "Terminal" "Launch VTerm" - (lambda (&rest _) (vterm)) - nil " " "") - - (,(nerd-icons-mdicon "nf-md-calendar") - "Agenda" "Main Org Agenda" - (lambda (&rest _) (cj/main-agenda-display)) - nil " " "")) - - ;; Row 2 — Read & Learn - ((,(nerd-icons-faicon "nf-fa-rss_square") - "Feeds" "Elfeed Feed Reader" - (lambda (&rest _) (cj/elfeed-open)) - nil " " "") - - (,(nerd-icons-faicon "nf-fae-book_open_o") - "Books" "Calibre Ebook Reader" - (lambda (&rest _) (calibredb)) - nil " " "") - - (,(nerd-icons-mdicon "nf-md-school") - "Flashcards" "Org-Drill" - (lambda (&rest _) (cj/drill-start)) - nil " " "") - - (,(nerd-icons-mdicon "nf-md-music") - "Music" "EMMS Music Player" - (lambda (&rest _) (cj/music-playlist-toggle) (cj/music-playlist-load)) - nil " " "")) - - ;; Row 3 — Communication - ((,(nerd-icons-faicon "nf-fa-envelope") - "Email" "Mu4e Email Client" - (lambda (&rest _) (mu4e)) - nil " " "") - - (,(nerd-icons-faicon "nf-fa-comments") - "IRC" "Emacs Relay Chat" - (lambda (&rest _) (cj/erc-switch-to-buffer-with-completion)) - nil " " "") - - (,(nerd-icons-faicon "nf-fa-slack") - "Slack" "Slack Client" - (lambda (&rest _) (cj/slack-start)) - nil " " "") - - (,(nerd-icons-faicon "nf-fa-telegram") - "Telegram" "Telega Telegram Client" - (lambda (&rest _) (cj/telega)) - nil " " "")))) + (setq dashboard-navigator-buttons (cj/dashboard--navigator-rows)) ;; == content (setq dashboard-show-shortcuts nil) ;; don't show dashboard item abbreviations @@ -198,24 +182,9 @@ window." ;; Disable 'q' to quit dashboard (define-key dashboard-mode-map (kbd "q") nil) - ;; Dashboard launcher keybindings, ordered to mirror the icon rows. - ;; Row 1 — Work tools - (define-key dashboard-mode-map (kbd "c") (lambda () (interactive) (projectile-switch-project))) - (define-key dashboard-mode-map (kbd "d") (lambda () (interactive) (dirvish user-home-dir))) - (define-key dashboard-mode-map (kbd "t") (lambda () (interactive) (vterm))) - (define-key dashboard-mode-map (kbd "a") (lambda () (interactive) (cj/main-agenda-display))) - ;; Row 2 — Read & Learn - (define-key dashboard-mode-map (kbd "r") (lambda () (interactive) (cj/elfeed-open))) - (define-key dashboard-mode-map (kbd "b") (lambda () (interactive) (calibredb))) - (define-key dashboard-mode-map (kbd "f") (lambda () (interactive) (cj/drill-start))) - (define-key dashboard-mode-map (kbd "m") (lambda () (interactive) - (cj/music-playlist-toggle) - (cj/music-playlist-load))) - ;; Row 3 — Communication - (define-key dashboard-mode-map (kbd "e") (lambda () (interactive) (mu4e))) - (define-key dashboard-mode-map (kbd "i") (lambda () (interactive) (cj/erc-switch-to-buffer-with-completion))) - (define-key dashboard-mode-map (kbd "s") (lambda () (interactive) (cj/slack-start))) - (define-key dashboard-mode-map (kbd "g") (lambda () (interactive) (cj/telega)))) + ;; Launcher keys, derived from `cj/dashboard--launchers' (same source as the + ;; navigator icons, so key order can't drift from the icon-row order). + (cj/dashboard--bind-launchers dashboard-mode-map)) ;; Override banner title centering (must be after dashboard-widgets loads) (with-eval-after-load 'dashboard-widgets diff --git a/tests/test-dashboard-config-launchers.el b/tests/test-dashboard-config-launchers.el new file mode 100644 index 00000000..633d6612 --- /dev/null +++ b/tests/test-dashboard-config-launchers.el @@ -0,0 +1,97 @@ +;;; test-dashboard-config-launchers.el --- Tests for the dashboard launcher table -*- lexical-binding: t; -*- + +;;; Commentary: +;; Locks the single-source launcher table that drives both the dashboard +;; navigator icon rows and the dashboard-mode-map keybindings. The bug this +;; guards against: the icon row and the keymap were maintained as two separate +;; inline lists that could drift in order or wiring. These tests assert the +;; table, the derived 3x4 navigator rows, and that every key binds to the +;; right command (including Music's two-call action). + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(setq load-prefer-newer t) + +;; deps dashboard-config pulls transitively / actions reference +(unless (fboundp 'cj/kill-all-other-buffers-and-windows) + (defun cj/kill-all-other-buffers-and-windows () nil)) +(unless (fboundp 'cj/make-buffer-undead) (defun cj/make-buffer-undead (_n) nil)) +(defvar user-home-dir "/tmp/") + +(require 'dashboard-config) + +(defconst test-dash--keys '("c" "d" "t" "a" "r" "b" "f" "m" "e" "i" "s" "g")) + +;; ----------------------------- launcher table -------------------------------- + +(ert-deftest test-dashboard-launchers-keys-in-order () + "Normal: 12 launchers with the expected keys in display order." + (should (= 12 (length cj/dashboard--launchers))) + (should (equal test-dash--keys (mapcar (lambda (l) (nth 0 l)) cj/dashboard--launchers)))) + +(ert-deftest test-dashboard-launchers-labels-in-order () + "Normal: labels in display order." + (should (equal '("Code" "Files" "Terminal" "Agenda" "Feeds" "Books" + "Flashcards" "Music" "Email" "IRC" "Slack" "Telegram") + (mapcar (lambda (l) (nth 3 l)) cj/dashboard--launchers)))) + +;; --------------------------- navigator rows ---------------------------------- + +(ert-deftest test-dashboard-navigator-rows-three-rows-of-four () + "Normal: navigator derives 3 rows of 4, with the right labels and button shape." + (cl-letf (((symbol-function 'nerd-icons-faicon) (lambda (n &rest _) (concat "I:" n))) + ((symbol-function 'nerd-icons-devicon) (lambda (n &rest _) (concat "I:" n))) + ((symbol-function 'nerd-icons-mdicon) (lambda (n &rest _) (concat "I:" n)))) + (let ((rows (cj/dashboard--navigator-rows))) + (should (= 3 (length rows))) + (should (cl-every (lambda (r) (= 4 (length r))) rows)) + (should (equal '("Code" "Files" "Terminal" "Agenda") + (mapcar (lambda (b) (nth 1 b)) (nth 0 rows)))) + (let ((btn (car (car rows)))) ; (icon label tooltip action nil " " "") + (should (string= "I:nf-fa-code" (nth 0 btn))) + (should (string= "Code" (nth 1 btn))) + (should (functionp (nth 3 btn))) + (should (null (nth 4 btn))))))) + +;; ---------------------------- keymap binding --------------------------------- + +(ert-deftest test-dashboard-bind-launchers-binds-every-key () + "Normal: every launcher key binds to a command; q is left to the caller." + (let ((map (make-sparse-keymap))) + (cj/dashboard--bind-launchers map) + (dolist (key test-dash--keys) + (should (commandp (keymap-lookup map key)))) + (should-not (keymap-lookup map "q")))) + +(ert-deftest test-dashboard-bind-launchers-each-key-runs-its-command () + "Behavior: invoking each key runs its launcher's command(s) — Music runs two." + (let ((map (make-sparse-keymap)) (calls nil)) + (cl-letf (((symbol-function 'projectile-switch-project) (lambda (&rest _) (push 'code calls))) + ((symbol-function 'dirvish) (lambda (&rest _) (push 'files calls))) + ((symbol-function 'vterm) (lambda (&rest _) (push 'term calls))) + ((symbol-function 'cj/main-agenda-display) (lambda (&rest _) (push 'agenda calls))) + ((symbol-function 'cj/elfeed-open) (lambda (&rest _) (push 'feeds calls))) + ((symbol-function 'calibredb) (lambda (&rest _) (push 'books calls))) + ((symbol-function 'cj/drill-start) (lambda (&rest _) (push 'cards calls))) + ((symbol-function 'cj/music-playlist-toggle) (lambda (&rest _) (push 'm-toggle calls))) + ((symbol-function 'cj/music-playlist-load) (lambda (&rest _) (push 'm-load calls))) + ((symbol-function 'mu4e) (lambda (&rest _) (push 'email calls))) + ((symbol-function 'cj/erc-switch-to-buffer-with-completion) (lambda (&rest _) (push 'irc calls))) + ((symbol-function 'cj/slack-start) (lambda (&rest _) (push 'slack calls))) + ((symbol-function 'cj/telega) (lambda (&rest _) (push 'tg calls)))) + (cj/dashboard--bind-launchers map) + (dolist (key test-dash--keys) + (call-interactively (keymap-lookup map key))) + (should (memq 'code calls)) + (should (memq 'tg calls)) + (should (memq 'm-toggle calls)) + (should (memq 'm-load calls)) + (should (= 13 (length calls)))))) ; 12 keys, Music fires two + +(provide 'test-dashboard-config-launchers) +;;; test-dashboard-config-launchers.el ends here -- cgit v1.2.3