From 3dad6db599ce010a887ea92001fd5c5d940f9ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Sahl=C3=A9n?= Date: Mon, 13 Jul 2026 21:01:42 +0200 Subject: [PATCH] Fix hidden terminal redraw recovery Keep hidden output pending until the buffer is redisplayed, and make synchronized-output admission atomic with rendering. Propagate redraw completion so pending state clears only after a successful render. --- extensions/evil-ghostel/evil-ghostel.el | 55 +++---- lisp/ghostel-line-mode.el | 5 +- lisp/ghostel.el | 188 ++++++++++++------------ src/GhostelTerm.zig | 19 ++- src/Renderer.zig | 4 +- test/evil-ghostel-test.el | 36 +++-- test/ghostel-ime-test.el | 3 +- test/ghostel-keys-test.el | 33 ++++- test/ghostel-links-test.el | 10 +- test/ghostel-render-test.el | 110 ++++++-------- 10 files changed, 251 insertions(+), 212 deletions(-) diff --git a/extensions/evil-ghostel/evil-ghostel.el b/extensions/evil-ghostel/evil-ghostel.el index f84c848a..1fe23b99 100644 --- a/extensions/evil-ghostel/evil-ghostel.el +++ b/extensions/evil-ghostel/evil-ghostel.el @@ -146,9 +146,10 @@ No window showing the buffer counts as following." ;; column) while elsewhere on the cursor's row. Off that row point ;; stays put. Visual markers are restored around the redraw. -(defun evil-ghostel--around-redraw (orig-fn term &optional full) +(defun evil-ghostel--around-redraw (orig-fn term &optional full force-sync) "Apply Evil point/visual handling around `ghostel--redraw'. -ORIG-FN is the advised function (TERM, FULL). Skipped in alt-screen (1049)." +ORIG-FN is the advised function (TERM, FULL, FORCE-SYNC). Skipped in +alt-screen (1049) and when the native renderer defers synchronized output." (if (and evil-ghostel-mode (not (ghostel--mode-enabled term 1049))) (let* ((visual-p (eq evil-state 'visual)) @@ -169,30 +170,32 @@ ORIG-FN is the advised function (TERM, FULL). Skipped in alt-screen (1049)." (saved-vb (and visual-p (bound-and-true-p evil-visual-beginning) (marker-position evil-visual-beginning))) (saved-ve (and visual-p (bound-and-true-p evil-visual-end) - (marker-position evil-visual-end)))) - (funcall orig-fn term full) - (cond - ;; Exact target; column geometry can diverge on wide glyphs. - (tracked - (when ghostel--cursor-char-pos - (goto-char ghostel--cursor-char-pos))) - ;; Roamed within the cursor's row: follow the row, keep the column. - (saved-col - (when ghostel--cursor-char-pos - (goto-char ghostel--cursor-char-pos) - (move-to-column saved-col))) - ;; Don't drag point to the cursor while the user reads scrollback; - ;; redisplay would yank the viewport back to the bottom each frame. - ((and (memq evil-state '(insert emacs)) - (evil-ghostel--following-window-p)) - (evil-ghostel--reset-cursor-point))) - (when visual-p - (let ((pmax (point-max))) - (when saved-vb - (set-marker evil-visual-beginning (min saved-vb pmax))) - (when saved-ve - (set-marker evil-visual-end (min saved-ve pmax)))))) - (funcall orig-fn term full))) + (marker-position evil-visual-end))) + (rendered (funcall orig-fn term full force-sync))) + (when rendered + (cond + ;; Exact target; column geometry can diverge on wide glyphs. + (tracked + (when ghostel--cursor-char-pos + (goto-char ghostel--cursor-char-pos))) + ;; Roamed within the cursor's row: follow the row, keep the column. + (saved-col + (when ghostel--cursor-char-pos + (goto-char ghostel--cursor-char-pos) + (move-to-column saved-col))) + ;; Don't drag point to the cursor while the user reads scrollback; + ;; redisplay would yank the viewport back to the bottom each frame. + ((and (memq evil-state '(insert emacs)) + (evil-ghostel--following-window-p)) + (evil-ghostel--reset-cursor-point))) + (when visual-p + (let ((pmax (point-max))) + (when saved-vb + (set-marker evil-visual-beginning (min saved-vb pmax))) + (when saved-ve + (set-marker evil-visual-end (min saved-ve pmax)))))) + rendered) + (funcall orig-fn term full force-sync))) (defun evil-ghostel--anchor-inhibit (_window force) "Veto ghostel's redraw anchor while point roams off the live cursor. diff --git a/lisp/ghostel-line-mode.el b/lisp/ghostel-line-mode.el index f0225d57..cf4bdb3c 100644 --- a/lisp/ghostel-line-mode.el +++ b/lisp/ghostel-line-mode.el @@ -26,7 +26,8 @@ (declare-function ghostel--mode-line-refresh "ghostel") (declare-function ghostel--mode-line-tag-make "ghostel") (declare-function ghostel--open-link "ghostel") -(declare-function ghostel--redraw "ghostel-module" (term &optional full)) +(declare-function ghostel--redraw "ghostel-module" + (term &optional full force-sync)) (declare-function ghostel--regex-prompt-end "ghostel") (declare-function ghostel--send-encoded "ghostel") (declare-function ghostel--uri-at-pos "ghostel") @@ -591,7 +592,7 @@ which discards any type-ahead and runs inside `ghostel--redraw-now'." (unless pause (when ghostel--term (let ((inhibit-read-only t)) - (ghostel--redraw ghostel--term t))))) + (ghostel--redraw ghostel--term t t))))) (defun ghostel--line-mode-pause () "Drop line mode to semi-char while a full-screen app holds the alt screen. diff --git a/lisp/ghostel.el b/lisp/ghostel.el index 723af301..afd9da04 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -907,7 +907,8 @@ to nil to disable the regex fallback entirely (OSC 133 only)." (declare-function ghostel--module-version "ghostel-module") (declare-function ghostel--mouse-event "ghostel-module") (declare-function ghostel--new "ghostel-module") -(declare-function ghostel--redraw "ghostel-module" (term &optional full)) +(declare-function ghostel--redraw "ghostel-module" + (term &optional full force-sync)) (declare-function ghostel--set-bold-config "ghostel-module") (declare-function ghostel--set-default-colors "ghostel-module") (declare-function ghostel--set-palette "ghostel-module") @@ -978,7 +979,7 @@ Matches Ghostty 1.2.0's `bold-color' configuration." (when (and (derived-mode-p 'ghostel-mode) ghostel--term) (ghostel--apply-bold-config ghostel--term) (let ((inhibit-read-only t)) - (ghostel--redraw ghostel--term t)) + (ghostel--redraw ghostel--term t t)) (ghostel--apply-cursor-style)))))) (defvar-local ghostel--cursor-pos nil @@ -1032,6 +1033,9 @@ local code should not assume it is signalable unless the process is local.") (defvar-local ghostel--redraw-timer nil "Timer for delayed redraw.") +(defvar-local ghostel--pending-redraw nil + "Non-nil when redraw is needed when buffer is displayed again.") + (defvar-local ghostel--plain-link-detection-timer nil "Timer for delayed redraw-triggered plain-text link detection.") @@ -3806,9 +3810,6 @@ EVENT is the state-change description passed by Emacs." (let ((buf (process-buffer process))) (when (buffer-live-p buf) (with-current-buffer buf - (when ghostel--redraw-timer - (cancel-timer ghostel--redraw-timer) - (setq ghostel--redraw-timer nil)) (when ghostel--plain-link-detection-timer (cancel-timer ghostel--plain-link-detection-timer) (setq ghostel--plain-link-detection-timer nil @@ -4552,35 +4553,39 @@ terminal windows." (defun ghostel--invalidate () "Trigger a redraw for pending terminal output. -Output arriving within `ghostel-immediate-redraw-interval' of the last -keystroke is interactive echo and redrawn immediately to minimize -typing latency. Otherwise the redraw is deferred to a coalescing -timer; with `ghostel-adaptive-fps' that timer uses a shorter delay for -the first frame after idle for snappier response." - ;; Interactive echo: output arriving within - ;; `ghostel-immediate-redraw-interval' of the last keystroke. - (if (and ghostel--last-send-time - (< (float-time (time-subtract (current-time) - ghostel--last-send-time)) - ghostel-immediate-redraw-interval)) - (ghostel--redraw-now (current-buffer)) - ;; Bulk output: schedule a later redraw. - (unless ghostel--redraw-timer - (let ((delay (if (and ghostel-adaptive-fps ghostel--last-output-time) - (let ((idle-secs (float-time - (time-subtract (current-time) - ghostel--last-output-time)))) - ;; If idle for more than 100ms, use a short delay - ;; for snappy first-frame response. - (if (> idle-secs 0.1) - (min 0.016 ghostel-timer-delay) - ghostel-timer-delay)) - ghostel-timer-delay))) - (setq ghostel--last-output-time (current-time)) - (setq ghostel--redraw-timer - (run-with-timer delay nil - #'ghostel--redraw-now - (current-buffer))))))) +Visible output arriving within `ghostel-immediate-redraw-interval' of the +last keystroke is redrawn immediately; other visible output uses a coalescing +timer. Hidden output remains pending until the buffer is displayed." + (if (ghostel--get-render-window (current-buffer)) + ;; Interactive echo: output arriving within + ;; `ghostel-immediate-redraw-interval' of the last keystroke. + (if (and ghostel--last-send-time + (< (float-time (time-subtract (current-time) + ghostel--last-send-time)) + ghostel-immediate-redraw-interval)) + (ghostel--redraw-now (current-buffer)) + ;; Bulk output: schedule a later redraw. + (unless ghostel--redraw-timer + (let ((delay (if (and ghostel-adaptive-fps ghostel--last-output-time) + (let ((idle-secs + (float-time + (time-subtract + (current-time) ghostel--last-output-time)))) + ;; If idle for more than 100ms, use a short delay + ;; for snappy first-frame response. + (if (> idle-secs 0.1) + (min 0.016 ghostel-timer-delay) + ghostel-timer-delay)) + ghostel-timer-delay))) + (setq ghostel--last-output-time (current-time)) + (setq ghostel--redraw-timer + (run-with-timer delay nil + #'ghostel--redraw-now + (current-buffer)))))) + (when ghostel--redraw-timer + (cancel-timer ghostel--redraw-timer) + (setq ghostel--redraw-timer nil)) + (setq ghostel--pending-redraw t))) (defun ghostel--viewport-start () "Position of the first line of the terminal viewport, or nil if rows<=0." @@ -4756,16 +4761,16 @@ for BUFFER; return nil to let the redraw proceed." t)) (defun ghostel--redraw-now (buffer &optional force) - "Perform the actual redraw in BUFFER. + "Attempt to redraw BUFFER and retain the request until it succeeds. The renderer preserves buffer positions while applying terminal mutations; this function anchors windows that were following the live viewport. With FORCE non-nil, redraw even while synchronized output (mode 2026) -is open. Use it for repaints that must happen now regardless of frame -batching, such as a buffer reappearing in a window; leave it nil for -opportunistic output redraws that may safely wait for the frame to end." +is open. Normal redraws remain pending when the native renderer declines +them during synchronized output or when BUFFER has no render window." (when (buffer-live-p buffer) (with-current-buffer buffer + (setq ghostel--pending-redraw t) (when force (setq ghostel--force-next-redraw t)) (when ghostel--redraw-timer (cancel-timer ghostel--redraw-timer) @@ -4773,57 +4778,48 @@ opportunistic output redraws that may safely wait for the frame to end." (when (and ghostel--term (ghostel--terminal-live-p) (not (ghostel--maybe-defer-redraw buffer))) - ;; 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. + (when-let* ((render-win (ghostel--get-render-window buffer))) + ;; Pause line mode if alt-screen just turned on. This must run + ;; before the snapshot so a pause can take ownership of its input. (ghostel--line-mode-pre-redraw) - (setq ghostel--force-next-redraw nil) - (when-let* ((render-win (ghostel--get-render-window buffer))) - (let* ((anchored (ghostel--anchored-windows buffer t)) - ;; 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) - (inhibit-redisplay t) - (inhibit-modification-hooks t) - ;; Disable GC during redraw. - (gc-cons-threshold most-positive-fixnum)) - (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))))) + (let* ((anchored (ghostel--anchored-windows buffer t)) + ;; Line-mode input is not part of libghostty's grid. Remove + ;; it while native rendering runs, then restore it after + ;; rendering. + (line-snapshot (and (eq ghostel--input-mode 'line) + (ghostel--line-mode-snapshot))) + (inhibit-read-only t) + (inhibit-redisplay t) + (inhibit-modification-hooks t) + (gc-cons-threshold most-positive-fixnum) + (rendered + (with-selected-window render-win + ;; FULL and FORCE-SYNC are separate native policies. + (ghostel--redraw ghostel--term + (eq ghostel--input-mode 'line) + ghostel--force-next-redraw)))) + (when rendered + (setq ghostel--pending-redraw nil + ghostel--force-next-redraw nil)) + (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 because the prompt moved or disappeared; + ;; forward the saved input rather than silently losing it. + (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))))))) (defun ghostel-force-redraw () "Force an immediate terminal redraw, bypassing synchronized-output batching. @@ -4970,16 +4966,16 @@ and the TTY display that needs it off keeps working in parallel)." (unless (equal auto-composition-mode tt) (setq-local auto-composition-mode tt))))) +(defun ghostel--pre-redisplay (_window) + "Render pending terminal state before displaying this buffer." + (when ghostel--pending-redraw + (ghostel--redraw-now (current-buffer)))) + (defun ghostel--window-buffer-change (window) - "Anchor WINDOW and redraw it immediately when its buffer is displayed." + "Anchor WINDOW when its buffer is displayed." (when (and (window-live-p window) (eq (window-buffer window) (current-buffer))) - (ghostel--anchor-window window) - ;; While hidden the buffer renders nothing, so its content can be - ;; stale on reappear. Force the repaint past any open synchronized - ;; output (mode 2026); otherwise the redraw is skipped and the stale - ;; content lingers until the next non-2026 output, which may never come. - (ghostel--redraw-now (current-buffer) t))) + (ghostel--anchor-window window))) (defun ghostel--minibuffer-exit () "Schedule anchoring of all the currently anchored Ghostel windows. @@ -5068,6 +5064,7 @@ for both native and Emacs PTY paths." (add-hook 'window-buffer-change-functions #'ghostel--focus-change) (add-hook 'window-buffer-change-functions #'ghostel--window-buffer-change nil t) (add-hook 'window-buffer-change-functions #'ghostel--sync-tty-composition nil t) + (add-hook 'pre-redisplay-functions #'ghostel--pre-redisplay nil t) (add-hook 'window-size-change-functions #'ghostel--adjust-size nil t) (add-hook 'minibuffer-exit-hook #'ghostel--minibuffer-exit) (add-hook 'minibuffer-exit-hook #'ghostel--minibuffer-exit-maybe-leave) @@ -5161,6 +5158,7 @@ spawn after initialization." ghostel--command-running nil ghostel--event-buf nil ghostel--redraw-timer nil + ghostel--pending-redraw nil ghostel--plain-link-detection-timer nil ghostel--plain-link-detection-begin nil ghostel--plain-link-detection-end nil diff --git a/src/GhostelTerm.zig b/src/GhostelTerm.zig index a46f3de1..f517c70e 100644 --- a/src/GhostelTerm.zig +++ b/src/GhostelTerm.zig @@ -75,13 +75,14 @@ pub fn deinit(self: *Self) void { self.alloc.destroy(self); } -pub fn redraw(self: *Self, force_full: bool) !void { +pub fn redraw(self: *Self, force_full: bool, force_sync: bool) !bool { self.lockTerm(); defer self.unlockTerm(); - const env = emacs.current_env orelse return; + const env = emacs.current_env orelse return false; const pre_size = .{ self.terminal.cols, self.terminal.rows }; - try self.renderer.redraw(env, force_full); + if (!try self.renderer.redraw(env, force_full, force_sync)) return false; + _ = env.f("ghostel--kitty-clear", .{}); try kitty_graphics.emitPlacements(env, self); const post_size = .{ self.terminal.cols, self.terminal.rows }; @@ -96,6 +97,7 @@ pub fn redraw(self: *Self, force_full: bool) !void { ); } } + return true; } /// Set the color palette (256 entries). @@ -535,18 +537,21 @@ pub const emacs_functions = [_]emacs.FunctionEntry{ }, .{ .name = "ghostel--redraw", - .arity = .{ 1, 2 }, + .arity = .{ 1, 3 }, .doc = \\Redraw the terminal into the current buffer. \\ - \\(ghostel--redraw TERM &optional FULL) + \\(ghostel--redraw TERM &optional FULL FORCE-SYNC) + \\ + \\Return non-nil when rendering completed. Unless FORCE-SYNC is + \\non-nil, return nil without rendering during synchronized output. , .impl = struct { pub fn call(env: emacs.Env, nargs: isize, args: [*c]emacs.Value) !emacs.Value { const term = env.getUserPtr(Self, args[0]) orelse return error.InvalidTerminalHandle; const force_full = nargs > 1 and env.isNotNil(args[1]); - try term.redraw(force_full); - return env.nil(); + const force_sync = nargs > 2 and env.isNotNil(args[2]); + return env.makeValue(try term.redraw(force_full, force_sync)); } }, }, diff --git a/src/Renderer.zig b/src/Renderer.zig index a242f4a3..a2edaeff 100644 --- a/src/Renderer.zig +++ b/src/Renderer.zig @@ -154,7 +154,8 @@ pub fn resize(self: *Self, cols: u16, rows: u16, cell_w: u32, cell_h: u32) !void }; } -pub fn redraw(self: *Self, env: emacs.Env, force_full: bool) !void { +pub fn redraw(self: *Self, env: emacs.Env, force_full: bool, force_sync: bool) !bool { + if (!force_sync and self.term.modes.get(.synchronized_output)) return false; if (self.is_rendering) return error.ReentrantRedraw; self.is_rendering = true; defer self.is_rendering = false; @@ -190,6 +191,7 @@ pub fn redraw(self: *Self, env: emacs.Env, force_full: bool) !void { screen.pages.rows else screen.pages.total_rows; + return true; } fn clearPageDirtyFlags(from_node: *gt.PageList.List.Node) void { diff --git a/test/evil-ghostel-test.el b/test/evil-ghostel-test.el index dcaecd3b..895b3c6c 100644 --- a/test/evil-ghostel-test.el +++ b/test/evil-ghostel-test.el @@ -244,11 +244,12 @@ wrapper from every other ghostel buffer." The mock erases and reinserts the same text so these tests exercise `evil-ghostel--around-redraw' independent of renderer marker handling." `(cl-letf (((symbol-function 'ghostel--redraw) - (lambda (_term &optional _full) + (lambda (_term &optional _full _force-sync) (let ((text (buffer-string)) (inhibit-read-only t)) (erase-buffer) - (insert text)))) + (insert text) + t))) ((symbol-function 'ghostel--mode-enabled) (lambda (_term _mode) nil))) ,@body)) @@ -264,8 +265,9 @@ The mock erases and reinserts the same text so these tests exercise (goto-char (point-min)) (search-forward "four")))) (cl-letf (((symbol-function 'ghostel--redraw) - (lambda (_term &optional _full) - (goto-char renderer-point))) + (lambda (_term &optional _full _force-sync) + (goto-char renderer-point) + t)) ((symbol-function 'ghostel--mode-enabled) (lambda (_term _mode) nil))) (evil-ghostel--around-redraw (symbol-function 'ghostel--redraw) nil)) @@ -276,7 +278,7 @@ The mock erases and reinserts the same text so these tests exercise The mock appends rows below, advances the cursor to the new prompt at `point-max', and leaves point untouched, like the renderer." (cl-letf (((symbol-function 'ghostel--redraw) - (lambda (_term &optional _full) + (lambda (_term &optional _full _force-sync) (save-excursion (goto-char (point-max)) (evil-ghostel-test--insert "\nout-1\nout-2\n$ ")) @@ -343,7 +345,7 @@ snapping to the cursor or stranding in scrollback." ghostel--cursor-pos '(0 . 2)) (goto-char (point-min)) (search-forward "five") - (cl-letf (((symbol-function 'ghostel--redraw) #'ignore) + (cl-letf (((symbol-function 'ghostel--redraw) (lambda (&rest _) t)) ((symbol-function 'ghostel--mode-enabled) (lambda (_term _mode) nil))) (evil-ghostel--around-redraw (symbol-function 'ghostel--redraw) 'fake)) @@ -368,6 +370,19 @@ snapping to the cursor or stranding in scrollback." (should (= vb-target (marker-position evil-visual-beginning))) (should (= ve-target (marker-position evil-visual-end))))))) +(ert-deftest evil-ghostel-test-around-redraw-propagates-native-refusal () + "A deferred native redraw skips Evil post-processing and returns nil." + (evil-ghostel-test--with-evil-buffer + (let ((evil-state 'insert) + reset-called) + (cl-letf (((symbol-function 'ghostel--mode-enabled) + (lambda (_term _mode) nil)) + ((symbol-function 'evil-ghostel--reset-cursor-point) + (lambda () (setq reset-called t)))) + (should-not + (evil-ghostel--around-redraw (lambda (&rest _) nil) 'fake)) + (should-not reset-called))))) + (ert-deftest evil-ghostel-test-around-redraw-bypassed-in-alt-screen () "Advice is a passthrough when the terminal is in alt-screen mode (1049). Fullscreen TUIs own the screen and drive their own redraw cycle; the @@ -378,12 +393,13 @@ advice must not restore point or visual markers there." (goto-char (point-min)) (search-forward "three") (cl-letf (((symbol-function 'ghostel--redraw) - (lambda (_term &optional _full) + (lambda (_term &optional _full _force-sync) (let ((text (buffer-string)) (inhibit-read-only t)) (erase-buffer) (insert text) - (goto-char (point-min))))) + (goto-char (point-min)) + t))) ((symbol-function 'ghostel--mode-enabled) (lambda (_term mode) (= mode 1049)))) (evil-ghostel--around-redraw (symbol-function 'ghostel--redraw) nil)) @@ -425,14 +441,14 @@ it still snaps point to the cursor." (goto-char (point-min)) (set-window-start win (point-min) t) (should-not (ghostel--window-anchored-p win)) - (evil-ghostel--around-redraw #'ignore 'fake) + (evil-ghostel--around-redraw (lambda (&rest _) t) 'fake) (should (= (point) (point-min))) ;; Following the bottom: point snaps to the live cursor. (goto-char (point-max)) (ghostel--anchor-window win) (should (ghostel--window-anchored-p win)) (goto-char (point-min)) - (evil-ghostel--around-redraw #'ignore 'fake) + (evil-ghostel--around-redraw (lambda (&rest _) t) 'fake) (should (/= (point) (point-min))))))) (when (buffer-live-p buf) (with-current-buffer buf (evil-ghostel-mode -1))) diff --git a/test/ghostel-ime-test.el b/test/ghostel-ime-test.el index 20732671..6ba758b3 100644 --- a/test/ghostel-ime-test.el +++ b/test/ghostel-ime-test.el @@ -14,7 +14,8 @@ (require 'ghostel-ime) (declare-function ghostel--redraw-now "ghostel" (buffer &optional force)) -(declare-function ghostel--redraw "ghostel" (term &optional full)) +(declare-function ghostel--redraw "ghostel" + (term &optional full force-sync)) (declare-function ghostel--terminal-input-mode-p "ghostel") (declare-function ghostel--send-string "ghostel" (string)) diff --git a/test/ghostel-keys-test.el b/test/ghostel-keys-test.el index c756a8f5..5a0a95e2 100644 --- a/test/ghostel-keys-test.el +++ b/test/ghostel-keys-test.el @@ -179,11 +179,14 @@ instead of waiting for the redraw timer." (ghostel--redraw-timer nil) (redraw-now-called nil) (timer-scheduled nil)) - (cl-letf (((symbol-function 'ghostel--redraw-now) + (cl-letf (((symbol-function 'ghostel--get-render-window) + (lambda (&rest _) t)) + ((symbol-function 'ghostel--redraw-now) (lambda (_buf) (setq redraw-now-called t))) ((symbol-function 'run-with-timer) (lambda (&rest _) (setq timer-scheduled t) 'fake-timer))) (ghostel--invalidate) + (should-not ghostel--pending-redraw) (should redraw-now-called) (should-not timer-scheduled) (should-not ghostel--redraw-timer))))) @@ -199,11 +202,14 @@ must not redraw synchronously." (ghostel--redraw-timer nil) (redraw-now-called nil) (timer-scheduled nil)) - (cl-letf (((symbol-function 'ghostel--redraw-now) + (cl-letf (((symbol-function 'ghostel--get-render-window) + (lambda (&rest _) t)) + ((symbol-function 'ghostel--redraw-now) (lambda (_buf) (setq redraw-now-called t))) ((symbol-function 'run-with-timer) (lambda (&rest _) (setq timer-scheduled t) 'fake-timer))) (ghostel--invalidate) + (should-not ghostel--pending-redraw) (should-not redraw-now-called) (should timer-scheduled) (should (eq ghostel--redraw-timer 'fake-timer)))))) @@ -218,15 +224,36 @@ Without this guard a flood would spawn a storm of redraw timers." (ghostel--redraw-timer 'existing-timer) (redraw-now-called nil) (timer-scheduled nil)) - (cl-letf (((symbol-function 'ghostel--redraw-now) + (cl-letf (((symbol-function 'ghostel--get-render-window) + (lambda (&rest _) t)) + ((symbol-function 'ghostel--redraw-now) (lambda (_buf) (setq redraw-now-called t))) ((symbol-function 'run-with-timer) (lambda (&rest _) (setq timer-scheduled t) 'fake-timer))) (ghostel--invalidate) + (should-not ghostel--pending-redraw) (should-not redraw-now-called) (should-not timer-scheduled) (should (eq ghostel--redraw-timer 'existing-timer)))))) +(ert-deftest ghostel-test-invalidate-hidden-buffer-uses-pending-state () + "Hidden output cancels its timer and remains pending until redisplay." + (with-temp-buffer + (let ((ghostel--redraw-timer 'existing-timer) + cancelled + timer-scheduled) + (cl-letf (((symbol-function 'ghostel--get-render-window) + (lambda (&rest _) nil)) + ((symbol-function 'cancel-timer) + (lambda (timer) (setq cancelled timer))) + ((symbol-function 'run-with-timer) + (lambda (&rest _) (setq timer-scheduled t)))) + (ghostel--invalidate) + (should ghostel--pending-redraw) + (should (eq cancelled 'existing-timer)) + (should-not ghostel--redraw-timer) + (should-not timer-scheduled))))) + (ert-deftest ghostel-test-send-string-writes-to-pty-and-records-send-time () "`ghostel--send-string' writes to the PTY boundary and records send time." (with-temp-buffer diff --git a/test/ghostel-links-test.el b/test/ghostel-links-test.el index 7d331fed..292f9a62 100644 --- a/test/ghostel-links-test.el +++ b/test/ghostel-links-test.el @@ -537,9 +537,8 @@ E.g. `compilation-mode' error loci in `ghostel-compile-view-mode'." timer-fn fn timer-args args) 'ghostel-test-link-timer)) - ((symbol-function 'ghostel--mode-enabled) - (lambda (&rest _) nil)) - ((symbol-function 'ghostel--redraw) #'ignore) + ((symbol-function 'ghostel--redraw) + (lambda (&rest _) t)) ((symbol-function 'ghostel--window-anchored-p) #'ignore) ((symbol-function 'ghostel--viewport-start) (lambda () nil))) @@ -587,9 +586,8 @@ E.g. `compilation-mode' error loci in `ghostel-compile-view-mode'." timer-fn fn timer-args args) 'ghostel-test-link-timer)) - ((symbol-function 'ghostel--mode-enabled) - (lambda (&rest _) nil)) - ((symbol-function 'ghostel--redraw) #'ignore) + ((symbol-function 'ghostel--redraw) + (lambda (&rest _) t)) ((symbol-function 'ghostel--window-anchored-p) #'ignore) ((symbol-function 'ghostel--viewport-start) (lambda () nil))) diff --git a/test/ghostel-render-test.el b/test/ghostel-render-test.el index 3690108c..6db5a77c 100644 --- a/test/ghostel-render-test.el +++ b/test/ghostel-render-test.el @@ -1686,29 +1686,27 @@ closing it - the state of a buffer hidden while a TUI streams a frame." ,@body)) (kill-buffer ,buffer))))) -(ert-deftest ghostel-test-redraw-now-force-bypasses-open-sync-output () - "FORCE makes `ghostel--redraw-now' repaint even while mode 2026 is open. -Without FORCE the redraw is correctly suppressed during synchronized -output; with FORCE it repaints now (the contract the #456 fix relies on)." - :tags '(native) - (ghostel-test--with-open-sync-output (buf term) - ;; Unforced redraw is suppressed mid synchronized output: still baseline. - (ghostel--redraw-now buf) - (should (string-match-p "BASELINE-CONTENT" (buffer-string))) - (should-not (string-match-p "UPDATED-CONTENT" (buffer-string))) - ;; Forced redraw bypasses the skip and repaints now. - (ghostel--redraw-now buf t) - (should (string-match-p "UPDATED-CONTENT" (buffer-string))) - (should-not (string-match-p "BASELINE-CONTENT" (buffer-string))))) +(ert-deftest ghostel-test-native-redraw-reports-sync-refusal () + "Native redraw reports whether synchronized-output gating admitted it." + :tags '(native) + (ghostel-test--with-open-sync-output (_buf term) + (should-not (ghostel--redraw term)))) -(ert-deftest ghostel-test-window-buffer-change-repaints-stale-reappear () - "Regression for #456: a buffer reappearing repaints past open mode 2026. -A hidden ghostel buffer renders nothing, so its content is stale on -reappear. `ghostel--window-buffer-change' must force the repaint instead -of leaving the stale content until the next non-2026 output." +(ert-deftest ghostel-test-sync-refusal-does-not-commit-pending-resize () + "A refused redraw performs no renderer work, including resize commit." :tags '(native) (ghostel-test--with-open-sync-output (_buf term) - (ghostel--window-buffer-change (selected-window)) + (ghostel--set-size term 6 40) + (should-not (ghostel--redraw term)) + (should (= ghostel--term-rows 10)) + (should (ghostel--redraw term nil t)) + (should (= ghostel--term-rows 6)))) + +(ert-deftest ghostel-test-redraw-now-force-bypasses-open-sync-output () + "FORCE makes `ghostel--redraw-now' repaint while mode 2026 is open." + :tags '(native) + (ghostel-test--with-open-sync-output (buf _term) + (ghostel--redraw-now buf t) (should (string-match-p "UPDATED-CONTENT" (buffer-string))) (should-not (string-match-p "BASELINE-CONTENT" (buffer-string))))) @@ -1870,56 +1868,46 @@ rendered by `ghostel--redraw-now'. This is the exact real-world path." ;; naturally — no need to stub `get-buffer-window-list'. (let ((ghostel--term t) (redraw-called nil)) - (cl-letf (((symbol-function 'ghostel--mode-enabled) - (lambda (&rest _) nil)) - ((symbol-function 'ghostel--redraw) + (cl-letf (((symbol-function 'ghostel--redraw) (lambda (&rest _) (setq redraw-called t)))) (ghostel--redraw-now buf) - (should-not redraw-called)))) + (should-not redraw-called) + (should ghostel--pending-redraw)))) (kill-buffer buf)))) -(ert-deftest ghostel-test-defers-redraw-while-hidden () - "Buffer is not redrawn while hidden. -When the buffer reappears, it is immediately redrawn." +(ert-deftest ghostel-test-hidden-invalidation-redraws-on-pre-redisplay () + "Hidden output remains pending without a timer and redraws on reveal." :tags '(native) - (let* ((win (selected-window)) - (orig-buf (window-buffer win)) - (buf (generate-new-buffer " *ghostel-test-hidden-defer*"))) + (let* ((window (selected-window)) + (previous-buffer (window-buffer window)) + (buf (generate-new-buffer " *ghostel-test-hidden-timer*"))) (unwind-protect (progn - (set-window-buffer win buf) + (set-window-buffer window buf) (with-current-buffer buf (ghostel-mode) - (let* ((term (ghostel--new 5 40 100)) - (ghostel--term term) - (ghostel--term-rows 5) - (inhibit-read-only t)) - (ghostel--write-vt term "initial\r\n") - (ghostel--redraw term t) - (should (string-match-p "initial" (buffer-string))) - - ;; Hide the buffer. - (set-window-buffer win orig-buf) - - ;; Output arrives while hidden but does not appear; make - ;; run-with-timer fire synchronously so no sleep is needed. - (ghostel--write-vt term "while-hidden\r\n") - (cl-letf (((symbol-function 'run-with-timer) - (lambda (_delay _repeat fn &rest args) - (apply fn args) nil))) - (ghostel--invalidate)) - - ;; Redraw blocked: buffer still shows the old content. - (should-not (string-match-p "while-hidden" (buffer-string))) - - ;; Reshow the buffer; the hook redraws immediately without - ;; waiting for the delayed invalidation timer. - (set-window-buffer win buf) - (run-hook-with-args 'window-buffer-change-functions win) - - (should (string-match-p "while-hidden" (buffer-string)))))) - (set-window-buffer win orig-buf) - (kill-buffer buf)))) + (let ((ghostel--term (ghostel--new 5 40 100)) + (ghostel--term-rows 5) + (inhibit-read-only t)) + (set-window-buffer window previous-buffer) + (ghostel--write-vt ghostel--term "pending-before-timer\r\n") + (ghostel--invalidate) + (should ghostel--pending-redraw) + (should-not ghostel--redraw-timer) + (should-not (string-match-p "pending-before-timer" + (buffer-string))) + (set-window-buffer window buf) + (ghostel--pre-redisplay window) + (should-not ghostel--pending-redraw) + (should-not ghostel--redraw-timer) + (should (string-match-p "pending-before-timer" + (buffer-string)))))) + (set-window-buffer window previous-buffer) + (when (buffer-live-p buf) + (with-current-buffer buf + (when ghostel--redraw-timer + (cancel-timer ghostel--redraw-timer))) + (kill-buffer buf))))) (ert-deftest ghostel-test-pty-output-is-processed-when-buffer-is-hidden () "Output is processed but not drawn while the buffer is hidden.