diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-10 03:19:03 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-10 03:19:03 -0500 |
| commit | 9712c2e122bd6923298910fcb53b33ca675ddd82 (patch) | |
| tree | 8c31197d5e5ab17d71f654d0107ce4746602ddb0 | |
| parent | b4f2b1d7d18f9246b06baf1e573b2cd990af83c8 (diff) | |
| download | dotemacs-9712c2e122bd6923298910fcb53b33ca675ddd82.tar.gz dotemacs-9712c2e122bd6923298910fcb53b33ca675ddd82.zip | |
refactor: extract toggle-state helpers shared by F9 and F12
The F12 commit (554b32d) flagged this as a follow-up: ~120 lines of capture-state and display-saved logic were duplicated between modules/ai-vterm.el and modules/eshell-vterm-config.el. The only differences were the default direction (right for F9, below for F12) and the customization name for the fallback size. Extract the shared logic into modules/cj-window-toggle.el so both consumers reduce to thin delegates that pass their state-var symbols and defaults. The state vars stay where they were, so existing tests against each consumer's helpers keep working.
10 new tests cover the parameterized helpers in isolation. All consumer tests still pass.
| -rw-r--r-- | modules/ai-vterm.el | 72 | ||||
| -rw-r--r-- | modules/cj-window-toggle.el | 85 | ||||
| -rw-r--r-- | modules/eshell-vterm-config.el | 48 | ||||
| -rw-r--r-- | tests/test-cj-window-toggle.el | 188 |
4 files changed, 297 insertions, 96 deletions
diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el index e817f441..482f6522 100644 --- a/modules/ai-vterm.el +++ b/modules/ai-vterm.el @@ -37,6 +37,7 @@ (require 'cl-lib) (require 'seq) (require 'cj-window-geometry) +(require 'cj-window-toggle) (declare-function vterm "vterm" (&optional buffer-name)) (declare-function vterm-send-string "vterm" (string &optional paste-p)) @@ -235,15 +236,12 @@ cost of not auto-scaling if the frame itself resizes.") Sets `cj/--ai-vterm-last-direction' and `cj/--ai-vterm-last-size' so a subsequent F9 display can restore the user's chosen orientation and size. Called at toggle-off (just before the window is torn -down). The default direction is 'right -- the module's side-panel -default. - -Does nothing when WINDOW is not live." - (when (window-live-p window) - (let* ((dir (cj/window-direction window 'right)) - (size (cj/window-body-size window dir))) - (setq cj/--ai-vterm-last-direction dir - cj/--ai-vterm-last-size size)))) +down). The default direction is `right' -- the module's side-panel +default. Does nothing when WINDOW is not live." + (cj/window-toggle-capture-state + window 'right + 'cj/--ai-vterm-last-direction + 'cj/--ai-vterm-last-size)) (defun cj/--ai-vterm-reuse-existing-claude (buffer _alist) "Display-buffer action: reuse any window in this frame already showing @@ -268,56 +266,12 @@ project changes." (defun cj/--ai-vterm-display-saved (buffer alist) "Display-buffer action: split per saved direction and size. -Reads `cj/--ai-vterm-last-direction' and `cj/--ai-vterm-last-size' -(falling back to right and `cj/ai-vterm-window-width' when nil) and -delegates to `display-buffer-in-direction' with an alist that carries -the saved values. - -The captured cardinal direction (right/left/below/above) is mapped -to its frame-edge variant (rightmost/leftmost/bottom/top) so the new -claude always lands at the same frame edge it came from. This -means: the new window splits the frame's main window at the -matching edge, not whatever window happens to be selected when F9 -fires. Without this mapping, a toggle-off-on cycle in a 3+ window -layout would put claude into a middle position (right of the -selected window) rather than the edge it lived on before. As a -side benefit, claude always lands without a sibling on its -captured-edge side, so its body-width and total-width match -- no -divider chrome eating 1 col per toggle. - -An integer size (the captured absolute body-cols or body-lines) is -wrapped in a `(body-columns . N)' / `(body-lines . N)' cons so -`display-buffer-in-direction' sets the body width or body height -exactly. A float size (the customizable default fallback) passes -through as a fraction of the new window's parent. - -Any direction/window-width/window-height entries in ALIST are -stripped so the saved-state values control placement -- callers -shouldn't specify direction or size in the rule when this action is -used." - (let* ((direction (or cj/--ai-vterm-last-direction 'right)) - (edge-direction (or (cj/cardinal-to-edge-direction direction) - 'rightmost)) - (size (or cj/--ai-vterm-last-size cj/ai-vterm-window-width)) - (size-key (if (memq direction '(right left)) - 'window-width - 'window-height)) - (body-tag (if (memq direction '(right left)) - 'body-columns - 'body-lines)) - (size-value (if (integerp size) - (cons body-tag size) - size)) - (filtered (cl-remove-if - (lambda (cell) - (memq (car-safe cell) - '(direction window-width window-height))) - alist)) - (effective (append - (list (cons 'direction edge-direction) - (cons size-key size-value)) - filtered))) - (display-buffer-in-direction buffer effective))) +Delegates to `cj/window-toggle-display-saved' against the F9 state +vars, falling back to `right' and `cj/ai-vterm-window-width'." + (cj/window-toggle-display-saved + buffer alist + 'cj/--ai-vterm-last-direction 'right + 'cj/--ai-vterm-last-size cj/ai-vterm-window-width)) (defun cj/--ai-vterm-display-rule-list () "Return the `display-buffer-alist' entry list installed by this module. diff --git a/modules/cj-window-toggle.el b/modules/cj-window-toggle.el new file mode 100644 index 00000000..016b1967 --- /dev/null +++ b/modules/cj-window-toggle.el @@ -0,0 +1,85 @@ +;;; cj-window-toggle.el --- Shared toggle-state helpers for display-buffer dispatchers -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; Parameterized helpers used by ai-vterm.el (F9) and +;; eshell-vterm-config.el (F12) to capture a window's geometry at +;; toggle-off and replay it on the next toggle-on. Each consumer +;; holds its own pair of state variables (last-direction symbol + +;; last-size integer/float) and passes the variable symbols to the +;; helpers. Both helpers are pure with respect to their arguments; +;; the side effects are confined to the named state variables. +;; +;; Pulls the geometry primitives in from cj-window-geometry.el. + +;;; Code: + +(require 'cl-lib) +(require 'cj-window-geometry) + +(defun cj/window-toggle-capture-state (window default-direction + direction-var size-var) + "Write WINDOW's direction and body size into DIRECTION-VAR and SIZE-VAR. + +DEFAULT-DIRECTION is the symbol used by `cj/window-direction' when +WINDOW fills its frame's root area. DIRECTION-VAR and SIZE-VAR are +the symbols of the consumer's state variables; they receive the +captured values via `set'. + +No-op when WINDOW is nil or not live." + (when (window-live-p window) + (let* ((dir (cj/window-direction window default-direction)) + (size (cj/window-body-size window dir))) + (set direction-var dir) + (set size-var size)))) + +(defun cj/window-toggle-display-saved (buffer alist + direction-var default-direction + size-var default-size) + "Display-buffer action: split per saved DIRECTION-VAR and SIZE-VAR. + +Reads the consumer's stored direction and size through DIRECTION-VAR +and SIZE-VAR (symbols); falls back to DEFAULT-DIRECTION and +DEFAULT-SIZE when the stored values are nil. The cardinal direction +is mapped to its frame-edge variant via +`cj/cardinal-to-edge-direction' so the new buffer always lands at +the same frame edge regardless of the selected window. An integer +size is wrapped in a `(body-columns . N)' / `(body-lines . N)' cons +so `display-buffer-in-direction' sets the body explicitly, +divider-independent. A float size passes through as a fraction of +the new window's parent. + +Caller-supplied ALIST entries for direction, window-width, or +window-height are stripped before delegating to +`display-buffer-in-direction' so the saved-state values control +placement; the remaining alist entries are passed through." + (let* ((stored-dir (and (boundp direction-var) (symbol-value direction-var))) + (stored-size (and (boundp size-var) (symbol-value size-var))) + (direction (or stored-dir default-direction)) + (edge-direction (or (cj/cardinal-to-edge-direction direction) + (cj/cardinal-to-edge-direction default-direction))) + (size (or stored-size default-size)) + (size-key (if (memq direction '(right left)) + 'window-width + 'window-height)) + (body-tag (if (memq direction '(right left)) + 'body-columns + 'body-lines)) + (size-value (if (integerp size) + (cons body-tag size) + size)) + (filtered (cl-remove-if + (lambda (cell) + (memq (car-safe cell) + '(direction window-width window-height))) + alist)) + (effective (append + (list (cons 'direction edge-direction) + (cons size-key size-value)) + filtered))) + (display-buffer-in-direction buffer effective))) + +(provide 'cj-window-toggle) +;;; cj-window-toggle.el ends here diff --git a/modules/eshell-vterm-config.el b/modules/eshell-vterm-config.el index dff31e4d..165e0437 100644 --- a/modules/eshell-vterm-config.el +++ b/modules/eshell-vterm-config.el @@ -396,6 +396,7 @@ ai-vterm.el is loaded." (require 'cl-lib) (require 'seq) (require 'cj-window-geometry) +(require 'cj-window-toggle) (defcustom cj/vterm-toggle-window-height 0.7 "Default fraction of frame height for the F12 vterm window. @@ -446,47 +447,20 @@ FRAME defaults to the selected frame. Minibuffer is excluded." Default direction is `below' to match F12's traditional bottom split when WINDOW fills the frame's root area." - (when (window-live-p window) - (let* ((dir (cj/window-direction window 'below)) - (size (cj/window-body-size window dir))) - (setq cj/--vterm-toggle-last-direction dir - cj/--vterm-toggle-last-size size)))) + (cj/window-toggle-capture-state + window 'below + 'cj/--vterm-toggle-last-direction + 'cj/--vterm-toggle-last-size)) (defun cj/--vterm-toggle-display-saved (buffer alist) "Display-buffer action: split per saved direction and body size. -Reads `cj/--vterm-toggle-last-direction' and -`cj/--vterm-toggle-last-size', falling back to `below' and -`cj/vterm-toggle-window-height' when nil. The cardinal direction -is mapped to its frame-edge variant (`right' -> `rightmost', etc.) -so the new vterm always lands at the same frame edge it came from -regardless of which window is selected. An integer size is wrapped -in a `(body-columns . N)' / `(body-lines . N)' cons so the body -width or height is set explicitly, divider-independent. A float -size passes through as a fraction of the new window's parent." - (let* ((direction (or cj/--vterm-toggle-last-direction 'below)) - (edge-direction (or (cj/cardinal-to-edge-direction direction) - 'bottom)) - (size (or cj/--vterm-toggle-last-size cj/vterm-toggle-window-height)) - (size-key (if (memq direction '(right left)) - 'window-width - 'window-height)) - (body-tag (if (memq direction '(right left)) - 'body-columns - 'body-lines)) - (size-value (if (integerp size) - (cons body-tag size) - size)) - (filtered (cl-remove-if - (lambda (cell) - (memq (car-safe cell) - '(direction window-width window-height))) - alist)) - (effective (append - (list (cons 'direction edge-direction) - (cons size-key size-value)) - filtered))) - (display-buffer-in-direction buffer effective))) +Delegates to `cj/window-toggle-display-saved' against the F12 state +vars, falling back to `below' and `cj/vterm-toggle-window-height'." + (cj/window-toggle-display-saved + buffer alist + 'cj/--vterm-toggle-last-direction 'below + 'cj/--vterm-toggle-last-size cj/vterm-toggle-window-height)) (defun cj/--vterm-toggle-display-rule-list () "Return the `display-buffer-alist' entry list installed by F12. diff --git a/tests/test-cj-window-toggle.el b/tests/test-cj-window-toggle.el new file mode 100644 index 00000000..c6273463 --- /dev/null +++ b/tests/test-cj-window-toggle.el @@ -0,0 +1,188 @@ +;;; test-cj-window-toggle.el --- Tests for shared toggle-state helpers -*- lexical-binding: t; -*- + +;;; Commentary: +;; cj-window-toggle.el provides parameterized capture-state and +;; display-saved helpers shared by ai-vterm.el (F9) and +;; eshell-vterm-config.el (F12). Each consumer holds its own pair of +;; state variables (last-direction symbol + last-size integer/float) +;; and passes the variable symbols to the helpers. These tests cover +;; the helpers in isolation against a fresh pair of test-only vars. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'cj-window-toggle) + +(defvar test-cj-window-toggle--last-direction nil) +(defvar test-cj-window-toggle--last-size nil) + +(ert-deftest test-cj-window-toggle-capture-records-right-split () + "Normal: right-split window writes direction=right and integer body-cols." + (save-window-excursion + (delete-other-windows) + (let ((right (split-window (selected-window) nil 'right)) + (test-cj-window-toggle--last-direction nil) + (test-cj-window-toggle--last-size nil)) + (cj/window-toggle-capture-state + right 'right + 'test-cj-window-toggle--last-direction + 'test-cj-window-toggle--last-size) + (should (eq test-cj-window-toggle--last-direction 'right)) + (should (integerp test-cj-window-toggle--last-size)) + (should (= test-cj-window-toggle--last-size + (window-body-width right)))))) + +(ert-deftest test-cj-window-toggle-capture-records-below-split () + "Normal: below-split window writes direction=below and integer body-lines." + (save-window-excursion + (delete-other-windows) + (let ((below (split-window (selected-window) nil 'below)) + (test-cj-window-toggle--last-direction nil) + (test-cj-window-toggle--last-size nil)) + (cj/window-toggle-capture-state + below 'below + 'test-cj-window-toggle--last-direction + 'test-cj-window-toggle--last-size) + (should (eq test-cj-window-toggle--last-direction 'below)) + (should (integerp test-cj-window-toggle--last-size)) + (should (= test-cj-window-toggle--last-size + (window-body-height below)))))) + +(ert-deftest test-cj-window-toggle-capture-falls-back-to-default-direction () + "Boundary: window filling the frame uses the supplied default direction." + (save-window-excursion + (delete-other-windows) + (let ((root (selected-window)) + (test-cj-window-toggle--last-direction nil) + (test-cj-window-toggle--last-size nil)) + (cj/window-toggle-capture-state + root 'below + 'test-cj-window-toggle--last-direction + 'test-cj-window-toggle--last-size) + (should (eq test-cj-window-toggle--last-direction 'below))))) + +(ert-deftest test-cj-window-toggle-capture-noop-on-nil-window () + "Error: nil window leaves both state vars unchanged." + (let ((test-cj-window-toggle--last-direction 'sentinel-dir) + (test-cj-window-toggle--last-size 0.123)) + (cj/window-toggle-capture-state + nil 'right + 'test-cj-window-toggle--last-direction + 'test-cj-window-toggle--last-size) + (should (eq test-cj-window-toggle--last-direction 'sentinel-dir)) + (should (= test-cj-window-toggle--last-size 0.123)))) + +(ert-deftest test-cj-window-toggle-capture-noop-on-deleted-window () + "Error: a deleted window leaves both state vars unchanged." + (let ((test-cj-window-toggle--last-direction 'sentinel-dir) + (test-cj-window-toggle--last-size 0.123) + (dead (save-window-excursion + (delete-other-windows) + (let ((w (split-window (selected-window) nil 'right))) + (delete-window w) + w)))) + (cj/window-toggle-capture-state + dead 'right + 'test-cj-window-toggle--last-direction + 'test-cj-window-toggle--last-size) + (should (eq test-cj-window-toggle--last-direction 'sentinel-dir)) + (should (= test-cj-window-toggle--last-size 0.123)))) + +(ert-deftest test-cj-window-toggle-display-saved-uses-defaults-when-state-nil () + "Normal: nil state -> direction=edge of default, size=default." + (let (received-alist + (test-cj-window-toggle--last-direction nil) + (test-cj-window-toggle--last-size nil)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/window-toggle-display-saved + 'fake-buf + '((inhibit-same-window . t)) + 'test-cj-window-toggle--last-direction + 'below + 'test-cj-window-toggle--last-size + 0.7)) + (should (eq (cdr (assq 'direction received-alist)) 'bottom)) + (should (= (cdr (assq 'window-height received-alist)) 0.7)) + (should (eq (cdr (assq 'inhibit-same-window received-alist)) t)))) + +(ert-deftest test-cj-window-toggle-display-saved-maps-below-to-bottom () + "Normal: saved below + integer size -> bottom edge, body-lines cons." + (let (received-alist + (test-cj-window-toggle--last-direction 'below) + (test-cj-window-toggle--last-size 12)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/window-toggle-display-saved + 'fake-buf nil + 'test-cj-window-toggle--last-direction + 'below + 'test-cj-window-toggle--last-size + 0.7)) + (should (eq (cdr (assq 'direction received-alist)) 'bottom)) + (should (equal (cdr (assq 'window-height received-alist)) + '(body-lines . 12))) + (should-not (assq 'window-width received-alist)))) + +(ert-deftest test-cj-window-toggle-display-saved-maps-right-to-rightmost () + "Normal: saved right + integer size -> rightmost edge, body-columns cons." + (let (received-alist + (test-cj-window-toggle--last-direction 'right) + (test-cj-window-toggle--last-size 80)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/window-toggle-display-saved + 'fake-buf nil + 'test-cj-window-toggle--last-direction + 'right + 'test-cj-window-toggle--last-size + 0.5)) + (should (eq (cdr (assq 'direction received-alist)) 'rightmost)) + (should (equal (cdr (assq 'window-width received-alist)) + '(body-columns . 80))) + (should-not (assq 'window-height received-alist)))) + +(ert-deftest test-cj-window-toggle-display-saved-strips-conflicting-entries () + "Boundary: caller-supplied direction/size are removed; saved values win." + (let (received-alist + (test-cj-window-toggle--last-direction 'right) + (test-cj-window-toggle--last-size 30)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/window-toggle-display-saved + 'fake-buf + '((direction . above) + (window-width . 0.2) + (window-height . 0.3) + (inhibit-same-window . t)) + 'test-cj-window-toggle--last-direction + 'right + 'test-cj-window-toggle--last-size + 0.5)) + (should (eq (cdr (assq 'direction received-alist)) 'rightmost)) + (should (equal (cdr (assq 'window-width received-alist)) + '(body-columns . 30))) + (should-not (assq 'window-height received-alist)) + (should (eq (cdr (assq 'inhibit-same-window received-alist)) t)))) + +(ert-deftest test-cj-window-toggle-display-saved-passes-float-size-through () + "Boundary: float size passes through as a fraction (no body-N wrapping)." + (let (received-alist + (test-cj-window-toggle--last-direction 'below) + (test-cj-window-toggle--last-size nil)) + (cl-letf (((symbol-function 'display-buffer-in-direction) + (lambda (_b a) (setq received-alist a) 'fake-window))) + (cj/window-toggle-display-saved + 'fake-buf nil + 'test-cj-window-toggle--last-direction + 'below + 'test-cj-window-toggle--last-size + 0.4)) + (should (eq (cdr (assq 'direction received-alist)) 'bottom)) + (should (= (cdr (assq 'window-height received-alist)) 0.4)))) + +(provide 'test-cj-window-toggle) +;;; test-cj-window-toggle.el ends here |
