diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-25 18:05:45 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-25 18:05:45 -0500 |
| commit | 9ed2af69be43d889f59ae1ca262af40405c481c5 (patch) | |
| tree | 911c44938776342b75821d0fc8304fa26d4e20e3 | |
| parent | 4828d59db798a0be5b6f3f1ccfd5c49dc4a6c92b (diff) | |
| download | dotemacs-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.el | 34 | ||||
| -rw-r--r-- | tests/test-mousetrap-mode--keymap-cache.el | 84 |
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 |
