aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-25 18:05:45 -0500
committerCraig Jennings <c@cjennings.net>2026-05-25 18:05:45 -0500
commit9ed2af69be43d889f59ae1ca262af40405c481c5 (patch)
tree911c44938776342b75821d0fc8304fa26d4e20e3
parent4828d59db798a0be5b6f3f1ccfd5c49dc4a6c92b (diff)
downloaddotemacs-9ed2af69be43d889f59ae1ca262af40405c481c5.tar.gz
dotemacs-9ed2af69be43d889f59ae1ca262af40405c481c5.zip
perf(mousetrap): cache built keymaps per profile
mouse-trap--build-keymap ran on every major-mode hook and rebuilt the whole keymap (~8 prefixes by ~30 events) from scratch each time, so rapid mode-switching paid that cost over and over. I moved the build into mouse-trap--build-keymap-1 and cache its result in mouse-trap--keymap-cache, keyed on the profile name plus its allowed-categories list. The same profile reuses the cached keymap, and editing a profile's categories changes the key so it rebuilds. Sharing one keymap object across buffers is safe here: the map only binds disallowed events to ignore and is never mutated after it's built. Added mouse-trap--clear-keymap-cache to force a fresh build after editing profiles by hand.
-rw-r--r--modules/mousetrap-mode.el34
-rw-r--r--tests/test-mousetrap-mode--keymap-cache.el84
2 files changed, 116 insertions, 2 deletions
diff --git a/modules/mousetrap-mode.el b/modules/mousetrap-mode.el
index 5da66780..4444716c 100644
--- a/modules/mousetrap-mode.el
+++ b/modules/mousetrap-mode.el
@@ -92,6 +92,27 @@ When checking, the mode hierarchy is respected via `derived-mode-p'.")
;;; Keymap Builder
+(defvar mouse-trap--keymap-cache nil
+ "Cache of built keymaps, keyed by profile.
+
+An alist mapping `(profile-name . allowed-categories)' keys to the
+keymap object `mouse-trap--build-keymap' produced for that profile.
+Repeated major-mode switches reuse a cached keymap instead of
+rebuilding it. The key includes the allowed-categories list so that
+editing a profile's categories at runtime changes the key and forces a
+rebuild. Sharing one keymap object across buffers with the same
+profile is safe: the map is a read-only \"bind disallowed events to
+`ignore'\" map that is never mutated after it is built.")
+
+(defun mouse-trap--clear-keymap-cache ()
+ "Clear the cached profile keymaps.
+
+Call this after editing `mouse-trap-profiles' or
+`mouse-trap--event-categories' if you want the next keymap build to
+start fresh rather than reuse a previously cached map."
+ (interactive)
+ (setq mouse-trap--keymap-cache nil))
+
(defun mouse-trap--get-profile-for-mode ()
"Return the profile for the current major mode.
@@ -116,8 +137,17 @@ NOT allowed by the current profile. This function is called each time
the mode is toggled, allowing dynamic behavior without reloading config."
(let* ((profile-name (mouse-trap--get-profile-for-mode))
(allowed-categories (alist-get profile-name mouse-trap-profiles))
- (prefixes '("" "C-" "M-" "S-" "C-M-" "C-S-" "M-S-" "C-M-S-"))
- (map (make-sparse-keymap)))
+ (cache-key (cons profile-name allowed-categories))
+ (cached (cdr (assoc cache-key mouse-trap--keymap-cache))))
+ (or cached
+ (let ((map (mouse-trap--build-keymap-1 allowed-categories)))
+ (push (cons cache-key map) mouse-trap--keymap-cache)
+ map))))
+
+(defun mouse-trap--build-keymap-1 (allowed-categories)
+ "Build a fresh keymap binding events not in ALLOWED-CATEGORIES to `ignore'."
+ (let ((prefixes '("" "C-" "M-" "S-" "C-M-" "C-S-" "M-S-" "C-M-S-"))
+ (map (make-sparse-keymap)))
;; For each event category, disable it if not in allowed list
(dolist (category-entry mouse-trap--event-categories)
diff --git a/tests/test-mousetrap-mode--keymap-cache.el b/tests/test-mousetrap-mode--keymap-cache.el
new file mode 100644
index 00000000..09cb5e5c
--- /dev/null
+++ b/tests/test-mousetrap-mode--keymap-cache.el
@@ -0,0 +1,84 @@
+;;; test-mousetrap-mode--keymap-cache.el --- Tests for keymap caching -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for the per-profile keymap cache in mouse-trap--build-keymap.
+;; Repeated builds for the same profile should reuse one keymap object;
+;; distinct profiles get distinct objects; clearing the cache forces a rebuild.
+
+;;; Code:
+
+(require 'ert)
+(require 'mousetrap-mode)
+
+;; Each test starts from a clean cache so results don't depend on order.
+(defun test-mousetrap--with-clear-cache (thunk)
+ "Run THUNK with the keymap cache cleared before and after."
+ (mouse-trap--clear-keymap-cache)
+ (unwind-protect (funcall thunk)
+ (mouse-trap--clear-keymap-cache)))
+
+;;; Cache-hit cases
+
+(ert-deftest test-mousetrap-mode--keymap-cache-same-profile-returns-same-object ()
+ "Normal: two builds for the same profile return the same keymap (eq)."
+ (test-mousetrap--with-clear-cache
+ (lambda ()
+ (let ((major-mode 'test-mode)
+ (mouse-trap-mode-profiles '((test-mode . disabled))))
+ (let ((first (mouse-trap--build-keymap))
+ (second (mouse-trap--build-keymap)))
+ (should (eq first second)))))))
+
+(ert-deftest test-mousetrap-mode--keymap-cache-different-profiles-differ ()
+ "Normal: distinct profiles return distinct keymap objects."
+ (test-mousetrap--with-clear-cache
+ (lambda ()
+ (let* ((mouse-trap-mode-profiles '((mode-a . disabled)
+ (mode-b . full)))
+ (map-a (let ((major-mode 'mode-a)) (mouse-trap--build-keymap)))
+ (map-b (let ((major-mode 'mode-b)) (mouse-trap--build-keymap))))
+ (should-not (eq map-a map-b))))))
+
+;;; Cache-clear case
+
+(ert-deftest test-mousetrap-mode--keymap-cache-clear-forces-fresh-object ()
+ "Boundary: after clearing the cache, a rebuild yields a fresh object."
+ (test-mousetrap--with-clear-cache
+ (lambda ()
+ (let ((major-mode 'test-mode)
+ (mouse-trap-mode-profiles '((test-mode . disabled))))
+ (let ((first (mouse-trap--build-keymap)))
+ (mouse-trap--clear-keymap-cache)
+ (let ((second (mouse-trap--build-keymap)))
+ (should-not (eq first second))))))))
+
+(ert-deftest test-mousetrap-mode--keymap-cache-edited-categories-rebuild ()
+ "Boundary: editing a profile's category list at runtime changes the key,
+forcing a rebuild rather than returning the stale cached keymap."
+ (test-mousetrap--with-clear-cache
+ (lambda ()
+ (let ((major-mode 'test-mode)
+ (mouse-trap-mode-profiles '((test-mode . tweakable))))
+ (let* ((mouse-trap-profiles '((tweakable . (scroll))))
+ (first (mouse-trap--build-keymap)))
+ (let* ((mouse-trap-profiles '((tweakable . (scroll primary-click))))
+ (second (mouse-trap--build-keymap)))
+ (should-not (eq first second))
+ ;; Behavior reflects the edited categories.
+ (should (eq (lookup-key second (kbd "<mouse-1>")) nil))))))))
+
+;;; Behavior-preservation sanity
+
+(ert-deftest test-mousetrap-mode--keymap-cache-preserves-ignore-binding ()
+ "Normal: a cached keymap still binds a disallowed event to `ignore'."
+ (test-mousetrap--with-clear-cache
+ (lambda ()
+ (let ((major-mode 'test-mode)
+ (mouse-trap-mode-profiles '((test-mode . disabled))))
+ ;; Build twice; the cached object must still block the event.
+ (mouse-trap--build-keymap)
+ (let ((map (mouse-trap--build-keymap)))
+ (should (eq (lookup-key map (kbd "<mouse-1>")) 'ignore)))))))
+
+(provide 'test-mousetrap-mode--keymap-cache)
+;;; test-mousetrap-mode--keymap-cache.el ends here