diff --git a/lisp/ghostel-compile.el b/lisp/ghostel-compile.el index 00b3b8b..5533b0a 100644 --- a/lisp/ghostel-compile.el +++ b/lisp/ghostel-compile.el @@ -548,7 +548,9 @@ so there is no remote-integration round-trip on TRAMP buffers." (ghostel--load-module t) (let* ((buffer (get-buffer-create name)) (win (or (get-buffer-window buffer t) (selected-window))) - (height (if (window-live-p win) (window-body-height win) 24)) + (height (if (window-live-p win) + (with-selected-window win (floor (window-screen-lines))) + 24)) (width (if (window-live-p win) (window-max-chars-per-line win) 80))) (with-current-buffer buffer ;; Set `default-directory' before `ghostel-mode' so the mode's @@ -626,7 +628,8 @@ honour custom compile-mode subclasses the caller passed to ;; column and look garbled until the user's first resize triggers ;; `ghostel--window-adjust-process-window-size'. (when (and outwin ghostel--term) - (let ((oh (max 1 (window-body-height outwin))) + (let ((oh (max 1 (with-selected-window outwin + (floor (window-screen-lines))))) (ow (max 1 (window-max-chars-per-line outwin)))) (ghostel--set-size ghostel--term oh ow) (setq ghostel--term-rows oh))) @@ -659,8 +662,9 @@ honour custom compile-mode subclasses the caller passed to ;; window, e.g. `allow-no-window'). Use `window-max-chars-per-line' ;; as the canonical width measure, matching `ghostel--spawn-pty'. (let* ((height (max 1 (if outwin - (window-body-height outwin) - (window-body-height)))) + (with-selected-window outwin + (floor (window-screen-lines))) + (floor (window-screen-lines))))) (width (max 1 (if outwin (window-max-chars-per-line outwin) (window-max-chars-per-line)))) diff --git a/lisp/ghostel-debug.el b/lisp/ghostel-debug.el index 98d0b84..f85e8dd 100644 --- a/lisp/ghostel-debug.el +++ b/lisp/ghostel-debug.el @@ -570,11 +570,13 @@ ghostel settings into *ghostel-debug* for pasting into bug reports." (if copy "active" "off")))) (insert "Term handle: nil (no terminal)\n")) ;; Size sync — surfaces #192-class bugs. - ;; If body-rows ≠ term-rows but cur=recorded body pixels, then - ;; Emacs already absorbed the chrome change but ghostel didn't - ;; reconcile (a real ghostel bug). If both differ, the next - ;; redisplay will fire `window-{size,configuration}-change-hook' - ;; and `--window-adjust-process-window-size' will reconcile. + ;; Compare term-rows against `floor(window-screen-lines)' (what + ;; `window-adjust-process-window-size-smallest' uses), NOT + ;; `window-body-height': the latter divides by frame char + ;; height while screen-lines divides by `default-line-height' + ;; (face-remap-aware). When a theme remaps the default face + ;; height, the two disagree and the body-height comparison + ;; cries wolf. (when (and term (window-live-p win)) (insert "\n--- Size sync ---\n") (let* ((cur-body-px (window-body-height win t)) @@ -584,12 +586,21 @@ ghostel settings into *ghostel-debug* for pasting into bug reports." (screen-lines (with-selected-window win (window-screen-lines))) (body-rows (window-body-height win)) - (rows-match (eql body-rows term-rows)) + (frame-ch (frame-char-height)) + (default-lh (with-selected-window win + (default-line-height))) + (target-rows (floor screen-lines)) + (rows-match (eql target-rows term-rows)) (px-match (eql cur-body-px old-body-px))) - (insert (format "Body rows: %d (window) vs %s (term) %s\n" - body-rows term-rows + (insert (format "screen-lines: %.3f → target %d (term=%s) %s\n" + screen-lines target-rows term-rows (if rows-match "[in sync]" "[MISMATCH]"))) - (insert (format "window-screen-lines: %s\n" screen-lines)) + (insert (format "Body rows (frame): %d (window-body-height — frame chars)\n" + body-rows)) + (insert (format "Line height: frame=%d px default-face=%d px%s\n" + frame-ch default-lh + (if (eql frame-ch default-lh) "" + " [face-remap or theme bumps height]"))) (insert (format "Body pixels: cur=%d recorded=%d %s\n" cur-body-px old-body-px (if px-match "" "[redisplay pending]"))) @@ -599,8 +610,8 @@ ghostel settings into *ghostel-debug* for pasting into bug reports." (rows-match (insert "Diagnosis: in sync\n")) (px-match - (insert "Diagnosis: Emacs absorbed the chrome change\n") - (insert " but ghostel didn't reconcile (#192)\n")) + (insert "Diagnosis: Emacs absorbed the change but\n") + (insert " ghostel didn't reconcile (#192)\n")) (t (insert "Diagnosis: pending redisplay; hooks will fire\n") (insert " on next paint\n")))))))) diff --git a/lisp/ghostel.el b/lisp/ghostel.el index bbdb914..3b75e4e 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -3454,7 +3454,8 @@ window (not when it has just been deselected)." ghostel--term ghostel--process (process-live-p ghostel--process)) - (let ((height (window-body-height window)) + (let ((height (with-selected-window window + (floor (window-screen-lines)))) (width (window-max-chars-per-line window)) (buf (current-buffer))) (unless (and (eql height ghostel--term-rows) @@ -3471,41 +3472,6 @@ window (not when it has just been deselected)." (setq ghostel--redraw-timer nil)) (let ((ghostel--redraw-resize-active t)) (ghostel--delayed-redraw buf)))))) - -(defun ghostel--reconcile-display-size (window) - "Reconcile terminal size when this buffer is (re-)displayed in WINDOW. -Buffer migration to a window of a different size produces no -window-size-change event — Emacs's `adjust-window-size-function' -machinery does not fire — but `window-buffer-change-functions' does. -Catch it here and resize libghostty + PTY to match WINDOW. - -Intended for buffer-local `window-buffer-change-functions'. The -single-window guard avoids ping-pong if the buffer is shown in two -differently-sized windows simultaneously — that case is handled by -the existing `window-adjust-process-window-size-smallest' path." - (when (and (window-live-p window) - (eq (window-buffer window) (current-buffer)) - ghostel--term - ghostel--process - (process-live-p ghostel--process) - (= 1 (length (get-buffer-window-list (current-buffer) nil t)))) - (let ((height (window-body-height window)) - (width (window-max-chars-per-line window))) - (unless (and (eql height ghostel--term-rows) - (eql width ghostel--term-cols)) - (ghostel--set-size ghostel--term (max 1 height) (max 1 width)) - (setq ghostel--term-rows height - ghostel--term-cols width - ghostel--force-next-redraw t) - (set-process-window-size ghostel--process - (max 1 height) (max 1 width)) - (when ghostel--redraw-timer - (cancel-timer ghostel--redraw-timer) - (setq ghostel--redraw-timer nil)) - (let ((ghostel--redraw-resize-active t)) - (ghostel--delayed-redraw (current-buffer))))))) - - ;;; Major mode @@ -3543,8 +3509,6 @@ the existing `window-adjust-process-window-size-smallest' path." #'ghostel--commit-cropped-size nil t) (add-hook 'window-buffer-change-functions #'ghostel--reshow-snap nil t) - (add-hook 'window-buffer-change-functions - #'ghostel--reconcile-display-size nil t) (ghostel--suppress-interfering-modes) (setq ghostel--scroll-intercept-active t) ;; Let C-g reach the keymap instead of triggering keyboard-quit. @@ -3584,17 +3548,31 @@ buffer can be found again after title-tracking renames it." (defun ghostel--init-buffer (buffer &optional identity) "Initialize BUFFER as a ghostel terminal if no terminal handle exists yet. Terminal dimensions come from BUFFER's displayed window when one -exists, otherwise from the selected window. Subsequent migrations -to differently-sized windows are reconciled by -`ghostel--reconcile-display-size' on `window-buffer-change-functions'. +exists, otherwise from the selected window. Height uses +`window-screen-lines' (the metric the standard +`adjust-window-size-function' path also uses), not +`window-body-height'. The former divides the window's pixel height +by the buffer's `default-line-height', which respects +`face-remapping-alist' and `:height' on the default face; the latter +divides by frame char height. When a theme remaps default — +`nano-light' / `nano-dark' do this — the two metrics disagree, and +using `window-body-height' would size the terminal to N rows only to +have the standard adjust-fn immediately resize to N-K, sending a +startup SIGWINCH that some TUI apps (Claude Code's /tui fullscreen) +handle imperfectly (issue #192). IDENTITY, if given, is stored as `ghostel--buffer-identity' so the buffer can be found again after title-tracking renames it." (with-current-buffer buffer (unless ghostel--term (ghostel--prepare-buffer buffer identity) (let* ((w (or (get-buffer-window buffer t) (selected-window))) - (height (if (window-live-p w) (window-body-height w) 24)) - (width (if (window-live-p w) (window-max-chars-per-line w) 80))) + (height (max 1 (if (window-live-p w) + (with-selected-window w + (floor (window-screen-lines))) + 24))) + (width (max 1 (if (window-live-p w) + (window-max-chars-per-line w) + 80)))) (setq ghostel--term (ghostel--new height width ghostel-max-scrollback)) (setq ghostel--term-rows height) @@ -3658,7 +3636,11 @@ Signals `user-error' if BUFFER already has a live ghostel process." (let ((window (or (get-buffer-window buffer t) (selected-window)))) (with-current-buffer buffer (ghostel--prepare-buffer buffer nil) - (let* ((height (max 1 (window-body-height window))) + ;; Use `window-screen-lines' (not `window-body-height') so the + ;; height matches the unit `window-adjust-process-window-size-smallest' + ;; uses — see `ghostel--init-buffer' for why. + (let* ((height (max 1 (with-selected-window window + (floor (window-screen-lines))))) (width (max 1 (window-max-chars-per-line window))) (remote-p (file-remote-p default-directory))) (setq ghostel--term diff --git a/test/ghostel-test.el b/test/ghostel-test.el index 930add0..1db4730 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -7350,6 +7350,9 @@ It must also raise `read-process-output-max'. Same reason as (set-size-args nil) (swsize-args nil) (redraw-called nil)) + ;; Pass a real `(selected-window)' rather than a fake symbol so + ;; `with-selected-window' works without stubbing implementation + ;; internals (which differ across the supported Emacs versions). (cl-letf (((symbol-function 'ghostel--set-size) (lambda (_term h w) (setq set-size-args (list h w)))) ((symbol-function 'ghostel--delayed-redraw) @@ -7357,14 +7360,15 @@ It must also raise `read-process-output-max'. Same reason as ((symbol-function 'process-live-p) (lambda (_p) t)) ((symbol-function 'set-process-window-size) (lambda (_p h w) (setq swsize-args (list h w)))) - ((symbol-function 'window-live-p) (lambda (_w) t)) - ((symbol-function 'window-frame) (lambda (_w) 'test-frame)) - ((symbol-function 'frame-selected-window) - (lambda (_f) 'test-win)) - ((symbol-function 'window-body-height) (lambda (&rest _) 25)) + ;; Regression guard for #192: if the function ever + ;; reverts to `window-body-height' instead of + ;; `window-screen-lines', the assertions below fail + ;; because 99 ≠ 25. + ((symbol-function 'window-body-height) (lambda (&rest _) 99)) + ((symbol-function 'window-screen-lines) (lambda () 25.0)) ((symbol-function 'window-max-chars-per-line) (lambda (&rest _) 120)) ((symbol-function 'minibuffer-depth) (lambda () 1))) - (ghostel--commit-cropped-size 'test-win) + (ghostel--commit-cropped-size (selected-window)) (should (equal '(25 120) set-size-args)) (should (equal '(25 120) swsize-args)) (should (eql ghostel--term-rows 25)) @@ -7431,156 +7435,13 @@ It must also raise `read-process-output-max'. Same reason as ((symbol-function 'process-live-p) (lambda (_p) t)) ((symbol-function 'set-process-window-size) (lambda (_p _h _w) (setq swsize-called t))) - ((symbol-function 'window-live-p) (lambda (_w) t)) - ((symbol-function 'window-frame) (lambda (_w) 'test-frame)) - ((symbol-function 'frame-selected-window) - (lambda (_f) 'test-win)) - ((symbol-function 'window-body-height) (lambda (&rest _) 40)) + ((symbol-function 'window-screen-lines) (lambda () 40.0)) ((symbol-function 'window-max-chars-per-line) (lambda (&rest _) 120)) ((symbol-function 'minibuffer-depth) (lambda () 1))) - (ghostel--commit-cropped-size 'test-win) - (should-not set-size-called) - (should-not swsize-called))))) - -(ert-deftest ghostel-test-reconcile-display-size-resizes-on-mismatch () - "If the new window's body differs from the captured value, resize. -Models the issue #192 case where the buffer migrates to a window of -a different size — no `window-size-change-functions' fires because no -window's recorded size changed, only the buffer-to-window mapping did." - (with-temp-buffer - (let ((ghostel--term 'fake) - (ghostel--process 'fake-proc) - (ghostel--term-rows 32) - (ghostel--term-cols 87) - (ghostel--force-next-redraw nil) - (set-size-args nil) - (swsize-args nil) - (redraw-called nil) - (buf (current-buffer))) - (cl-letf (((symbol-function 'ghostel--set-size) - (lambda (_term h w) (setq set-size-args (list h w)))) - ((symbol-function 'ghostel--delayed-redraw) - (lambda (_buf) (setq redraw-called t))) - ((symbol-function 'process-live-p) (lambda (_p) t)) - ((symbol-function 'set-process-window-size) - (lambda (_p h w) (setq swsize-args (list h w)))) - ((symbol-function 'window-live-p) (lambda (_w) t)) - ((symbol-function 'window-buffer) (lambda (_w) buf)) - ((symbol-function 'get-buffer-window-list) - (lambda (&rest _) '(test-win))) - ((symbol-function 'window-body-height) (lambda (&rest _) 34)) - ((symbol-function 'window-max-chars-per-line) (lambda (&rest _) 87))) - (ghostel--reconcile-display-size 'test-win) - (should (equal '(34 87) set-size-args)) - (should (equal '(34 87) swsize-args)) - (should (eql ghostel--term-rows 34)) - (should (eql ghostel--term-cols 87)) - (should ghostel--force-next-redraw) - (should redraw-called))))) - -(ert-deftest ghostel-test-reconcile-display-size-noop-on-match () - "If the window size already matches the captured value, do nothing." - (with-temp-buffer - (let ((ghostel--term 'fake) - (ghostel--process 'fake-proc) - (ghostel--term-rows 34) - (ghostel--term-cols 87) - (set-size-called nil) - (swsize-called nil) - (buf (current-buffer))) - (cl-letf (((symbol-function 'ghostel--set-size) - (lambda (_term _h _w) (setq set-size-called t))) - ((symbol-function 'ghostel--delayed-redraw) #'ignore) - ((symbol-function 'process-live-p) (lambda (_p) t)) - ((symbol-function 'set-process-window-size) - (lambda (_p _h _w) (setq swsize-called t))) - ((symbol-function 'window-live-p) (lambda (_w) t)) - ((symbol-function 'window-buffer) (lambda (_w) buf)) - ((symbol-function 'get-buffer-window-list) - (lambda (&rest _) '(test-win))) - ((symbol-function 'window-body-height) (lambda (&rest _) 34)) - ((symbol-function 'window-max-chars-per-line) (lambda (&rest _) 87))) - (ghostel--reconcile-display-size 'test-win) + (ghostel--commit-cropped-size (selected-window)) (should-not set-size-called) (should-not swsize-called))))) -(ert-deftest ghostel-test-reconcile-display-size-noop-on-multi-window () - "If the buffer is in more than one window, defer to smallest-window logic. -Ping-ponging libghostty between two differently-sized windows would -cause flicker; the existing `adjust-window-size-function' path uses -`window-adjust-process-window-size-smallest' to pick a stable size." - (with-temp-buffer - (let ((ghostel--term 'fake) - (ghostel--process 'fake-proc) - (ghostel--term-rows 32) - (ghostel--term-cols 87) - (set-size-called nil) - (buf (current-buffer))) - (cl-letf (((symbol-function 'ghostel--set-size) - (lambda (_term _h _w) (setq set-size-called t))) - ((symbol-function 'ghostel--delayed-redraw) #'ignore) - ((symbol-function 'process-live-p) (lambda (_p) t)) - ((symbol-function 'window-live-p) (lambda (_w) t)) - ((symbol-function 'window-buffer) (lambda (_w) buf)) - ((symbol-function 'get-buffer-window-list) - (lambda (&rest _) '(test-win other-win))) - ((symbol-function 'window-body-height) (lambda (&rest _) 34)) - ((symbol-function 'window-max-chars-per-line) (lambda (&rest _) 87))) - (ghostel--reconcile-display-size 'test-win) - (should-not set-size-called))))) - -(ert-deftest ghostel-test-reconcile-display-size-noop-when-process-dead () - "If the process died, do nothing. -Hook firing after the shell exited (e.g. exec'd PROGRAM crashed, -sentinel queued for next idle) must not SIGWINCH a corpse." - (with-temp-buffer - (let ((ghostel--term 'fake) - (ghostel--process 'fake-proc) - (ghostel--term-rows 32) - (ghostel--term-cols 87) - (set-size-called nil) - (swsize-called nil) - (buf (current-buffer))) - (cl-letf (((symbol-function 'ghostel--set-size) - (lambda (_term _h _w) (setq set-size-called t))) - ((symbol-function 'ghostel--delayed-redraw) #'ignore) - ((symbol-function 'process-live-p) (lambda (_p) nil)) - ((symbol-function 'set-process-window-size) - (lambda (_p _h _w) (setq swsize-called t))) - ((symbol-function 'window-live-p) (lambda (_w) t)) - ((symbol-function 'window-buffer) (lambda (_w) buf)) - ((symbol-function 'get-buffer-window-list) - (lambda (&rest _) '(test-win))) - ((symbol-function 'window-body-height) (lambda (&rest _) 34)) - ((symbol-function 'window-max-chars-per-line) (lambda (&rest _) 87))) - (ghostel--reconcile-display-size 'test-win) - (should-not set-size-called) - (should-not swsize-called))))) - -(ert-deftest ghostel-test-reconcile-display-size-noop-when-term-nil () - "If `ghostel--term' was never created, do nothing. -The early-`when' guard must short-circuit before calling -`ghostel--set-size' on a nil terminal." - (with-temp-buffer - (let ((ghostel--term nil) - (ghostel--process 'fake-proc) - (ghostel--term-rows nil) - (ghostel--term-cols nil) - (set-size-called nil) - (buf (current-buffer))) - (cl-letf (((symbol-function 'ghostel--set-size) - (lambda (_term _h _w) (setq set-size-called t))) - ((symbol-function 'ghostel--delayed-redraw) #'ignore) - ((symbol-function 'process-live-p) (lambda (_p) t)) - ((symbol-function 'window-live-p) (lambda (_w) t)) - ((symbol-function 'window-buffer) (lambda (_w) buf)) - ((symbol-function 'get-buffer-window-list) - (lambda (&rest _) '(test-win))) - ((symbol-function 'window-body-height) (lambda (&rest _) 34)) - ((symbol-function 'window-max-chars-per-line) (lambda (&rest _) 87))) - (ghostel--reconcile-display-size 'test-win) - (should-not set-size-called))))) - ;;; SIGWINCH delivery tests — verify the PTY actually sends the signal ;; Uses ghostel-test--wait-for defined at the top of this file. @@ -8036,11 +7897,6 @@ COLORTERM, INSIDE_EMACS, …) plus pass-through LANG/LC_*." ghostel-test-commit-cropped-size-noop-outside-minibuffer ghostel-test-commit-cropped-size-noop-on-deselect ghostel-test-commit-cropped-size-noop-when-matched - ghostel-test-reconcile-display-size-resizes-on-mismatch - ghostel-test-reconcile-display-size-noop-on-match - ghostel-test-reconcile-display-size-noop-on-multi-window - ghostel-test-reconcile-display-size-noop-when-process-dead - ghostel-test-reconcile-display-size-noop-when-term-nil ghostel-test-sigwinch-reaches-shell-basic ghostel-test-sigwinch-reaches-shell-ghostel-style ghostel-test-sigwinch-reaches-child-process