diff --git a/lisp/ghostel-line-mode.el b/lisp/ghostel-line-mode.el index d048eede..c84c97b3 100644 --- a/lisp/ghostel-line-mode.el +++ b/lisp/ghostel-line-mode.el @@ -429,6 +429,7 @@ in line mode (the interactive entry validates these)." (setq ghostel--line-mode-on-alt-screen (ghostel--line-mode-alt-screen-p)) (setq ghostel--line-mode-paused nil) (setq ghostel--input-mode 'line) + (ghostel--line-mode-enable-around-redraw) (use-local-map ghostel-line-mode-map) (setq ghostel--mode-line-tag (ghostel--mode-line-tag-make 'line ":Line")) (ghostel--mode-line-refresh) @@ -559,6 +560,7 @@ which discards any type-ahead and runs inside `ghostel--redraw-now'." ;; below so none of them are recorded. (setq buffer-undo-list t) (unless pause + (ghostel--line-mode-disable-around-redraw) (let ((input (ghostel--line-mode-input-text))) ;; Erase the adopted prefix from the shell's readline before ;; handing back our (possibly edited) version, so the shell ends @@ -628,6 +630,7 @@ already running. Stashes an empty snapshot so the alt-screen-off transition picks it up and enters line mode for real. Preserves any existing paused snapshot (the user re-arming should not clobber input captured by an earlier auto-pause)." + (ghostel--line-mode-enable-around-redraw) (unless ghostel--line-mode-paused (setq ghostel--line-mode-paused (list :input "" :point-offset nil :mark-offset nil))) @@ -663,6 +666,37 @@ screen. Also drives the deferred startup entry when (ghostel--line-mode-try-resume)) (ghostel--line-mode-maybe-enter-initial)) +(defun ghostel--line-mode-enable-around-redraw () + "Add line mode's buffer-local redraw wrapper." + (add-hook 'ghostel--around-redraw-functions + #'ghostel--line-mode-around-redraw 90 t)) + +(defun ghostel--line-mode-defer-initial-entry () + "Arm line mode to enter when the first prompt renders." + (setq ghostel--pending-initial-line-mode t) + (ghostel--line-mode-enable-around-redraw)) + +(defun ghostel--line-mode-disable-around-redraw () + "Remove line mode's buffer-local redraw wrapper." + (remove-hook 'ghostel--around-redraw-functions + #'ghostel--line-mode-around-redraw t)) + +(defun ghostel--line-mode-around-redraw (next) + "Run NEXT while preserving line-mode state across the redraw." + (ghostel--line-mode-pre-redraw) + (let* ((snapshot (ghostel--line-mode-snapshot)) + (redrew (funcall next)) + (restored (and snapshot + (ghostel--line-mode-restore snapshot)))) + (when (and snapshot (not restored)) + (let ((input (plist-get snapshot :input))) + (when (and input (> (length input) 0)) + (ghostel--write-pty ghostel--term input)) + (message "ghostel: line-mode prompt lost; input forwarded raw"))) + (when redrew + (ghostel--line-mode-post-redraw) + t))) + (defun ghostel--line-mode-startup-prompt-ready-p () "Non-nil when the cursor row shows a real prompt (OSC 133 prop or regex). Gates the deferred startup entry so line mode skips a blank pre-prompt screen. @@ -687,7 +721,8 @@ wins: a non-semi-char buffer drops the flag without entering." (when ghostel--pending-initial-line-mode (cond ((not (eq ghostel--input-mode 'semi-char)) - (setq ghostel--pending-initial-line-mode nil)) + (setq ghostel--pending-initial-line-mode nil) + (ghostel--line-mode-disable-around-redraw)) ((ghostel--line-mode-startup-prompt-ready-p) (setq ghostel--pending-initial-line-mode nil) (ghostel-line-mode))))) diff --git a/lisp/ghostel.el b/lisp/ghostel.el index 4edeb194..4109eede 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -4499,6 +4499,40 @@ run the shell on the remote host." ;;; Rendering +(defvar ghostel--around-redraw-functions + '(ghostel--redraw-maybe-defer + ghostel--redraw-detect-password-prompt + ghostel--redraw-detect-links + ghostel--redraw-anchor-windows + ghostel--redraw-apply-cursor-style) + "Functions composing a Ghostel redraw. +Each function is called with one argument, NEXT. Calling NEXT runs the +next function in the chain; not calling NEXT suppresses the rest of the +redraw. + +This is a hook-like variable. Buffer-local values may include the symbol +t to splice in the default value, matching normal local hook semantics. + +Around-redraw functions should call NEXT synchronously and return its non-nil +value when NEXT reaches an actual redraw.") + +(defun ghostel--run-around-redraw-list (functions redraw) + "Run REDRAW through around-redraw FUNCTIONS." + (if-let* ((function (car functions))) + (if (eq function t) + (ghostel--run-around-redraw-list + (default-value 'ghostel--around-redraw-functions) + (lambda () + (ghostel--run-around-redraw-list (cdr functions) redraw))) + (funcall function + (lambda () + (ghostel--run-around-redraw-list (cdr functions) redraw)))) + (funcall redraw))) + +(defun ghostel--run-around-redraw-functions (redraw) + "Run REDRAW through `ghostel--around-redraw-functions'." + (ghostel--run-around-redraw-list ghostel--around-redraw-functions redraw)) + (defvar-local ghostel--last-output-time nil "Time of the last process output, for adaptive frame rate.") @@ -4672,16 +4706,46 @@ user's point, since its input region is user-owned." orig (or ghostel--cursor-char-pos target)))))))) -(defun ghostel--maybe-defer-redraw (buffer) - "Defer BUFFER's redraw if a `ghostel-inhibit-redraw-functions' hook asks. -Return non-nil when deferred, after rescheduling `ghostel--redraw-now' -for BUFFER; return nil to let the redraw proceed." - (when (with-demoted-errors "ghostel-inhibit-redraw-functions error: %S" - (run-hook-with-args-until-success - 'ghostel-inhibit-redraw-functions buffer)) - (setq ghostel--redraw-timer - (run-with-timer ghostel-timer-delay nil - #'ghostel--redraw-now buffer)) +(defun ghostel--redraw-maybe-defer (next) + "Call NEXT unless `ghostel-inhibit-redraw-functions' asks to defer." + (if (with-demoted-errors "ghostel-inhibit-redraw-functions error: %S" + (run-hook-with-args-until-success + 'ghostel-inhibit-redraw-functions (current-buffer))) + (progn + (setq ghostel--redraw-timer + (run-with-timer ghostel-timer-delay nil + #'ghostel--redraw-now (current-buffer))) + nil) + (funcall next))) + +(defun ghostel--redraw-detect-password-prompt (next) + "Run NEXT, then detect password prompts if a redraw happened." + (when (funcall next) + (ghostel--detect-password-prompt) + t)) + +(defun ghostel--redraw-detect-links (next) + "Run NEXT, then schedule redraw-triggered plain-text link detection." + (when (funcall next) + (ghostel--schedule-link-detection (ghostel--viewport-start) + (point-max)) + t)) + +(defun ghostel--redraw-anchor-windows (next) + "Run NEXT, then re-anchor windows that followed the live viewport." + (let ((anchored (cl-remove-if-not #'ghostel--window-anchored-p + (get-buffer-window-list (current-buffer) + nil t)))) + (when (funcall next) + (dolist (win anchored) + (when (window-live-p win) + (ghostel--anchor-window win))) + t))) + +(defun ghostel--redraw-apply-cursor-style (next) + "Run NEXT, then apply the terminal-published cursor style." + (when (funcall next) + (ghostel--apply-cursor-style) t)) (defun ghostel--redraw-now (buffer &optional force) @@ -4700,63 +4764,25 @@ opportunistic output redraws that may safely wait for the frame to end." (cancel-timer ghostel--redraw-timer) (setq ghostel--redraw-timer nil)) (when (and ghostel--term - (ghostel--terminal-live-p) - (not (ghostel--maybe-defer-redraw buffer))) + (ghostel--terminal-live-p)) ;; Skip during synchronized output unless forced by scroll/resize. (unless (and (not ghostel--force-next-redraw) (ghostel--mode-enabled ghostel--term 2026)) - ;; Pause line mode if alt-screen just turned on — must run - ;; before the line-snapshot block so that snapshot sees the - ;; post-pause input mode and skips its own capture. - (ghostel--line-mode-pre-redraw) (setq ghostel--force-next-redraw nil) (when-let* ((render-win (ghostel--get-render-window buffer))) - (let* ((all-windows (get-buffer-window-list buffer nil t)) - (anchored (cl-remove-if-not #'ghostel--window-anchored-p - all-windows)) - ;; In line mode the user's in-progress input lives - ;; in the buffer past the prompt and is not in - ;; libghostty's grid; the renderer would otherwise - ;; clobber it. Snapshot the input region (and clear - ;; it from the buffer) before the redraw, then - ;; restore it after. - (line-snapshot (and (eq ghostel--input-mode 'line) - (ghostel--line-mode-snapshot))) - (inhibit-read-only t) + (let* ((inhibit-read-only t) (inhibit-redisplay t) (inhibit-modification-hooks t) ;; Raise GC threshold to defer GC during redraw. (gc-cons-threshold (min most-positive-fixnum (* gc-cons-threshold 3))) (ghostel--query-font-cache (make-hash-table :test 'eq))) - (with-selected-window render-win - ;; Line mode snapshots editable input out of the buffer; - ;; redraw fully so the prompt row is always rebuilt - ;; before restoring input at its fresh marker position. - (ghostel--redraw ghostel--term (eq ghostel--input-mode 'line))) - (ghostel--apply-cursor-style) - - (dolist (win anchored) (ghostel--anchor-window win)) - - (let* ((line-restored - (and line-snapshot - (ghostel--line-mode-restore line-snapshot)))) - ;; Restore failed (prompt scrolled off / shell - ;; integration dropped out): the input was already - ;; deleted from the buffer in snapshot — forward it raw - ;; so the user does not lose what they typed. - (when (and line-snapshot (not line-restored)) - (let ((input (plist-get line-snapshot :input))) - (when (and input (> (length input) 0)) - (ghostel--write-pty ghostel--term input)) - (message "ghostel: line-mode prompt lost; input forwarded raw"))) - - (ghostel--schedule-link-detection (ghostel--viewport-start) - (point-max))) - ;; Resume line mode if alt-screen just turned off, and - ;; update the alt-screen-prev cache for the next cycle. - (ghostel--line-mode-post-redraw)))) - (ghostel--detect-password-prompt))))) + (ghostel--run-around-redraw-functions + (lambda () + (with-selected-window render-win + (ghostel--redraw ghostel--term + (eq ghostel--input-mode 'line))) + t))))))))) (defun ghostel-force-redraw () "Force an immediate terminal redraw, bypassing synchronized-output batching. @@ -5126,7 +5152,7 @@ buffer creation time — see `ghostel--buffer-identity'." `semi-char' needs nothing." (pcase ghostel-initial-input-mode ('char (ghostel-char-mode)) - ('line (setq ghostel--pending-initial-line-mode t)))) + ('line (ghostel--line-mode-defer-initial-entry)))) ;;;###autoload (defun ghostel (&optional arg) diff --git a/test/ghostel-ime-test.el b/test/ghostel-ime-test.el index 46c05118..85a43cb0 100644 --- a/test/ghostel-ime-test.el +++ b/test/ghostel-ime-test.el @@ -21,41 +21,6 @@ ;; `quail-overlay' is defined by quail.el, which is not loaded in batch. (defvar quail-overlay) -(ert-deftest ghostel-test-ime-inhibit-hook-defers-redraw () - "A non-nil `ghostel-inhibit-redraw-functions' reschedules the redraw. -The buffer must not be redrawn while a feature inhibits it." - (ghostel-test--with-compile-buffer buf - (let ((old-timer (run-with-timer 1000 nil #'ignore)) - timer-delay timer-repeat timer-fn timer-args) - (setq-local ghostel--redraw-timer old-timer) - (setq-local ghostel--term 'fake-term) - (add-hook 'ghostel-inhibit-redraw-functions - (lambda (buffer) - (should (eq buffer buf)) - (should (eq (current-buffer) buf)) - t) - nil t) - (cl-letf (((symbol-function 'ghostel--terminal-live-p) - (lambda (&rest _) t)) - ((symbol-function 'ghostel--redraw) - (lambda (&rest _) - (ert-fail "Inhibited redraw must not call native redraw"))) - ((symbol-function 'run-with-timer) - (lambda (delay repeat fn &rest args) - (setq timer-delay delay - timer-repeat repeat - timer-fn fn - timer-args args) - 'ghostel-test-ime-timer))) - (ghostel--redraw-now buf)) - (should (equal timer-delay ghostel-timer-delay)) - (should (null timer-repeat)) - (should (eq timer-fn #'ghostel--redraw-now)) - (should (equal timer-args (list buf))) - (should (eq ghostel--redraw-timer 'ghostel-test-ime-timer)) - (when (timerp old-timer) - (cancel-timer old-timer))))) - (ert-deftest ghostel-test-ime-mode-installs-buffer-local-wrapper () "`ghostel-ime-mode' installs and removes its buffer-local wiring." (ghostel-test--with-compile-buffer buf diff --git a/test/ghostel-line-mode-test.el b/test/ghostel-line-mode-test.el index c1ea4a67..768f1d4c 100644 --- a/test/ghostel-line-mode-test.el +++ b/test/ghostel-line-mode-test.el @@ -2010,8 +2010,8 @@ prompt and engages on the redraw that exposes one." (setq ghostel-detect-password-prompts nil) (set-window-buffer (selected-window) buf) (setq ghostel--process 'fake-proc) - ;; Arm exactly as `ghostel--apply-initial-input-mode' would. - (setq ghostel--pending-initial-line-mode t) + (let ((ghostel-initial-input-mode 'line)) + (ghostel--apply-initial-input-mode)) (cl-letf (((symbol-function 'process-live-p) (lambda (_p) t)) ((symbol-function 'ghostel--write-pty) #'ignore) ((symbol-function 'ghostel--invalidate) #'ignore)) @@ -2034,7 +2034,8 @@ prompt and engages on the redraw that exposes one." (setq ghostel-detect-password-prompts nil) (set-window-buffer (selected-window) buf) (setq ghostel--process 'fake-proc) - (setq ghostel--pending-initial-line-mode t) + (let ((ghostel-initial-input-mode 'line)) + (ghostel--apply-initial-input-mode)) (cl-letf (((symbol-function 'process-live-p) (lambda (_p) t)) ((symbol-function 'ghostel--write-pty) #'ignore) ((symbol-function 'ghostel--invalidate) #'ignore)) diff --git a/test/ghostel-render-test.el b/test/ghostel-render-test.el index eca9f301..9aab5e39 100644 --- a/test/ghostel-render-test.el +++ b/test/ghostel-render-test.el @@ -1642,6 +1642,104 @@ rendered by `ghostel--redraw-now'. This is the exact real-world path." ;;; Delayed redraw and hidden buffers +(ert-deftest ghostel-test-around-redraw-functions-run-in-order () + "Around-redraw functions compose in list order around the redraw core." + (let ((ghostel--around-redraw-functions nil) + calls) + (let ((outer (lambda (next) + (push 'outer-before calls) + (funcall next) + (push 'outer-after calls))) + (inner (lambda (next) + (push 'inner-before calls) + (funcall next) + (push 'inner-after calls)))) + (add-hook 'ghostel--around-redraw-functions outer 90) + (add-hook 'ghostel--around-redraw-functions inner 90) + (ghostel--run-around-redraw-functions + (lambda () (push 'redraw calls))) + (should (equal (nreverse calls) + '(outer-before inner-before redraw + inner-after outer-after)))))) + +(ert-deftest ghostel-test-around-redraw-function-can-short-circuit () + "An around-redraw function can suppress the remaining redraw chain." + (let ((ghostel--around-redraw-functions nil) + calls) + (let ((outer (lambda (next) + (push 'outer-before calls) + (funcall next) + (push 'outer-after calls))) + (stop (lambda (_next) + (push 'stop calls))) + (inner (lambda (next) + (push 'inner-before calls) + (funcall next) + (push 'inner-after calls)))) + (add-hook 'ghostel--around-redraw-functions outer 90) + (add-hook 'ghostel--around-redraw-functions stop 90) + (add-hook 'ghostel--around-redraw-functions inner 90) + (ghostel--run-around-redraw-functions + (lambda () (push 'redraw calls))) + (should (equal (nreverse calls) + '(outer-before stop outer-after)))))) + +(ert-deftest ghostel-test-around-redraw-inhibit-hook-defers-redraw () + "A non-nil `ghostel-inhibit-redraw-functions' reschedules the redraw." + (let (timer-delay timer-repeat timer-fn timer-args) + (add-hook 'ghostel-inhibit-redraw-functions + (lambda (buffer) + (should (eq buffer (current-buffer))) + t) + nil t) + (cl-letf (((symbol-function 'run-with-timer) + (lambda (delay repeat fn &rest args) + (setq timer-delay delay + timer-repeat repeat + timer-fn fn + timer-args args) + 'ghostel-test-redraw-timer))) + (should-not (ghostel--redraw-maybe-defer + (lambda () + (ert-fail "Inhibited redraw must not continue"))))) + (should (equal timer-delay ghostel-timer-delay)) + (should (null timer-repeat)) + (should (eq timer-fn #'ghostel--redraw-now)) + (should (equal timer-args (list (current-buffer)))))) + +(ert-deftest ghostel-test-around-redraw-local-functions-splice-defaults () + "Buffer-local around-redraw functions splice the default hook list." + (let ((old-default (default-value 'ghostel--around-redraw-functions)) + calls) + (unwind-protect + (let ((global (lambda (next) + (push 'global-before calls) + (funcall next) + (push 'global-after calls))) + (local-before (lambda (next) + (push 'local-before-before calls) + (funcall next) + (push 'local-before-after calls))) + (local-after (lambda (next) + (push 'local-after-before calls) + (funcall next) + (push 'local-after-after calls)))) + (set-default 'ghostel--around-redraw-functions (list global)) + (with-temp-buffer + (add-hook 'ghostel--around-redraw-functions local-before nil t) + (add-hook 'ghostel--around-redraw-functions local-after 90 t) + (ghostel--run-around-redraw-functions + (lambda () (push 'redraw calls)))) + (should (equal (nreverse calls) + '(local-before-before + global-before + local-after-before + redraw + local-after-after + global-after + local-before-after)))) + (set-default 'ghostel--around-redraw-functions old-default)))) + (ert-deftest ghostel-test-delayed-redraw-skips-native-redraw-without-window () "When the buffer has no window, `ghostel--redraw-now' must not call \ `ghostel--redraw'."