aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/ai-vterm.el72
-rw-r--r--modules/cj-window-toggle.el85
-rw-r--r--modules/eshell-vterm-config.el48
-rw-r--r--tests/test-cj-window-toggle.el188
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