diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-09 14:48:01 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-09 14:48:01 -0500 |
| commit | 7ad613f96380319c037f367a1b6b1beda03846ca (patch) | |
| tree | b442ddc79de803e042505df84ff1d8acb8c812cb | |
| parent | a29ac8d9f31443279ba5897b13cf5cda49519975 (diff) | |
| download | dotemacs-7ad613f96380319c037f367a1b6b1beda03846ca.tar.gz dotemacs-7ad613f96380319c037f367a1b6b1beda03846ca.zip | |
refactor: extract window-geometry helpers shared by F9 and F12
`ai-vterm.el` (F9) and `eshell-vterm-config.el` (F12) both grew the same geometry-preservation pattern: classify a window's position, capture its body size, map cardinal direction to its frame-edge variant. The shared helpers were sitting as near-duplicates in both modules. With two real consumers established, the abstraction has the right shape. I pulled them into `cj-window-geometry.el`.
The new module exposes three pure helpers:
- `cj/window-direction` returns right/below/left/above based on edges relative to `frame-root-window`. Takes an optional DEFAULT for the single-window-frame fallback so each consumer picks its own (ai-vterm wants 'right, vterm-toggle wants 'below).
- `cj/window-body-size` returns body-cols (right/left) or body-lines (below/above). Same body-vs-total reasoning as before: divider-independent, matches what the user sees.
- `cj/cardinal-to-edge-direction` maps right/left/below/above to rightmost/leftmost/bottom/top, used by each consumer's `display-saved` action.
`ai-vterm.el` and `eshell-vterm-config.el` now `(require 'cj-window-geometry)` and call the shared helpers directly. The consumer-specific `capture-state` and `display-saved` bodies stay in each module because they bind to consumer-specific state vars. Extracting those would either need parameter-passing-via-symbol or a macro, both heavier than the duplication they would remove.
Tests: 15 in `test-cj-window-geometry.el` covering all four directions, body-size on both axes, cardinal-to-edge mapping, default-arg fallback, and the unknown-direction nil case. Deleted `test-ai-vterm--window-geometry.el` (now redundant) and trimmed four duplicate window-direction/size tests from `test-vterm-toggle--display.el`. Net LOC: each consumer ~40-50 lines lighter, with the new module + tests paying roughly half that back. Full make test green. make validate-modules green.
| -rw-r--r-- | modules/ai-vterm.el | 57 | ||||
| -rw-r--r-- | modules/cj-window-geometry.el | 81 | ||||
| -rw-r--r-- | modules/eshell-vterm-config.el | 50 | ||||
| -rw-r--r-- | tests/test-ai-vterm--window-geometry.el | 88 | ||||
| -rw-r--r-- | tests/test-cj-window-geometry.el | 103 | ||||
| -rw-r--r-- | tests/test-vterm-toggle--display.el | 29 |
6 files changed, 201 insertions, 207 deletions
diff --git a/modules/ai-vterm.el b/modules/ai-vterm.el index 9b47e33d..e817f441 100644 --- a/modules/ai-vterm.el +++ b/modules/ai-vterm.el @@ -36,6 +36,7 @@ (require 'cl-lib) (require 'seq) +(require 'cj-window-geometry) (declare-function vterm "vterm" (&optional buffer-name)) (declare-function vterm-send-string "vterm" (string &optional paste-p)) @@ -228,57 +229,19 @@ and a fraction-of-frame produces the wrong size on replay (squeezes the other windows). An integer is unambiguous, at the cost of not auto-scaling if the frame itself resizes.") -(defun cj/--ai-vterm-window-direction (window) - "Return the side WINDOW occupies in its frame. - -Returns one of right, below, left, above. Falls back to right when -WINDOW fills its frame's root area (single-window or atypical -layout), since right is the module's default split direction. - -Comparison uses `frame-root-window' edges rather than frame edges so -the minibuffer doesn't make every full-area window look like it -fails to span the full height." - (let* ((root (frame-root-window (window-frame window))) - (edges (window-edges window)) - (root-edges (window-edges root)) - (left (nth 0 edges)) - (top (nth 1 edges)) - (right (nth 2 edges)) - (bottom (nth 3 edges)) - (root-left (nth 0 root-edges)) - (root-top (nth 1 root-edges)) - (root-right (nth 2 root-edges)) - (root-bottom (nth 3 root-edges)) - (spans-full-width (and (= left root-left) (= right root-right))) - (spans-full-height (and (= top root-top) (= bottom root-bottom)))) - (cond - ((not spans-full-width) (if (= left root-left) 'left 'right)) - ((not spans-full-height) (if (= top root-top) 'above 'below)) - (t 'right)))) - -(defun cj/--ai-vterm-window-size (window direction) - "Return WINDOW's body size in cols (right/left) or lines (below/above). - -Returns body width or body height -- the count of characters -visible in the text content area, independent of fringes, -scrollbars, or window dividers. See `cj/--ai-vterm-last-size' for -why body size, not total size, is the right thing to capture." - (if (memq direction '(right left)) - (window-body-width window) - (window-body-height window))) - (defun cj/--ai-vterm-capture-state (window) "Capture WINDOW's direction and size into module-level state. 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 `quit-window' tears the -window down). +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/--ai-vterm-window-direction window)) - (size (cj/--ai-vterm-window-size window dir))) + (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)))) @@ -333,12 +296,8 @@ 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 (pcase direction - ('right 'rightmost) - ('left 'leftmost) - ('below 'bottom) - ('above 'top) - (_ 'rightmost))) + (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 diff --git a/modules/cj-window-geometry.el b/modules/cj-window-geometry.el new file mode 100644 index 00000000..88fa83d4 --- /dev/null +++ b/modules/cj-window-geometry.el @@ -0,0 +1,81 @@ +;;; cj-window-geometry.el --- Pure window-geometry helpers -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> + +;;; Commentary: + +;; Pure helpers for classifying a window's position in its frame and +;; computing body sizes. Shared between `ai-vterm.el' (F9 dispatch) +;; and `eshell-vterm-config.el' (F12 dispatch); the geometry- +;; preservation pattern in both modules captures direction + body +;; size at toggle-off and replays them on the next toggle-on. +;; +;; All functions are pure: they read window/frame edges and return +;; classifications. No side effects, no state. Consumers wrap them +;; with consumer-specific state vars and display logic. + +;;; Code: + +(defun cj/window-direction (window &optional default) + "Return the side WINDOW occupies in its frame. + +Returns one of right, below, left, above. Falls back to DEFAULT +(or right when DEFAULT is nil) when WINDOW fills its frame's +root area. Comparison uses `frame-root-window' edges so the +minibuffer doesn't make every full-area window look like it +fails to span the full height." + (let* ((root (frame-root-window (window-frame window))) + (edges (window-edges window)) + (root-edges (window-edges root)) + (left (nth 0 edges)) + (top (nth 1 edges)) + (right (nth 2 edges)) + (bottom (nth 3 edges)) + (root-left (nth 0 root-edges)) + (root-top (nth 1 root-edges)) + (root-right (nth 2 root-edges)) + (root-bottom (nth 3 root-edges)) + (spans-full-width (and (= left root-left) (= right root-right))) + (spans-full-height (and (= top root-top) (= bottom root-bottom)))) + (cond + ((not spans-full-width) (if (= left root-left) 'left 'right)) + ((not spans-full-height) (if (= top root-top) 'above 'below)) + (t (or default 'right))))) + +(defun cj/window-body-size (window direction) + "Return WINDOW's body size on the axis matching DIRECTION. + +Returns body-width (columns) when DIRECTION is right or left. +Returns body-height (lines) when DIRECTION is below or above. + +Body size, not total size, is the right thing to capture for +geometry replay: total-width includes the right-side divider when +the window has a right sibling but excludes it at the frame edge, +so a captured rightmost window replayed into a middle position +would leave the body 1 col short. Body size is divider- +independent and matches what the user actually sees." + (if (memq direction '(right left)) + (window-body-width window) + (window-body-height window))) + +(defun cj/cardinal-to-edge-direction (direction) + "Map cardinal DIRECTION to its `display-buffer-in-direction' edge variant. + +Returns rightmost/leftmost/bottom/top for right/left/below/above +respectively. Returns nil for any other input. + +The edge variants route splits relative to the frame's main +window rather than the selected window, so a toggle-on lands at +the same frame edge regardless of which window is selected. The +cardinal variants would split the selected window's tree branch +instead, putting the new window mid-frame in multi-window +layouts." + (pcase direction + ('right 'rightmost) + ('left 'leftmost) + ('below 'bottom) + ('above 'top) + (_ nil))) + +(provide 'cj-window-geometry) +;;; cj-window-geometry.el ends here diff --git a/modules/eshell-vterm-config.el b/modules/eshell-vterm-config.el index ed257893..34738fa1 100644 --- a/modules/eshell-vterm-config.el +++ b/modules/eshell-vterm-config.el @@ -261,6 +261,7 @@ ai-vterm.el is loaded." (require 'cl-lib) (require 'seq) +(require 'cj-window-geometry) (defcustom cj/vterm-toggle-window-height 0.7 "Default fraction of frame height for the F12 vterm window. @@ -306,43 +307,14 @@ FRAME defaults to the selected frame. Minibuffer is excluded." (cj/--vterm-toggle-buffer-p (window-buffer w))) (window-list (or frame (selected-frame)) 'never))) -(defun cj/--vterm-toggle-window-direction (window) - "Return the side WINDOW occupies in its frame. - -Returns one of right, below, left, above. Falls back to 'below -(F12's traditional bottom split) when WINDOW fills its frame's -root area. Comparison uses `frame-root-window' edges so the -minibuffer doesn't make every full-area window look like it -fails to span the full height." - (let* ((root (frame-root-window (window-frame window))) - (edges (window-edges window)) - (root-edges (window-edges root)) - (left (nth 0 edges)) - (top (nth 1 edges)) - (right (nth 2 edges)) - (bottom (nth 3 edges)) - (root-left (nth 0 root-edges)) - (root-top (nth 1 root-edges)) - (root-right (nth 2 root-edges)) - (root-bottom (nth 3 root-edges)) - (spans-full-width (and (= left root-left) (= right root-right))) - (spans-full-height (and (= top root-top) (= bottom root-bottom)))) - (cond - ((not spans-full-width) (if (= left root-left) 'left 'right)) - ((not spans-full-height) (if (= top root-top) 'above 'below)) - (t 'below)))) - -(defun cj/--vterm-toggle-window-size (window direction) - "Return WINDOW's body size in cols (right/left) or lines (below/above)." - (if (memq direction '(right left)) - (window-body-width window) - (window-body-height window))) - (defun cj/--vterm-toggle-capture-state (window) - "Capture WINDOW's direction + body size into module-level state." + "Capture WINDOW's direction + body size into module-level state. + +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/--vterm-toggle-window-direction window)) - (size (cj/--vterm-toggle-window-size window dir))) + (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)))) @@ -359,12 +331,8 @@ 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 (pcase direction - ('right 'rightmost) - ('left 'leftmost) - ('below 'bottom) - ('above 'top) - (_ 'bottom))) + (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 diff --git a/tests/test-ai-vterm--window-geometry.el b/tests/test-ai-vterm--window-geometry.el deleted file mode 100644 index c2200273..00000000 --- a/tests/test-ai-vterm--window-geometry.el +++ /dev/null @@ -1,88 +0,0 @@ -;;; test-ai-vterm--window-geometry.el --- Tests for direction + fraction helpers -*- lexical-binding: t; -*- - -;;; Commentary: -;; Two pure helpers used by F9's geometry-preservation feature: -;; -;; - `cj/--ai-vterm-window-direction' classifies a window's position -;; relative to its frame as right / below / left / above (with a -;; right fallback when the window fills the frame). -;; -;; - `cj/--ai-vterm-window-fraction' returns the window's size on -;; the matching axis as a fraction of the frame. -;; -;; Tests use real window splits in `save-window-excursion' rather -;; than mocking, since the helpers consume `window-edges' and -;; `frame-width' / `frame-height' directly. - -;;; Code: - -(require 'ert) - -(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) -(require 'ai-vterm) - -(ert-deftest test-ai-vterm--window-direction-right-split () - "Normal: 2-window vertical split, right-side window -> right." - (save-window-excursion - (delete-other-windows) - (let ((right (split-window (selected-window) nil 'right))) - (should (eq (cj/--ai-vterm-window-direction right) 'right))))) - -(ert-deftest test-ai-vterm--window-direction-left-split () - "Normal: 2-window vertical split, left-side window -> left." - (save-window-excursion - (delete-other-windows) - (split-window (selected-window) nil 'right) - (should (eq (cj/--ai-vterm-window-direction (selected-window)) 'left)))) - -(ert-deftest test-ai-vterm--window-direction-below-split () - "Normal: 2-window horizontal split, bottom window -> below." - (save-window-excursion - (delete-other-windows) - (let ((below (split-window (selected-window) nil 'below))) - (should (eq (cj/--ai-vterm-window-direction below) 'below))))) - -(ert-deftest test-ai-vterm--window-direction-above-split () - "Normal: 2-window horizontal split, top window -> above." - (save-window-excursion - (delete-other-windows) - (split-window (selected-window) nil 'below) - (should (eq (cj/--ai-vterm-window-direction (selected-window)) 'above)))) - -(ert-deftest test-ai-vterm--window-direction-single-window-fallback () - "Boundary: single-window frame -> default right." - (save-window-excursion - (delete-other-windows) - (should (eq (cj/--ai-vterm-window-direction (selected-window)) 'right)))) - -(ert-deftest test-ai-vterm--window-size-right-split-returns-body-cols () - "Normal: right window -> integer body-cols matching window-body-width." - (save-window-excursion - (delete-other-windows) - (let* ((right (split-window (selected-window) nil 'right)) - (size (cj/--ai-vterm-window-size right 'right))) - (should (integerp size)) - (should (= size (window-body-width right)))))) - -(ert-deftest test-ai-vterm--window-size-below-split-returns-body-lines () - "Normal: bottom window -> integer body-lines matching window-body-height." - (save-window-excursion - (delete-other-windows) - (let* ((below (split-window (selected-window) nil 'below)) - (size (cj/--ai-vterm-window-size below 'below))) - (should (integerp size)) - (should (= size (window-body-height below)))))) - -(ert-deftest test-ai-vterm--window-size-narrow-right-split () - "Normal: deliberately narrow right window -> matching body-col count." - (save-window-excursion - (delete-other-windows) - (let* ((frame-w (frame-width)) - (target-cols (/ frame-w 4)) - (right (split-window (selected-window) (- target-cols) 'right)) - (size (cj/--ai-vterm-window-size right 'right))) - (should (integerp size)) - (should (= size (window-body-width right)))))) - -(provide 'test-ai-vterm--window-geometry) -;;; test-ai-vterm--window-geometry.el ends here diff --git a/tests/test-cj-window-geometry.el b/tests/test-cj-window-geometry.el new file mode 100644 index 00000000..8b0b9732 --- /dev/null +++ b/tests/test-cj-window-geometry.el @@ -0,0 +1,103 @@ +;;; test-cj-window-geometry.el --- Tests for the shared window-geometry helpers -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests the three pure helpers in `cj-window-geometry.el': +;; `cj/window-direction', `cj/window-body-size', and +;; `cj/cardinal-to-edge-direction'. + +;;; Code: + +(require 'ert) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'cj-window-geometry) + +(ert-deftest test-cj-window-geometry--direction-right-split () + "Normal: 2-window vertical split, right-side window -> right." + (save-window-excursion + (delete-other-windows) + (let ((right (split-window (selected-window) nil 'right))) + (should (eq (cj/window-direction right) 'right))))) + +(ert-deftest test-cj-window-geometry--direction-left-split () + "Normal: 2-window vertical split, left-side window -> left." + (save-window-excursion + (delete-other-windows) + (split-window (selected-window) nil 'right) + (should (eq (cj/window-direction (selected-window)) 'left)))) + +(ert-deftest test-cj-window-geometry--direction-below-split () + "Normal: 2-window horizontal split, bottom window -> below." + (save-window-excursion + (delete-other-windows) + (let ((below (split-window (selected-window) nil 'below))) + (should (eq (cj/window-direction below) 'below))))) + +(ert-deftest test-cj-window-geometry--direction-above-split () + "Normal: 2-window horizontal split, top window -> above." + (save-window-excursion + (delete-other-windows) + (split-window (selected-window) nil 'below) + (should (eq (cj/window-direction (selected-window)) 'above)))) + +(ert-deftest test-cj-window-geometry--direction-single-window-default-right () + "Boundary: single-window frame, no DEFAULT arg -> 'right." + (save-window-excursion + (delete-other-windows) + (should (eq (cj/window-direction (selected-window)) 'right)))) + +(ert-deftest test-cj-window-geometry--direction-single-window-with-default () + "Boundary: single-window frame with DEFAULT='below -> 'below." + (save-window-excursion + (delete-other-windows) + (should (eq (cj/window-direction (selected-window) 'below) 'below)))) + +(ert-deftest test-cj-window-geometry--body-size-right-returns-body-cols () + "Normal: right window with direction='right -> body-width in cols." + (save-window-excursion + (delete-other-windows) + (let ((right (split-window (selected-window) nil 'right))) + (should (= (cj/window-body-size right 'right) + (window-body-width right)))))) + +(ert-deftest test-cj-window-geometry--body-size-below-returns-body-lines () + "Normal: below window with direction='below -> body-height in lines." + (save-window-excursion + (delete-other-windows) + (let ((below (split-window (selected-window) nil 'below))) + (should (= (cj/window-body-size below 'below) + (window-body-height below)))))) + +(ert-deftest test-cj-window-geometry--body-size-narrow-window () + "Normal: deliberately narrow right window -> matching body cols." + (save-window-excursion + (delete-other-windows) + (let* ((frame-w (frame-width)) + (target-cols (/ frame-w 4)) + (right (split-window (selected-window) (- target-cols) 'right))) + (should (= (cj/window-body-size right 'right) + (window-body-width right)))))) + +(ert-deftest test-cj-window-geometry--cardinal-to-edge-right () + "Normal: 'right -> 'rightmost." + (should (eq (cj/cardinal-to-edge-direction 'right) 'rightmost))) + +(ert-deftest test-cj-window-geometry--cardinal-to-edge-left () + "Normal: 'left -> 'leftmost." + (should (eq (cj/cardinal-to-edge-direction 'left) 'leftmost))) + +(ert-deftest test-cj-window-geometry--cardinal-to-edge-below () + "Normal: 'below -> 'bottom." + (should (eq (cj/cardinal-to-edge-direction 'below) 'bottom))) + +(ert-deftest test-cj-window-geometry--cardinal-to-edge-above () + "Normal: 'above -> 'top." + (should (eq (cj/cardinal-to-edge-direction 'above) 'top))) + +(ert-deftest test-cj-window-geometry--cardinal-to-edge-unknown-returns-nil () + "Boundary: an unknown direction symbol -> nil." + (should (null (cj/cardinal-to-edge-direction 'sideways))) + (should (null (cj/cardinal-to-edge-direction nil)))) + +(provide 'test-cj-window-geometry) +;;; test-cj-window-geometry.el ends here diff --git a/tests/test-vterm-toggle--display.el b/tests/test-vterm-toggle--display.el index 70fa61ac..ed7cd858 100644 --- a/tests/test-vterm-toggle--display.el +++ b/tests/test-vterm-toggle--display.el @@ -16,35 +16,6 @@ (add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) (require 'eshell-vterm-config) -(ert-deftest test-vterm-toggle--window-direction-single-window-defaults-to-below () - "Boundary: full-frame window -> default 'below for F12." - (save-window-excursion - (delete-other-windows) - (should (eq (cj/--vterm-toggle-window-direction (selected-window)) - 'below)))) - -(ert-deftest test-vterm-toggle--window-direction-below-split () - "Normal: bottom window of horizontal split -> 'below." - (save-window-excursion - (delete-other-windows) - (let ((below (split-window (selected-window) nil 'below))) - (should (eq (cj/--vterm-toggle-window-direction below) 'below))))) - -(ert-deftest test-vterm-toggle--window-direction-right-split () - "Normal: right window of vertical split -> 'right." - (save-window-excursion - (delete-other-windows) - (let ((right (split-window (selected-window) nil 'right))) - (should (eq (cj/--vterm-toggle-window-direction right) 'right))))) - -(ert-deftest test-vterm-toggle--window-size-returns-body () - "Normal: returns body-width for right/left, body-height for below/above." - (save-window-excursion - (delete-other-windows) - (let ((below (split-window (selected-window) nil 'below))) - (should (= (cj/--vterm-toggle-window-size below 'below) - (window-body-height below)))))) - (ert-deftest test-vterm-toggle--capture-state-records-direction-and-size () "Normal: capture-state writes direction and integer body size." (save-window-excursion |
