diff --git a/README.md b/README.md index 90519de..3c34c08 100644 --- a/README.md +++ b/README.md @@ -195,24 +195,15 @@ Normal letter keys exit copy mode and send the key to the terminal. | `C-c C-n` | Jump to next prompt | | `C-c C-p` | Jump to previous prompt | | `C-l` | Recenter viewport | -| `C-c C-a` | Load full scrollback into buffer | | `C-c C-t` | Exit without copying | | `a`–`z` | Exit and send key to terminal | Soft-wrapped newlines are automatically stripped from copied text. -After `C-c C-a`, the entire scrollback history is loaded into the buffer -as styled text. Standard Emacs commands work across the full content: -`C-x h` to select all, `C-s` to search, mark/region spanning any distance. - -Set `ghostel-copy-mode-auto-load-scrollback` to `t` to skip the -viewport-only step and load the full scrollback immediately when -entering copy mode. Advantages: produces a pure Emacs buffer where -all standard commands work (incremental search, `occur`, `M-x -flush-lines`, etc.) without an extra keystroke. Disadvantages: entering -copy mode takes longer for large scrollback buffers, clickable links -(URLs, file references, OSC 8 hyperlinks) are not detected in the -loaded scrollback. +The full scrollback is always rendered into the buffer as styled text, +so `isearch`, `consult-line`, `occur`, `M-x flush-lines`, `C-x h` to +select all, and any other buffer-based command work across the full +history — even outside copy mode. ## Features @@ -223,7 +214,7 @@ loaded scrollback. - Text attributes: bold, italic, faint, underline (single/double/curly/dotted/dashed with color), strikethrough, inverse - Cursor styles: block, bar, underline, hollow block - Alternate screen buffer (for TUI apps like htop, vim, etc.) -- Scrollback buffer (configurable, default 20MB (~10,000 lines)) +- Scrollback buffer (configurable, default 5 MB (~5,000 lines), materialized into the Emacs buffer so `isearch`/`consult-line` work over history) ### Links and File Detection - **OSC 8 hyperlinks** — clickable URLs emitted by terminal programs (click or `RET` to open) @@ -395,7 +386,7 @@ individual faces with `M-x customize-face`. | `ghostel-tramp-default-method` | `nil` | TRAMP method for new remote paths from OSC 7 (nil uses `tramp-default-method`) | | `ghostel-tramp-shell-integration` | `nil` | Auto-inject shell integration for remote TRAMP sessions | | `ghostel-buffer-name` | `"*ghostel*"` | Default buffer name | -| `ghostel-max-scrollback` | `20MB` | Maximum scrollback size in bytes | +| `ghostel-max-scrollback` | `5MB` | Maximum scrollback size in bytes (materialized into the Emacs buffer; ~5,000 rows on 80-col terminals) | | `ghostel-timer-delay` | `0.033` | Base redraw delay in seconds (~30fps) | | `ghostel-adaptive-fps` | `t` | Adaptive frame rate (shorter delay after idle, stop timer when idle) | | `ghostel-immediate-redraw-threshold` | `256` | Max output bytes to trigger immediate redraw (0 to disable) | @@ -408,7 +399,6 @@ individual faces with `M-x customize-face`. | `ghostel-enable-url-detection` | `t` | Linkify plain-text URLs in terminal output | | `ghostel-enable-file-detection` | `t` | Linkify file:line references in terminal output | | `ghostel-keymap-exceptions` | `("C-c" "C-x" ...)` | Keys passed through to Emacs | -| `ghostel-copy-mode-auto-load-scrollback` | `nil` | Load full scrollback automatically when entering copy mode | | `ghostel-exit-functions` | `nil` | Hook run when the shell process exits | ## Evil-mode @@ -498,16 +488,18 @@ terminal emulators: [vterm](https://github.com/akermu/emacs-libvterm) (native module), [eat](https://codeberg.org/akib/emacs-eat) (pure Elisp), and Emacs built-in `term`. -The primary benchmark streams 1 MB of data through a real process pipe, -matching actual terminal usage. Results on Apple M4 Max, Emacs 31.0.50: +The primary benchmark streams 5 MB of data through a real process pipe, +matching actual terminal usage. All backends are configured with ~1,000 +lines of scrollback (matching vterm's default). Results on Apple M4 Max, +Emacs 31.0.50: | Backend | Plain ASCII | URL-heavy | |----------------------|------------:|----------:| -| ghostel | 72 MB/s | 26 MB/s | -| ghostel (no detect) | 74 MB/s | 74 MB/s | -| vterm | 33 MB/s | 27 MB/s | -| eat | 4.4 MB/s | 3.4 MB/s | -| term | 5.4 MB/s | 4.6 MB/s | +| ghostel | 65 MB/s | 42 MB/s | +| ghostel (no detect) | 64 MB/s | 65 MB/s | +| vterm | 29 MB/s | 24 MB/s | +| eat | 3.9 MB/s | 3.0 MB/s | +| term | 4.8 MB/s | 4.1 MB/s | Ghostel scans terminal output for URLs and file paths, making them clickable. The "no detect" row shows throughput with this detection disabled @@ -571,8 +563,8 @@ powering Neovim's built-in terminal. | Copy mode | Yes | Yes | | Drag-and-drop | Yes | No | | Auto module download | Yes | No | -| Scrollback default | ~10,000 | 1,000 | -| PTY throughput (plain ASCII) | 72 MB/s | 33 MB/s | +| Scrollback default | ~5,000 | 1,000 | +| PTY throughput (plain ASCII) | 65 MB/s | 29 MB/s | | Default redraw rate | ~30 fps | ~10 fps | ### Key differences @@ -599,12 +591,13 @@ bash, zsh, and fish — no shell RC changes needed. vterm requires manually sourcing scripts in your shell configuration. Both support Elisp eval from the shell and TRAMP-aware remote directory tracking. -**Performance.** In PTY throughput benchmarks (1 MB streamed through `cat`), -ghostel is roughly 2x faster than vterm on plain ASCII data (72 vs 33 MB/s). -On URL-heavy output the gap narrows as ghostel's link detection adds overhead, -but with detection disabled ghostel reaches 74 MB/s. See the -[Performance](#performance) section above for full numbers and how to run the -benchmark suite yourself. +**Performance.** In PTY throughput benchmarks (5 MB streamed through `cat`, +both backends configured with ~1,000 lines of scrollback), ghostel is +roughly 2x faster than vterm on plain ASCII data (65 vs 29 MB/s). On +URL-heavy output ghostel still comes out ahead of vterm (42 vs 24 MB/s); +with link detection disabled ghostel reaches 65 MB/s regardless of input. +See the [Performance](#performance) section above for full numbers and how +to run the benchmark suite yourself. **Installation.** Ghostel can automatically download a pre-built native module or compile from source with [Zig](https://ziglang.org/). vterm uses diff --git a/bench/ghostel-bench.el b/bench/ghostel-bench.el index b7b21ac..ae567ad 100644 --- a/bench/ghostel-bench.el +++ b/bench/ghostel-bench.el @@ -192,8 +192,10 @@ for reliable measurement." ;; --------------------------------------------------------------------------- (defun ghostel-bench--make-ghostel (rows cols) - "Create a ghostel terminal for benchmarking." - (ghostel--new rows cols ghostel-bench-scrollback)) + "Create a ghostel terminal for benchmarking. +`ghostel-bench-scrollback' is in lines (matching vterm/term), +but `ghostel--new' takes bytes — convert at ~1 KB per row." + (ghostel--new rows cols (* ghostel-bench-scrollback 1024))) (defun ghostel-bench--make-vterm (rows cols) "Create a vterm terminal for benchmarking." diff --git a/ghostel.el b/ghostel.el index 45b466f..19751b5 100644 --- a/ghostel.el +++ b/ghostel.el @@ -113,11 +113,18 @@ detection fails." (list (choice string (const login-shell)) (choice (const :tag "No fallback" nil) string)))) -(defcustom ghostel-max-scrollback (* 20 1024 1024) ; 20MB +(defcustom ghostel-max-scrollback (* 5 1024 1024) ; 5MB "Maximum scrollback size in bytes. -Memory is allocated lazily, so a large value does not consume -memory at startup. The default of 20 MB holds roughly 10,000 -lines at typical terminal widths." +5 MB holds roughly 5,000 rows on a typical 80-column terminal +\(fewer on wider terminals — the cost scales with column count). + +The full scrollback is materialized into the Emacs buffer so that +`isearch', `consult-line', and other buffer-based commands work +across history. Each materialized row also lives in the Emacs +buffer with text properties for color/style/links, so the +practical Emacs heap cost is roughly equal to the libghostty +allocation, and large values noticeably slow down sustained +high-throughput output (e.g. `cat huge.log')." :type 'integer) (defcustom ghostel-timer-delay 0.033 @@ -261,15 +268,6 @@ into the scrollback will first jump to the bottom of the terminal before sending the input." :type 'boolean) -(defcustom ghostel-copy-mode-auto-load-scrollback nil - "Automatically load the full scrollback when entering copy mode. -When non-nil, entering copy mode immediately loads the entire -scrollback history into the buffer, producing a plain Emacs buffer -that supports all standard commands (search, select-all, etc.). -When nil (the default), copy mode shows only the current viewport -and scrollback can be loaded on demand with \\[ghostel-copy-mode-load-all]." - :type 'boolean) - ;;; ANSI color faces (defface ghostel-color-black @@ -391,7 +389,6 @@ Bump this only when the Elisp code requires a newer native module (declare-function ghostel--module-version "ghostel-module") (declare-function ghostel--mouse-event "ghostel-module") (declare-function ghostel--new "ghostel-module") -(declare-function ghostel--redraw-full-scrollback "ghostel-module") (declare-function ghostel--redraw "ghostel-module" (term &optional full)) (declare-function ghostel--scroll "ghostel-module") (declare-function ghostel--scroll-bottom "ghostel-module") @@ -602,12 +599,13 @@ DIR is the module directory." (defvar-local ghostel--term nil "Handle to the native terminal instance.") +(defvar-local ghostel--term-rows nil + "Row count of the native terminal, for viewport/scrollback arithmetic. +Updated whenever the terminal is created or resized.") + (defvar-local ghostel--copy-mode-active nil "Non-nil when copy mode is active.") -(defvar-local ghostel--copy-mode-full-buffer nil - "Non-nil when full scrollback has been loaded into the buffer in copy mode.") - (defvar-local ghostel--process nil "The shell process.") @@ -1099,119 +1097,55 @@ Return non-nil if the event was forwarded (mouse tracking is active)." (ghostel--mouse-mods event))))) (defun ghostel--scroll-up (&optional event) - "Scroll the terminal viewport up (into scrollback). + "Scroll the Emacs window up (toward older scrollback). When the terminal has mouse tracking enabled, forward EVENT as a scroll event to the running application instead." (interactive "e") - (if ghostel--copy-mode-full-buffer - (scroll-down 3) - (when ghostel--term - (unless (ghostel--forward-scroll-event event 4) ; button 4 = scroll up - (ghostel--scroll ghostel--term -3) - (if ghostel--copy-mode-active - (let ((inhibit-read-only t)) - (ghostel--redraw ghostel--term ghostel-full-redraw)) - (setq ghostel--force-next-redraw t) - (ghostel--invalidate)))))) + (unless (ghostel--forward-scroll-event event 4) ; button 4 = scroll up + (scroll-down 3))) (defun ghostel--scroll-down (&optional event) - "Scroll the terminal viewport down. + "Scroll the Emacs window down (toward newer content). When the terminal has mouse tracking enabled, forward EVENT as a scroll event to the running application instead." (interactive "e") - (if ghostel--copy-mode-full-buffer - (scroll-up 3) - (when ghostel--term - (unless (ghostel--forward-scroll-event event 5) ; button 5 = scroll down - (ghostel--scroll ghostel--term 3) - (if ghostel--copy-mode-active - (let ((inhibit-read-only t)) - (ghostel--redraw ghostel--term ghostel-full-redraw)) - (setq ghostel--force-next-redraw t) - (ghostel--invalidate)))))) + (unless (ghostel--forward-scroll-event event 5) ; button 5 = scroll down + (scroll-up 3))) (defun ghostel-copy-mode-scroll-up () - "Scroll the terminal viewport up by a page in copy mode." + "Scroll the Emacs window up by a page in copy mode." (interactive) - (let ((col (current-column))) - (if ghostel--copy-mode-full-buffer - (scroll-down-command) - (when ghostel--term - (let ((height (count-lines (point-min) (point-max)))) - (ghostel--scroll ghostel--term (- 2 height)) - (let ((inhibit-read-only t)) - (ghostel--redraw ghostel--term ghostel-full-redraw))))) - (move-to-column col))) + (scroll-down-command)) (defun ghostel-copy-mode-scroll-down () - "Scroll the terminal viewport down by a page in copy mode." + "Scroll the Emacs window down by a page in copy mode." (interactive) - (let ((col (current-column))) - (if ghostel--copy-mode-full-buffer - (scroll-up-command) - (when ghostel--term - (let ((height (count-lines (point-min) (point-max)))) - (ghostel--scroll ghostel--term (- height 2)) - (let ((inhibit-read-only t)) - (ghostel--redraw ghostel--term ghostel-full-redraw))))) - (move-to-column col))) + (scroll-up-command)) (defun ghostel-copy-mode-previous-line () - "Move to the previous line, scrolling the viewport if at the top." + "Move to the previous line in copy mode." (interactive) (let ((col (current-column))) - (if ghostel--copy-mode-full-buffer - (forward-line -1) - (if (= (line-number-at-pos) 1) - (when ghostel--term - (ghostel--scroll ghostel--term -1) - (let ((inhibit-read-only t)) - (ghostel--redraw ghostel--term ghostel-full-redraw)) - (goto-char (point-min))) - (forward-line -1))) + (forward-line -1) (move-to-column col))) (defun ghostel-copy-mode-next-line () - "Move to the next line, scrolling the viewport if at the bottom." + "Move to the next line in copy mode." (interactive) (let ((col (current-column))) - (if ghostel--copy-mode-full-buffer - (forward-line 1) - (if (>= (line-number-at-pos) (line-number-at-pos (point-max))) - (when ghostel--term - (ghostel--scroll ghostel--term 1) - (let ((inhibit-read-only t)) - (ghostel--redraw ghostel--term ghostel-full-redraw)) - (goto-char (point-max)) - (beginning-of-line)) - (forward-line 1))) + (forward-line 1) (move-to-column col))) (defun ghostel-copy-mode-beginning-of-buffer () - "Scroll to the top of scrollback in copy mode." + "Move to the top of the buffer (oldest scrollback) in copy mode." (interactive) - (if ghostel--copy-mode-full-buffer - (goto-char (point-min)) - (when ghostel--term - (ghostel--scroll-top ghostel--term) - (let ((inhibit-read-only t)) - (ghostel--redraw ghostel--term ghostel-full-redraw)) - (goto-char (point-min))))) + (goto-char (point-min))) (defun ghostel-copy-mode-end-of-buffer () - "Scroll to the bottom of scrollback in copy mode." + "Move to the bottom of the buffer (current viewport) in copy mode." (interactive) - (if ghostel--copy-mode-full-buffer - (progn - (goto-char (point-max)) - (skip-chars-backward " \t\n")) - (when ghostel--term - (ghostel--scroll-bottom ghostel--term) - (let ((inhibit-read-only t)) - (ghostel--redraw ghostel--term ghostel-full-redraw)) - ;; The native redraw already positions point at the terminal cursor, - ;; so no explicit goto-char needed here. - ))) + (goto-char (point-max)) + (skip-chars-backward " \t\n")) (defun ghostel-copy-mode-end-of-line () "Move to the last non-whitespace character on the line." @@ -1220,37 +1154,9 @@ scroll event to the running application instead." (skip-chars-backward " \t")) (defun ghostel-copy-mode-recenter () - "Recenter the terminal viewport around the current line in copy mode. -Scrolls the terminal viewport so the current line is vertically -centered, then redraws. When the scroll is clamped at a scrollback -boundary (nothing to scroll into), does nothing." + "Recenter the current line in the window." (interactive) - (if ghostel--copy-mode-full-buffer - (recenter) - (when ghostel--term - (let* ((current-line (line-number-at-pos)) - (win-height (window-body-height)) - (center (/ win-height 2)) - (col (current-column))) - (unless (= current-line center) - ;; Hash the buffer to detect whether the scroll was clamped. - (let ((old-hash (buffer-hash))) - (ghostel--scroll ghostel--term (- current-line center)) - (let ((inhibit-read-only t)) - (ghostel--redraw ghostel--term ghostel-full-redraw)) - ;; If the buffer changed the viewport actually moved — - ;; reposition point at center. Otherwise the scroll was - ;; clamped; restore point since redraw moved it to the - ;; terminal cursor. - (if (equal old-hash (buffer-hash)) - (progn - (goto-char (point-min)) - (forward-line (1- current-line)) - (move-to-column col)) - (goto-char (point-min)) - (forward-line (1- (min center (line-number-at-pos (point-max))))) - (move-to-column col) - (recenter)))))))) + (recenter)) ;;; Mouse input @@ -1341,7 +1247,6 @@ boundary (nothing to scroll into), does nothing." (define-key map (kbd "M->") #'ghostel-copy-mode-end-of-buffer) (define-key map (kbd "C-e") #'ghostel-copy-mode-end-of-line) (define-key map (kbd "C-l") #'ghostel-copy-mode-recenter) - (define-key map (kbd "C-c C-a") #'ghostel-copy-mode-load-all) map) "Keymap for `ghostel-copy-mode'. Standard Emacs navigation works. @@ -1359,13 +1264,12 @@ Covers both `global-hl-line-mode' and buffer-local `hl-line-mode'.") (defun ghostel-copy-mode () "Enter copy mode for selecting and copying terminal text. -The display is frozen and standard Emacs navigation keys work. -Set mark, navigate to select, then \\[ghostel-copy-mode-copy] to copy. -Press \\`q' or \\[ghostel-copy-mode-exit] to exit without copying." +Live terminal output is paused; standard Emacs navigation, search, +and marking work across the full scrollback that is already rendered +in the buffer. Press \\`q' or \\[ghostel-copy-mode-exit] to exit." (interactive) (if ghostel--copy-mode-active (ghostel-copy-mode-exit) - ;; Freeze display (setq ghostel--copy-mode-active t) (when ghostel--redraw-timer (cancel-timer ghostel--redraw-timer) @@ -1373,42 +1277,34 @@ Press \\`q' or \\[ghostel-copy-mode-exit] to exit without copying." ;; Ensure cursor is visible for navigation (setq ghostel--saved-cursor-type cursor-type) (setq cursor-type (default-value 'cursor-type)) - ;; Switch to copy mode keymap (standard Emacs keys work by default) (setq ghostel--saved-local-map (current-local-map)) (use-local-map ghostel-copy-mode-map) (when ghostel--saved-hl-line-mode (hl-line-mode 1)) (setq buffer-read-only t) - (if ghostel-copy-mode-auto-load-scrollback - (ghostel-copy-mode-load-all) - (setq mode-line-process ":Copy") - (force-mode-line-update) - (message "Copy mode: C-SPC to mark, navigate to select, M-w to copy, q to exit")))) + (setq mode-line-process ":Copy") + (force-mode-line-update) + (message "Copy mode: C-SPC to mark, navigate to select, M-w to copy, q to exit"))) (defun ghostel-copy-mode-exit () "Exit copy mode and return to terminal mode." (interactive) (when ghostel--copy-mode-active - (let ((was-full ghostel--copy-mode-full-buffer)) - (setq ghostel--copy-mode-active nil) - (setq ghostel--copy-mode-full-buffer nil) - (setq cursor-type ghostel--saved-cursor-type) - (deactivate-mark) - (use-local-map ghostel--saved-local-map) - (when ghostel--saved-hl-line-mode - (hl-line-mode -1)) - (setq buffer-read-only nil) - (setq mode-line-process nil) - (force-mode-line-update) - (when ghostel--term - (ghostel--scroll-bottom ghostel--term) - (when was-full - ;; Erase stale full-scrollback content so normal redraw rebuilds - (let ((inhibit-read-only t)) - (erase-buffer) - (ghostel--redraw ghostel--term t)))) - (ghostel--invalidate) - (message "Copy mode exited")))) + (setq ghostel--copy-mode-active nil) + (setq cursor-type ghostel--saved-cursor-type) + (deactivate-mark) + (use-local-map ghostel--saved-local-map) + (when ghostel--saved-hl-line-mode + (hl-line-mode -1)) + (setq buffer-read-only nil) + (setq mode-line-process nil) + (force-mode-line-update) + ;; Jump out of any scrollback position so the redraw is allowed to + ;; position point at the terminal cursor (otherwise + ;; `ghostel--delayed-redraw' would preserve our scrollback marker). + (goto-char (point-max)) + (ghostel--invalidate) + (message "Copy mode exited"))) (defun ghostel-copy-mode-exit-and-send () "Exit copy mode and send the key that triggered exit to the terminal." @@ -1450,27 +1346,6 @@ stripped so the copied text matches the original terminal content." (message "Copied to kill ring"))) (ghostel-copy-mode-exit)) -(defun ghostel-copy-mode-load-all () - "Load the entire scrollback into the buffer for cross-viewport selection. -After loading, standard Emacs navigation and selection work across -the full scrollback history." - (interactive) - (when (and ghostel--copy-mode-active ghostel--term - (not ghostel--copy-mode-full-buffer)) - (message "Loading scrollback...") - (let* ((saved-line (1- (line-number-at-pos))) ; 0-based line within viewport - (saved-col (current-column)) - (inhibit-read-only t) - (viewport-line (ghostel--redraw-full-scrollback ghostel--term))) - (goto-char (point-min)) - (forward-line (+ (1- viewport-line) saved-line)) - (move-to-column saved-col) - (recenter saved-line)) - (setq ghostel--copy-mode-full-buffer t) - (setq mode-line-process ":Emacs") - (force-mode-line-update) - (message "Scrollback loaded"))) - (defun ghostel-copy-all () "Copy the entire scrollback buffer to the kill ring." (interactive) @@ -1521,42 +1396,47 @@ at the given line in another window." (interactive) (ghostel--open-link (get-text-property (point) 'help-echo))) -(defun ghostel--detect-urls () - "Scan the buffer for plain-text URLs and file:line references. -Skips regions that already have a `help-echo' property (e.g. from OSC 8)." - (save-excursion - ;; Pass 1: http(s) URLs - (when ghostel-enable-url-detection - (goto-char (point-min)) - (while (re-search-forward - "https?://[^ \t\n\r\"<>]*[^ \t\n\r\"<>.,;:!?)>]" - nil t) - (let ((beg (match-beginning 0)) - (end (match-end 0))) - (unless (get-text-property beg 'help-echo) - (let ((url (match-string-no-properties 0))) - (put-text-property beg end 'help-echo url) - (put-text-property beg end 'mouse-face 'highlight) - (put-text-property beg end 'keymap ghostel-link-map)))))) - ;; Pass 2: file:line references (e.g. "./foo.el:42" or "/tmp/bar.rs:10") - (when ghostel-enable-file-detection - (goto-char (point-min)) - (while (re-search-forward - "\\(?:\\./\\|/\\)[^ \t\n\r:\"<>]+:[0-9]+" - nil t) - (let ((beg (match-beginning 0)) - (end (match-end 0))) - (unless (get-text-property beg 'help-echo) - (let* ((text (match-string-no-properties 0)) - (sep (string-match ":[0-9]+\\'" text)) - (path (substring text 0 sep)) - (line (substring text (1+ sep))) - (abs-path (expand-file-name path))) - (when (file-exists-p abs-path) - (put-text-property beg end 'help-echo - (concat "fileref:" abs-path ":" line)) - (put-text-property beg end 'mouse-face 'highlight) - (put-text-property beg end 'keymap ghostel-link-map))))))))) +(defun ghostel--detect-urls (&optional begin end) + "Scan a buffer region for plain-text URLs and file:line references. +BEGIN and END default to `point-min' and `point-max' respectively. +Skips regions that already have a `help-echo' property (e.g. from OSC 8). +Bounding the scan keeps streaming output from re-scanning the entire +materialized scrollback on every redraw." + (let ((begin (or begin (point-min))) + (end (or end (point-max)))) + (save-excursion + ;; Pass 1: http(s) URLs + (when ghostel-enable-url-detection + (goto-char begin) + (while (re-search-forward + "https?://[^ \t\n\r\"<>]*[^ \t\n\r\"<>.,;:!?)>]" + end t) + (let ((beg (match-beginning 0)) + (mend (match-end 0))) + (unless (get-text-property beg 'help-echo) + (let ((url (match-string-no-properties 0))) + (put-text-property beg mend 'help-echo url) + (put-text-property beg mend 'mouse-face 'highlight) + (put-text-property beg mend 'keymap ghostel-link-map)))))) + ;; Pass 2: file:line references (e.g. "./foo.el:42" or "/tmp/bar.rs:10") + (when ghostel-enable-file-detection + (goto-char begin) + (while (re-search-forward + "\\(?:\\./\\|/\\)[^ \t\n\r:\"<>]+:[0-9]+" + end t) + (let ((beg (match-beginning 0)) + (mend (match-end 0))) + (unless (get-text-property beg 'help-echo) + (let* ((text (match-string-no-properties 0)) + (sep (string-match ":[0-9]+\\'" text)) + (path (substring text 0 sep)) + (line (substring text (1+ sep))) + (abs-path (expand-file-name path))) + (when (file-exists-p abs-path) + (put-text-property beg mend 'help-echo + (concat "fileref:" abs-path ":" line)) + (put-text-property beg mend 'mouse-face 'highlight) + (put-text-property beg mend 'keymap ghostel-link-map)))))))))) (defun ghostel--compensate-wide-chars () @@ -2286,7 +2166,10 @@ frame after idle to improve interactive responsiveness." (ghostel--write-input ghostel--term combined))))) (defun ghostel--delayed-redraw (buffer) - "Perform the actual redraw in BUFFER." + "Perform the actual redraw in BUFFER. +If point is currently inside the materialized scrollback (above the +viewport), save and restore it so scrolling up to read history is not +interrupted by live output updating the terminal cursor." (when (buffer-live-p buffer) (with-current-buffer buffer (setq ghostel--redraw-timer nil) @@ -2298,10 +2181,27 @@ frame after idle to improve interactive responsiveness." (ghostel--mode-enabled ghostel--term 2026)) (setq ghostel--force-next-redraw nil) (setq ghostel--has-wide-chars nil) - (let ((inhibit-read-only t) - (inhibit-redisplay t) - (inhibit-modification-hooks t)) - (ghostel--redraw ghostel--term ghostel-full-redraw)) + (let* ((rows (or ghostel--term-rows 0)) + ;; Compute the viewport start — first char of the last + ;; `rows` lines — to decide whether point is currently + ;; in the scrollback region above it. Bounded O(rows). + (viewport-start + (when (> rows 0) + (save-excursion + (goto-char (point-max)) + (forward-line (- (1- rows))) + (line-beginning-position)))) + ;; Preserve point only when reading scrollback. + (saved-marker + (when (and viewport-start (< (point) viewport-start)) + (copy-marker (point) t))) + (inhibit-read-only t) + (inhibit-redisplay t) + (inhibit-modification-hooks t)) + (ghostel--redraw ghostel--term ghostel-full-redraw) + (when saved-marker + (goto-char saved-marker) + (set-marker saved-marker nil))) (when ghostel--has-wide-chars (ghostel--compensate-wide-chars)) ;; Native redraw updates buffer-point via `goto-char', which @@ -2344,6 +2244,7 @@ PROCESS is the shell process, WINDOWS is the list of windows." (with-current-buffer buffer (when ghostel--term (ghostel--set-size ghostel--term height width) + (setq ghostel--term-rows height) (setq ghostel--force-next-redraw t) (ghostel--invalidate)))) ;; Return size — Emacs calls set-process-window-size (SIGWINCH) @@ -2425,6 +2326,7 @@ The name of the buffer is determined by the value of `ghostel-buffer-name'." (width (window-max-chars-per-line))) (setq ghostel--term (ghostel--new height width ghostel-max-scrollback)) + (setq ghostel--term-rows height) (ghostel--apply-palette ghostel--term)) (ghostel--start-process)))) diff --git a/src/emacs.zig b/src/emacs.zig index 0cf43fa..0f03bd1 100644 --- a/src/emacs.zig +++ b/src/emacs.zig @@ -294,6 +294,7 @@ pub const Sym = struct { @"line-end-position": Value, @"point-max": Value, @"delete-region": Value, + @"char-before": Value, // Text property names face: Value, diff --git a/src/module.zig b/src/module.zig index 51fd1f0..34f0836 100644 --- a/src/module.zig +++ b/src/module.zig @@ -47,7 +47,6 @@ export fn emacs_module_init(runtime: *c.struct_emacs_runtime) callconv(.c) c_int env.bindFunction("ghostel--cursor-position", 1, 1, &fnCursorPosition, "Return terminal cursor position as (COL . ROW), 0-indexed.\n\n(ghostel--cursor-position TERM)"); env.bindFunction("ghostel--debug-state", 1, 1, &fnDebugState, "Return debug info about terminal/render state.\n\n(ghostel--debug-state TERM)"); env.bindFunction("ghostel--debug-feed", 2, 2, &fnDebugFeed, "Feed STR to terminal and return first row + cursor.\n\n(ghostel--debug-feed TERM STR)"); - env.bindFunction("ghostel--redraw-full-scrollback", 1, 1, &fnRedrawFullScrollback, "Render entire scrollback into buffer, return original viewport line.\n\n(ghostel--redraw-full-scrollback TERM)"); env.bindFunction("ghostel--copy-all-text", 1, 1, &fnCopyAllText, "Return entire scrollback as plain text string.\n\n(ghostel--copy-all-text TERM)"); env.bindFunction("ghostel--module-version", 0, 0, &fnModuleVersion, "Return the native module version string.\n\n(ghostel--module-version)"); @@ -74,7 +73,7 @@ fn fnNew(raw_env: ?*c.emacs_env, nargs: isize, args: [*c]c.emacs_value, _: ?*any const max_scrollback: usize = if (nargs > 2 and env.isNotNil(args[2])) @intCast(env.extractInteger(args[2])) else - 25_000_000; // ~25 MB, roughly 10k lines at standard page density + 5 * 1024 * 1024; // ~5 MB, roughly 5k rows on an 80-column terminal const term = std.heap.c_allocator.create(Terminal) catch { env.signalError("ghostel: out of memory"); @@ -487,6 +486,9 @@ fn fnSetSize(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*any env.signalError("ghostel: resize failed"); return env.nil(); }; + // Reflow invalidates the materialized scrollback region — wipe the + // buffer so the next full redraw rebuilds it from libghostty state. + env.eraseBuffer(); return env.nil(); } @@ -900,16 +902,6 @@ fn fnCursorPosition(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _ return env.call2(emacs.sym.cons, env.makeInteger(@as(i64, cx)), env.makeInteger(@as(i64, cy))); } -/// (ghostel--redraw-full-scrollback TERM) -/// Render the entire scrollback into the current buffer. -/// Returns the 1-based line number of the original viewport position. -fn fnRedrawFullScrollback(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*anyopaque) callconv(.c) c.emacs_value { - const env = emacs.Env.init(raw_env.?); - const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil(); - const line = render.redrawFullScrollback(env, term); - return env.makeInteger(line); -} - /// (ghostel--copy-all-text TERM) /// Return the entire scrollback as a plain text string using the formatter API. fn fnCopyAllText(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*anyopaque) callconv(.c) c.emacs_value { diff --git a/src/render.zig b/src/render.zig index 9b49f36..64c88ac 100644 --- a/src/render.zig +++ b/src/render.zig @@ -341,11 +341,14 @@ fn scanHyperlinksFromGrid( } /// Apply hyperlink text properties to the Emacs buffer. +/// Row indices are relative to the viewport — caller passes the char +/// position of the first viewport line so we can resolve absolute rows. fn applyHyperlinks( env: emacs.Env, spans: []const HyperlinkSpan, span_count: usize, uri_buf: []const u8, + viewport_start: i64, ) void { if (span_count == 0) return; @@ -357,8 +360,8 @@ fn applyHyperlinks( const uri = uri_buf[span.uri_start..span.uri_start + span.uri_len]; if (uri.len == 0) continue; - // Navigate to span start - env.gotoCharN(1); + // Navigate to span start (viewport-relative row -> absolute line) + env.gotoCharN(viewport_start); _ = env.forwardLine(@as(i64, span.row)); env.moveToColumn(@as(i64, span.col_start)); const start = env.point(); @@ -393,6 +396,56 @@ fn isRowPrompt(term: *Terminal) bool { return semantic != 0; } +/// Hash the first ~16 cells of libghostty's first scrollback row using +/// FNV-1a. Returns 0 if there is no scrollback or if anything fails. +/// +/// Used to detect rotation: when libghostty's scrollback is plateaued at +/// its byte cap, sustained writes evict the oldest row in lockstep with +/// new rows being pushed, so `total_rows` doesn't change and the normal +/// delta-detection sees no work to do. Sampling the first scrollback +/// row's content lets us detect that the row at index 0 has changed +/// underneath us. +/// +/// Scrolls libghostty's viewport to the top to read the row, then +/// restores the previous viewport offset. Cheap (~6 libghostty calls); +/// gated by the caller to only run when rotation is suspected. +fn computeFirstScrollbackRowHash(term: *Terminal) u64 { + const sb = term.getScrollbar() orelse return 0; + const saved_offset = sb.offset; + + term.scrollViewport(gt.SCROLL_TOP, 0); + defer { + term.scrollViewport(gt.SCROLL_TOP, 0); + if (saved_offset > 0) { + term.scrollViewport(gt.SCROLL_DELTA, @intCast(saved_offset)); + } + } + + if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) return 0; + if (gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_ROW_ITERATOR, @ptrCast(&term.row_iterator)) != gt.SUCCESS) return 0; + if (!gt.c.ghostty_render_state_row_iterator_next(term.row_iterator)) return 0; + if (gt.c.ghostty_render_state_row_get(term.row_iterator, gt.RS_ROW_DATA_CELLS, @ptrCast(&term.row_cells)) != gt.SUCCESS) return 0; + + // FNV-1a 64-bit hash. We mix in the first ~16 cells' first + // codepoints (or a space for empty cells) — enough entropy to + // distinguish rotation states without scanning the whole row. + const fnv_prime: u64 = 0x100000001b3; + var hash: u64 = 0xcbf29ce484222325; + var i: usize = 0; + while (i < 16 and gt.c.ghostty_render_state_row_cells_next(term.row_cells)) : (i += 1) { + var graphemes_len: u32 = 0; + if (gt.c.ghostty_render_state_row_cells_get(term.row_cells, gt.RS_CELLS_DATA_GRAPHEMES_LEN, @ptrCast(&graphemes_len)) != gt.SUCCESS) continue; + if (graphemes_len == 0) { + hash = (hash ^ ' ') *% fnv_prime; + continue; + } + var codepoints: [4]u32 = undefined; + if (gt.c.ghostty_render_state_row_cells_get(term.row_cells, gt.RS_CELLS_DATA_GRAPHEMES_BUF, @ptrCast(&codepoints)) != gt.SUCCESS) continue; + hash = (hash ^ codepoints[0]) *% fnv_prime; + } + return hash; +} + /// Result from buildRowContent: byte length for make_string, char count for properties. const RowContent = struct { byte_len: usize, @@ -406,6 +459,14 @@ const RowContent = struct { /// Build text content and style runs for the current row in the iterator. /// Style runs use character (codepoint) offsets for Emacs put-text-property. +/// +/// Trailing blank cells — spaces with the default cell style — are +/// trimmed off the end of the row so the Emacs buffer does not carry +/// libghostty's full-width viewport padding. A cell is NOT blank if +/// its character is non-space, or if its style has any non-default +/// attribute (e.g. a colored background, underline, etc.), so visibly- +/// styled blanks are preserved. Style runs extending past the trim +/// point are clipped to the new length by `insertAndStyle'. fn buildRowContent( term: *Terminal, text_buf: []u8, @@ -414,6 +475,11 @@ fn buildRowContent( ) RowContent { var text_len: usize = 0; // byte offset var char_len: usize = 0; // character (codepoint) offset + // Position at the end of the last non-blank cell; final row length + // is trimmed back to this. Any run of blank cells past the end is + // discarded along with their default-style trailing padding. + var trim_text_len: usize = 0; + var trim_char_len: usize = 0; var prompt_char_len: usize = 0; // chars that are semantic prompt var in_prompt: bool = true; // track contiguous leading prompt cells var has_wide: bool = false; @@ -478,6 +544,12 @@ fn buildRowContent( char_len += 1; } if (in_prompt) prompt_char_len = char_len; + // Empty cells are blank for trim purposes unless their + // style has a visible attribute (e.g. colored background). + if (!cell_style.isDefault()) { + trim_text_len = text_len; + trim_char_len = char_len; + } continue; } @@ -496,8 +568,24 @@ fn buildRowContent( char_len += 1; // one codepoint = one Emacs character } if (in_prompt) prompt_char_len = char_len; + // Any cell that libghostty stored a grapheme for was written + // explicitly by the terminal, so it anchors the trim point — + // even if the grapheme happens to be a space (e.g. the space + // in a \"$ \" prompt, or a space the shell intentionally + // emitted as part of a layout). Only unwritten padding cells + // (the `graphemes_len == 0' branch above) are considered blank. + trim_text_len = text_len; + trim_char_len = char_len; } + // Trim trailing blank cells. Cap `prompt_char_len' at the new + // `char_len' so the "leading prompt" region never extends past the + // trimmed text. Style runs extending past the trim point are + // clipped by `insertAndStyle' via its `content.char_len' cap. + text_len = trim_text_len; + char_len = trim_char_len; + if (prompt_char_len > char_len) prompt_char_len = char_len; + // Close final run if (char_len > run_start_char and run_count.* < runs.len) { runs[run_count.*] = .{ @@ -537,109 +625,90 @@ fn insertAndStyle( } } -/// Render the entire scrollback + active screen into the Emacs buffer. -/// Returns the 1-based buffer line number corresponding to the original viewport top. -pub fn redrawFullScrollback(env: emacs.Env, term: *Terminal) i64 { - const total_rows = term.getTotalRows(); - if (total_rows == 0) return 1; - - // Save current viewport position - const sb = term.getScrollbar() orelse return 1; - const saved_offset = sb.offset; - - // Get default colors - // (need a render_state_update to read them, use current viewport for that) - if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) { - return 1; - } - var default_fg = gt.ColorRgb{ .r = 204, .g = 204, .b = 204 }; - var default_bg = gt.ColorRgb{ .r = 0, .g = 0, .b = 0 }; - _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_COLOR_FOREGROUND, @ptrCast(&default_fg)); - _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_COLOR_BACKGROUND, @ptrCast(&default_bg)); - - // Set buffer default face - var fg_hex: [7]u8 = undefined; - var bg_hex: [7]u8 = undefined; - _ = env.call2( - emacs.sym.@"ghostel--set-buffer-face", - env.makeString(formatColor(default_fg, &fg_hex)), - env.makeString(formatColor(default_bg, &bg_hex)), - ); - - // Erase buffer - env.eraseBuffer(); +/// Insert `count` libghostty rows starting at `first_row` (0 = top of +/// scrollback) into the Emacs buffer at `point`. Each row is followed by +/// a newline; soft-wrapped rows get the `ghostel-wrap` property on their +/// trailing newline so copy-mode can filter them out. +/// +/// Drives libghostty by scrolling its viewport through the requested range +/// and re-querying the render state for each page. The caller is expected +/// to save and restore the libghostty viewport position around this call. +/// +/// Returns the number of rows actually inserted. +fn insertScrollbackRange( + env: emacs.Env, + term: *Terminal, + first_row: usize, + count: usize, + default_fg: gt.ColorRgb, + default_bg: gt.ColorRgb, +) usize { + if (count == 0) return 0; - // Scroll to top of scrollback + // Position libghostty viewport at first_row. term.scrollViewport(gt.SCROLL_TOP, 0); + if (first_row > 0) { + term.scrollViewport(gt.SCROLL_DELTA, @intCast(first_row)); + } - // Shared buffers for row content var runs: [512]RunInfo = undefined; var text_buf: [16384]u8 = undefined; - var rendered: usize = 0; - var prev_wrapped: bool = false; + var inserted: usize = 0; - while (rendered < total_rows) { - // Query actual viewport position + while (inserted < count) { const cur_sb = term.getScrollbar() orelse break; const viewport_start = cur_sb.offset; - // Update render state for current viewport - if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) { - break; - } - - // Get row iterator - if (gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_ROW_ITERATOR, @ptrCast(&term.row_iterator)) != gt.SUCCESS) { - break; - } + if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) break; + if (gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_ROW_ITERATOR, @ptrCast(&term.row_iterator)) != gt.SUCCESS) break; - // How many rows to skip (already rendered from previous page overlap) - const viewport_rows: usize = term.rows; - const skip: usize = if (rendered > viewport_start) rendered - viewport_start else 0; - if (skip >= viewport_rows) break; // no new rows in this viewport - // How many rows to take from this viewport - const take: usize = @min(viewport_rows - skip, total_rows - rendered); - if (take == 0) break; // no progress possible + const absolute_target = first_row + inserted; + const skip: usize = if (absolute_target > viewport_start) absolute_target - viewport_start else 0; + if (skip >= term.rows) break; + const take: usize = @min(term.rows - skip, count - inserted); + if (take == 0) break; var row_in_page: usize = 0; + var took: usize = 0; while (gt.c.ghostty_render_state_row_iterator_next(term.row_iterator)) { defer row_in_page += 1; + if (row_in_page < skip) continue; + if (took >= take) break; - if (row_in_page < skip) { - // Still need to track wrap state through skipped rows - prev_wrapped = isRowWrapped(term); - continue; - } - if (row_in_page >= skip + take) { - break; - } - - // Get cells for this row if (gt.c.ghostty_render_state_row_get(term.row_iterator, gt.RS_ROW_DATA_CELLS, @ptrCast(&term.row_cells)) != gt.SUCCESS) { - prev_wrapped = false; - rendered += 1; + took += 1; + inserted += 1; continue; } - // Insert newline between rows - if (rendered > 0) { - const nl_start = env.point(); - env.insert("\n"); - if (prev_wrapped) { - env.putTextProperty(nl_start, env.point(), emacs.sym.@"ghostel-wrap", env.t()); - } - } - - // Build row content var run_count: usize = 0; - const content = buildRowContent(term, &text_buf, &runs, &run_count); + var content = buildRowContent(term, &text_buf, &runs, &run_count); + + // Append the trailing newline to the row buffer so the row + // text + newline insert through a single env.insert call + // instead of two. This saves one Elisp FFI round-trip per + // inserted row, which is the dominant per-row cost in this + // hot loop. Style runs only cover the row's cells, so the + // unstyled trailing \n is harmless to insertAndStyle. If the + // row exactly filled text_buf, fall back to a separate + // env.insert("\n") so the "one row per line" invariant + // (relied on by the `after_insert - 1` property math below) + // always holds. + const newline_in_buf = content.byte_len < text_buf.len; + if (newline_in_buf) { + text_buf[content.byte_len] = '\n'; + content.byte_len += 1; + content.char_len += 1; + } - // Insert text and apply styles const row_start = env.extractInteger(env.point()); insertAndStyle(env, &text_buf, content, &runs, run_count, default_fg, default_bg); + if (!newline_in_buf) { + env.insert("\n"); + } + const after_insert = env.extractInteger(env.point()); - // Mark prompt portion if (content.prompt_char_len > 0) { env.putTextProperty( env.makeInteger(row_start), @@ -650,40 +719,216 @@ pub fn redrawFullScrollback(env: emacs.Env, term: *Terminal) i64 { } else if (isRowPrompt(term)) { env.putTextProperty( env.makeInteger(row_start), - env.point(), + env.makeInteger(after_insert - 1), // exclude trailing newline emacs.sym.@"ghostel-prompt", env.t(), ); } - prev_wrapped = isRowWrapped(term); - rendered += 1; - } + // Mark the trailing newline with ghostel-wrap if the row is + // soft-wrapped, so copy-mode can filter wrap newlines from + // copied text. + if (isRowWrapped(term)) { + env.putTextProperty( + env.makeInteger(after_insert - 1), + env.makeInteger(after_insert), + emacs.sym.@"ghostel-wrap", + env.t(), + ); + } - if (rendered >= total_rows) break; + took += 1; + inserted += 1; + } - // Scroll down by viewport size for next page + if (inserted >= count) break; + // Advance viewport by a full page for the next iteration. term.scrollViewport(gt.SCROLL_DELTA, @intCast(term.rows)); } - // Restore viewport to saved position - term.scrollViewport(gt.SCROLL_TOP, 0); - if (saved_offset > 0) { - term.scrollViewport(gt.SCROLL_DELTA, @intCast(saved_offset)); - } - - // Return 1-based line number of the original viewport top - return @as(i64, @intCast(saved_offset)) + 1; + return inserted; } + /// Redraw the terminal into the current Emacs buffer. -/// When force_full is true, always erase and rebuild (matches Ghostty GPU behaviour). -pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void { +/// +/// Maintains a "growing buffer" model where the Emacs buffer contains +/// all materialized scrollback (above) and the current viewport (below). +/// On each call we: +/// 1. Force libghostty's viewport to the bottom (active screen). +/// 2. Poll `getTotalRows()` against `term.scrollback_in_buffer` to +/// detect rows that scrolled off the top (append to buffer) or +/// rows evicted by libghostty's scrollback cap (trim from buffer). +/// 3. Render the viewport into the tail of the buffer, anchored at +/// the line that follows the last scrollback row. +/// +/// When `force_full` is true, the viewport region is fully re-rendered +/// instead of using the incremental dirty-row path. +pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void { + var force_full = force_full_arg; + + // Lock the libghostty viewport to the bottom. Users navigate history + // through Emacs now, so any lingering scroll offset (e.g. from an + // explicit ghostel--scroll) would desync our scrollback tracker. + if (term.getScrollbar()) |sb| { + if (sb.len + sb.offset < sb.total) { + term.scrollViewport(gt.SCROLL_BOTTOM, 0); + } + } + // Update render state from terminal if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) { return; } + // Resolve default colors once — used for both the scrollback append + // path and the viewport render path. + var default_fg = gt.ColorRgb{ .r = 204, .g = 204, .b = 204 }; + var default_bg = gt.ColorRgb{ .r = 0, .g = 0, .b = 0 }; + _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_COLOR_FOREGROUND, @ptrCast(&default_fg)); + _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_COLOR_BACKGROUND, @ptrCast(&default_bg)); + + // ---- Scrollback rotation detection ------------------------------------ + // When libghostty's scrollback is at its byte cap, sustained writes + // evict the oldest rows and push new ones, so the row at scrollback + // index 0 changes underneath us. The normal delta-sync below tracks + // `total_rows` deltas, but those don't capture content rotation — + // if the count is unchanged (or even shrinking) the trim path would + // remove our top rows under the *assumption* they match the rows + // libghostty just evicted, which isn't true after rotation. + // + // Detect rotation by hashing the first scrollback row whenever + // writes have happened since the last redraw and we have scrollback. + // A change means the top row is no longer the row we materialized + // → wipe the buffer and let the delta-sync below re-fetch everything + // fresh from libghostty. + if (term.wrote_since_redraw and term.scrollback_in_buffer > 0 and term.first_scrollback_row_hash != 0) { + const new_hash = computeFirstScrollbackRowHash(term); + // computeFirstScrollbackRowHash scrolled libghostty's viewport to + // sample row 0 and the defer restored the offset, but the render + // state may now be stale — refresh it before continuing. + if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) return; + if (new_hash != term.first_scrollback_row_hash) { + // Rotation detected — erase the buffer entirely and force a + // full viewport render. The delta-sync below will then see + // libghostty_sb - 0 = libghostty_sb and refetch everything. + env.eraseBuffer(); + term.scrollback_in_buffer = 0; + term.first_scrollback_row_hash = 0; + force_full = true; + } + } + + // ---- Scrollback sync --------------------------------------------------- + // libghostty stores scrollback + active screen in a single row space. + // The rows "above" the viewport are scrollback; our invariant is that + // those rows are all materialized in the Emacs buffer, one per line. + // + // We compute the viewport-start char position at most once per redraw + // by walking from point-min and reusing point() after any insert/trim + // touches it. forwardLine is O(scrollback) so doing it twice would + // double the per-redraw cost in long-running sessions. + const total_rows = term.getTotalRows(); + const libghostty_sb: usize = if (total_rows > term.rows) total_rows - term.rows else 0; + + // Walk to the current viewport start (line scrollback_in_buffer + 1). + env.gotoCharN(1); + if (term.scrollback_in_buffer > 0) { + _ = env.forwardLine(@as(i64, @intCast(term.scrollback_in_buffer))); + } + var viewport_start_int = env.extractInteger(env.point()); + + if (libghostty_sb > term.scrollback_in_buffer) { + // New rows scrolled off in libghostty. Strategy: + // + // 1. Promote as many existing buffer rows as possible. The rows + // that were at the top of the viewport in the previous redraw + // are exactly the rows libghostty just pushed into scrollback, + // so just bumping `scrollback_in_buffer` makes them scrollback + // in our model too — no fetch, no re-render. Critically, any + // text properties applied to those rows while they were in the + // viewport (URL detection, ghostel-prompt, etc.) survive + // automatically because we never touch the text. + // + // 2. If the buffer didn't have enough viewport rows to absorb the + // full delta (bootstrap, post-resize, or a burst that scrolled + // more rows than the viewport between redraws), fall back to + // `insertScrollbackRange` for the remainder. + var delta = libghostty_sb - term.scrollback_in_buffer; + + env.gotoCharN(viewport_start_int); + const remaining_lines = env.forwardLine(@as(i64, @intCast(delta))); + var promoted: usize = delta - @as(usize, @intCast(remaining_lines)); + + // forward-line counts the position right after the last buffer + // char as a moveable "line N+1" even when the buffer doesn't + // end in \n. That position corresponds to our terminal cursor + // row — a stale snapshot, NOT a real libghostty scrollback row. + // If we landed at pointMax with no trailing newline, peel one + // off the promoted count so we don't promote the cursor row. + if (promoted > 0 and env.extractInteger(env.point()) == env.extractInteger(env.pointMax())) { + const cb = env.call0(emacs.sym.@"char-before"); + if (env.isNotNil(cb) and env.extractInteger(cb) != '\n') { + promoted -= 1; + } + } + + if (promoted > 0) { + term.scrollback_in_buffer += promoted; + // forward-line may have left point at pointMax even when it + // partially walked past complete lines, so always re-walk + // exactly `promoted` newline-bounded lines to anchor the new + // viewport_start at the start of the first un-promoted row. + env.gotoCharN(viewport_start_int); + _ = env.forwardLine(@as(i64, @intCast(promoted))); + viewport_start_int = env.extractInteger(env.point()); + delta -= promoted; + } + + if (delta > 0) { + // Bootstrap fallback: fetch the rest from libghostty. + env.gotoCharN(viewport_start_int); + const inserted = insertScrollbackRange( + env, + term, + term.scrollback_in_buffer, + delta, + default_fg, + default_bg, + ); + term.scrollback_in_buffer += inserted; + viewport_start_int = env.extractInteger(env.point()); + + // insertScrollbackRange scrolled libghostty's viewport through + // the scrollback range — restore it to the active screen and + // refresh the render state for the viewport render below. + term.scrollViewport(gt.SCROLL_BOTTOM, 0); + if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) return; + } + } else if (libghostty_sb < term.scrollback_in_buffer) { + // libghostty's scrollback cap evicted the oldest rows — trim the + // same number of lines from the top of the buffer. + const delta = term.scrollback_in_buffer - libghostty_sb; + env.gotoCharN(1); + if (env.forwardLine(@as(i64, @intCast(delta))) == 0) { + env.deleteRegion(env.makeInteger(1), env.point()); + term.scrollback_in_buffer -= delta; + // After the delete, the new viewport start has shifted down. + // forwardLine is already at the right line (point-min + delta + // lines was the next surviving row, now line 1+scrollback_in_buffer). + // Recompute by walking the remaining scrollback rows. + } else { + // Ran off the end — buffer is out of sync; rebuild from scratch. + env.eraseBuffer(); + term.scrollback_in_buffer = 0; + } + env.gotoCharN(1); + if (term.scrollback_in_buffer > 0) { + _ = env.forwardLine(@as(i64, @intCast(term.scrollback_in_buffer))); + } + viewport_start_int = env.extractInteger(env.point()); + } + // Check dirty state — cells are only redrawn when dirty, but cursor // positioning always runs so that cursor-only movements are visible. var dirty: c_int = gt.DIRTY_FALSE; @@ -694,12 +939,6 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void { var has_wide_chars: bool = false; if (dirty != gt.DIRTY_FALSE) { - // Get default colors - var default_fg = gt.ColorRgb{ .r = 204, .g = 204, .b = 204 }; - var default_bg = gt.ColorRgb{ .r = 0, .g = 0, .b = 0 }; - _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_COLOR_FOREGROUND, @ptrCast(&default_fg)); - _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_COLOR_BACKGROUND, @ptrCast(&default_bg)); - // Set buffer default face var fg_hex: [7]u8 = undefined; var bg_hex: [7]u8 = undefined; @@ -718,7 +957,8 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void { // force_full bypasses partial mode to avoid stale rows after scrolls. const partial = (!force_full and dirty == gt.DIRTY_PARTIAL); if (!partial) { - env.eraseBuffer(); + // Wipe only the viewport region; scrollback stays intact. + env.deleteRegion(env.makeInteger(viewport_start_int), env.pointMax()); } // Shared buffers for row content @@ -762,8 +1002,8 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void { } if (partial) { - // Navigate to this row and clear its content - env.gotoCharN(1); + // Navigate to this row (viewport-relative) and clear its content. + env.gotoCharN(viewport_start_int); const moved = env.forwardLine(@as(i64, @intCast(row_count))); if (moved != 0) { // Row doesn't exist yet — fall through to append @@ -820,10 +1060,10 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void { } // Trim excess buffer lines beyond the terminal's row count. - // Partial redraws don't erase the buffer, so stale trailing - // lines can accumulate after a resize or mode switch. + // Partial redraws don't erase the viewport region, so stale + // trailing lines can accumulate after a resize or mode switch. if (partial and row_count > 0) { - env.gotoCharN(1); + env.gotoCharN(viewport_start_int); if (env.forwardLine(@as(i64, @intCast(row_count))) == 0) { env.deleteRegion(env.point(), env.pointMax()); } @@ -848,19 +1088,26 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void { &hl_uri_buf, ); if (hl.count > 0) { - applyHyperlinks(env, &hl_spans, hl.count, &hl_uri_buf); + applyHyperlinks(env, &hl_spans, hl.count, &hl_uri_buf, viewport_start_int); } } - // Auto-detect plain-text URLs + // Auto-detect plain-text URLs — only on the viewport region. Scrollback + // rows are skipped to keep streaming O(viewport) per redraw instead of + // O(buffer); rows that have already scrolled past stay as plain text + // and remain searchable, just not clickable. if (dirty != gt.DIRTY_FALSE) { - _ = env.call0(emacs.sym.@"ghostel--detect-urls"); + _ = env.call2( + emacs.sym.@"ghostel--detect-urls", + env.makeInteger(viewport_start_int), + env.pointMax(), + ); if (has_wide_chars) { _ = env.call2(env.intern("set"), emacs.sym.@"ghostel--has-wide-chars", env.t()); } } - // Position cursor + // Position cursor (viewport-relative row -> absolute line) var cursor_has_value: bool = false; _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_CURSOR_VIEWPORT_HAS_VALUE, @ptrCast(&cursor_has_value)); if (cursor_has_value) { @@ -869,7 +1116,7 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void { _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_CURSOR_VIEWPORT_X, @ptrCast(&cx)); _ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_CURSOR_VIEWPORT_Y, @ptrCast(&cy)); - env.gotoCharN(1); + env.gotoCharN(viewport_start_int); _ = env.forwardLine(@as(i64, cy)); env.moveToColumn(@as(i64, cx)); } @@ -891,4 +1138,17 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void { if (term.getPwd()) |pwd| { _ = env.call1(emacs.sym.@"ghostel--update-directory", env.makeString(pwd)); } + + // Update the cached first-scrollback-row hash for the next redraw's + // rotation check. Always re-sample (cheap) because previous-redraw + // promotion/insert/trim could have shifted the row at index 0. + if (term.scrollback_in_buffer > 0) { + term.first_scrollback_row_hash = computeFirstScrollbackRowHash(term); + } else { + term.first_scrollback_row_hash = 0; + } + + // Clear the write flag so the next redraw can detect "writes happened + // since last redraw" for the rotation check. + term.wrote_since_redraw = false; } diff --git a/src/terminal.zig b/src/terminal.zig index a4dbf9c..e380048 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -30,6 +30,25 @@ mouse_encoder: gt.c.GhosttyMouseEncoder, cols: u16, rows: u16, +/// Number of libghostty scrollback rows already materialized into the +/// Emacs buffer above the viewport. Polled on each redraw; kept in sync +/// by appending newly-scrolled-off rows and trimming rows evicted by +/// libghostty's scrollback cap. +scrollback_in_buffer: usize = 0, + +/// Set by `vtWrite`, cleared at the end of `redraw`. Used to detect that +/// libghostty has been written to since the last redraw — required by +/// the cap-bound stale-scrollback rebuild trigger to distinguish "no +/// activity" from "writes happened but total_rows plateaued". +wrote_since_redraw: bool = false, + +/// Hash of the first scrollback row's content, sampled at the end of +/// each redraw that touched scrollback. Used to detect rotation +/// (libghostty evicting the oldest row in lockstep with new ones being +/// pushed) when `total_rows` is plateaued at the cap. Zero means "no +/// scrollback" or "not yet sampled". +first_scrollback_row_hash: u64 = 0, + /// Cached Emacs env pointer — only valid during a callback from Emacs. env: ?emacs.Env = null, @@ -181,15 +200,23 @@ pub fn getColorBackground(self: *Self, out: *gt.ColorRgb) bool { /// Feed VT data from the PTY into the terminal. pub fn vtWrite(self: *Self, data: []const u8) void { gt.c.ghostty_terminal_vt_write(self.terminal, data.ptr, data.len); + self.wrote_since_redraw = true; } /// Resize the terminal. +/// +/// Resets `scrollback_in_buffer` because libghostty reflows wrapped rows +/// on resize and the row count above the viewport no longer matches what +/// we have in the Emacs buffer. The caller is responsible for erasing the +/// buffer so the next redraw rebuilds scrollback from scratch. pub fn resize(self: *Self, cols: u16, rows: u16) !void { if (gt.c.ghostty_terminal_resize(self.terminal, cols, rows, 1, 1) != gt.SUCCESS) { return error.ResizeFailed; } self.cols = cols; self.rows = rows; + self.scrollback_in_buffer = 0; + self.first_scrollback_row_hash = 0; } /// Scroll the viewport. diff --git a/test/ghostel-test.el b/test/ghostel-test.el index fc32386..198b384 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -172,12 +172,187 @@ (let ((state (ghostel--debug-state term))) (should (string-match-p "line [0-4]" state))))) ; scrollback shows earlier lines +;; ----------------------------------------------------------------------- +;; Test: scrollback is materialized into the Emacs buffer (vterm parity) +;; ----------------------------------------------------------------------- + +(ert-deftest ghostel-test-scrollback-in-buffer () + "After overflowing the viewport, scrolled-off rows live in the Emacs buffer. +This is the vterm-style growing-buffer model that lets `isearch' and +`consult-line' search history without entering copy mode." + (let ((buf (generate-new-buffer " *ghostel-test-sb-buffer*"))) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 80 1000)) + (inhibit-read-only t)) + ;; Write 12 lines into a 5-row terminal — 7 should scroll off. + (dotimes (i 12) + (ghostel--write-input term (format "row-%02d\r\n" i))) + (ghostel--redraw term t) + (let ((content (buffer-substring-no-properties (point-min) (point-max)))) + ;; Earliest row that scrolled off must now live in the buffer. + (should (string-match-p "row-00" content)) + ;; A middle row that scrolled off must also be present. + (should (string-match-p "row-05" content)) + ;; The most recent row is on the active screen. + (should (string-match-p "row-11" content))) + ;; 12 distinct rows made it into the buffer. The trailing + ;; empty cursor row is trimmed to nothing by the renderer + ;; and therefore contributes no additional line. + (should (= 12 (count-lines (point-min) (point-max)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-render-trims-trailing-whitespace () + "Rendered rows do not carry libghostty's full-width padding. +The renderer should only keep cells the terminal actually wrote to, +so a short line in a 40-column terminal shows up as the written +content plus no trailing space padding. Shell-written spaces +\(e.g. the trailing space in a \\='$ \\=' prompt or `%-80s' layout) +are retained — only unwritten padding cells are trimmed." + (let ((buf (generate-new-buffer " *ghostel-test-trim-ws*"))) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 3 40 100)) + (inhibit-read-only t)) + ;; Write `hi` at the top-left and redraw. + (ghostel--write-input term "\e[H\e[2Jhi") + (ghostel--redraw term t) + (let ((lines (split-string (buffer-substring-no-properties + (point-min) (point-max)) + "\n"))) + ;; First row is trimmed to "hi" (no trailing spaces). + (should (equal "hi" (car lines))) + ;; Remaining rows are empty (not rows of 40 spaces). + (dolist (row (cdr lines)) + (should (string-empty-p row)))) + ;; Shell-written trailing space is preserved. + (ghostel--write-input term "\e[H\e[2J$ ") + (ghostel--redraw term t) + (let ((lines (split-string (buffer-substring-no-properties + (point-min) (point-max)) + "\n"))) + (should (equal "$ " (car lines)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-scrollback-preserves-url-properties () + "Verify URL text properties survive scrollback promotion. +When libghostty pushes a row into scrollback, the redraw promotes the +existing buffer text instead of fetching a fresh copy from libghostty, +so any text properties the row earned while it was the viewport (URL +detection, ghostel-prompt) stay attached." + (let ((buf (generate-new-buffer " *ghostel-test-sb-url*"))) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 80 1000)) + (inhibit-read-only t) + (ghostel-enable-url-detection t) + (ghostel-enable-file-detection nil)) + ;; Write a row with a URL while it's in the viewport. + (ghostel--write-input term "see https://example.com here\r\n") + (ghostel--redraw term t) + ;; Sanity: detect-urls applied a help-echo while the row is visible. + (goto-char (point-min)) + (let ((url-pos (search-forward "https://example.com" nil t))) + (should url-pos) + (should (equal "https://example.com" + (get-text-property (- url-pos 19) 'help-echo)))) + ;; Now scroll the URL row off the active screen. + (dotimes (_ 6) (ghostel--write-input term "filler\r\n")) + (ghostel--redraw term t) + ;; The URL row now lives in the scrollback region of the buffer. + (goto-char (point-min)) + (let ((url-pos (search-forward "https://example.com" nil t))) + (should url-pos) + ;; The clickable text properties survived the scroll because + ;; promotion preserved the buffer text instead of re-fetching + ;; from libghostty. + (should (equal "https://example.com" + (get-text-property (- url-pos 19) 'help-echo)))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-scrollback-grows-incrementally () + "Successive redraws append newly-scrolled-off rows without losing history." + (let ((buf (generate-new-buffer " *ghostel-test-sb-incr*"))) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 80 1000)) + (inhibit-read-only t)) + ;; First batch: write 8 lines, redraw. + (dotimes (i 8) + (ghostel--write-input term (format "first-%02d\r\n" i))) + (ghostel--redraw term t) + (let ((content (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "first-00" content)) + (should (string-match-p "first-07" content))) + ;; Second batch: write more lines, redraw again. + (dotimes (i 6) + (ghostel--write-input term (format "second-%02d\r\n" i))) + (ghostel--redraw term t) + (let ((content (buffer-substring-no-properties (point-min) (point-max)))) + ;; All earlier scrollback rows survive the second redraw. + (should (string-match-p "first-00" content)) + (should (string-match-p "first-07" content)) + (should (string-match-p "second-00" content)) + (should (string-match-p "second-05" content))))) + (kill-buffer buf)))) + +(ert-deftest ghostel-test-scrollback-rotation-rebuild () + "Verify cap rotation triggers a rebuild so the buffer reflects libghostty. +The test fills libghostty past its scrollback cap with EARLY markers, +redraws once so the buffer matches the current libghostty state, then +writes a much bigger batch of LATE markers (without an intervening +redraw). When the next redraw runs, libghostty's `total_rows' is +plateaued at the cap so the normal delta-detection sees nothing to do +— the rotation-detect path must kick in, notice the first scrollback +row's hash has changed, erase the buffer, and let the bootstrap fetch +re-sync from libghostty so the buffer reflects the LATE rows." + (let ((buf (generate-new-buffer " *ghostel-test-sb-rotate*"))) + (unwind-protect + (with-current-buffer buf + (let* (;; 4 KB cap empirically holds ~920 rows of short content + ;; in libghostty's compact storage. + (term (ghostel--new 5 80 (* 4 1024))) + (inhibit-read-only t)) + ;; Phase 1: write 5000 EARLY rows. libghostty's scrollback + ;; saturates at ~920 rows so the surviving rows are + ;; early-04080..early-04999 (the most recent 920 of 5000). + (dotimes (i 5000) + (ghostel--write-input term (format "early-%05d\r\n" i))) + (ghostel--redraw term t) + ;; After this redraw, buffer's scrollback_in_buffer matches + ;; libghostty's count (~920) and contains those high-numbered + ;; early rows. + (let ((content (buffer-substring-no-properties (point-min) (point-max)))) + (should (string-match-p "early-04999" content))) + ;; Phase 2: write 5000 LATE rows WITHOUT redrawing in + ;; between. libghostty rotates: every new write evicts an + ;; early row and pushes a late row. After 5000 writes, all + ;; survivors are late-* (since 5000 > 920 cap). + (dotimes (i 5000) + (ghostel--write-input term (format "late-%05d\r\n" i))) + ;; Final redraw: total_rows hasn't changed (libghostty is + ;; still at the cap) but the content has fully rotated. + ;; Without rotation-detect this would be a no-op and the + ;; buffer would still show early-* rows. + (ghostel--redraw term t) + (let ((content (buffer-substring-no-properties (point-min) (point-max)))) + ;; Late rows must be present (libghostty kept the most + ;; recent ones, the rebuild fetched them into the buffer). + (should (string-match-p "late-04999" content)) + ;; Early rows must NOT be present anywhere — libghostty + ;; evicted them AND the rebuild flushed our stale copy. + (should-not (string-match-p "early-" content))))) + (kill-buffer buf)))) + ;; ----------------------------------------------------------------------- ;; Test: clear screen (ghostel-clear) ;; ----------------------------------------------------------------------- (ert-deftest ghostel-test-clear-screen () - "Test that ghostel-clear clears the visible screen but preserves scrollback." + "Test that ghostel-clear clears the visible screen but preserves scrollback. +With the growing-buffer model the scrollback is always materialized into +the Emacs buffer, so we just check the buffer text directly instead of +scrolling libghostty's viewport." (let ((buf (generate-new-buffer " *ghostel-test-clear*"))) (unwind-protect (with-current-buffer buf @@ -211,11 +386,10 @@ ;; Simulate what delayed-redraw does (ghostel--flush-pending-output) (let ((inhibit-read-only t)) (ghostel--redraw ghostel--term t)) - ;; Scrollback should still exist after screen clear - (ghostel--scroll ghostel--term -30) - (let ((inhibit-read-only t)) (ghostel--redraw ghostel--term t)) + ;; Scrollback rows live in the buffer above the cleared + ;; viewport — search for any clear-test echo to confirm. (let ((content (buffer-substring-no-properties (point-min) (point-max)))) - (should (string-match-p "clear-test-0" content))) ; scrollback preserved + (should (string-match-p "clear-test-[0-9]+" content))) (delete-process proc))) (kill-buffer buf)))) @@ -317,7 +491,9 @@ (ert-deftest ghostel-test-wide-char-no-overflow () "Test that wide characters (emoji) don't make rendered lines overflow. A 2-cell-wide emoji should not produce an extra space for the spacer -cell, so the visual line width must equal the terminal column count." +cell, so the visual line width must equal the emoji width (2). The +renderer trims trailing blank cells, so we compare against 2 rather +than the full terminal `cols'." (let ((buf (generate-new-buffer " *ghostel-test-wide*")) (cols 40)) (unwind-protect @@ -327,12 +503,15 @@ cell, so the visual line width must equal the terminal column count." ;; Feed a wide emoji — occupies 2 terminal cells (ghostel--write-input term "🟢") (ghostel--redraw term t) - ;; First rendered line should have visual width == cols + ;; First rendered line should have visual width 2 (the + ;; emoji) and no trailing padding from the spacer cell. (goto-char (point-min)) (let* ((line (buffer-substring (line-beginning-position) (line-end-position))) (width (string-width line))) - (should (equal cols width))))) + (should (equal 2 width)) + ;; And the line must NOT exceed the terminal width. + (should (<= width cols))))) (kill-buffer buf)))) ;; ----------------------------------------------------------------------- @@ -868,7 +1047,9 @@ the reply waits for the redraw timer." (should (string-match-p "line-B" content)) ; row1 preserved (should (string-match-p "line-C updated" content))) ; row2 updated - (should (equal 5 (count-lines (point-min) (point-max)))))) ; line count + ;; 3 content rows + 2 trailing blank rows trimmed to + ;; empty strings = 4 newlines = 4 lines counted. + (should (equal 4 (count-lines (point-min) (point-max)))))) (kill-buffer buf)))) ;; ----------------------------------------------------------------------- @@ -1273,7 +1454,11 @@ app redraws all rows at new width via the filter pipeline." (ghostel--redraw ghostel--term t) (let ((c (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "WIDE-R00" c)) - ;; Verify rows are 80 chars wide. + ;; Row 1 is at most `cols' chars wide after the + ;; renderer trims unwritten padding. The shell + ;; here left-pads with spaces up to 80 cols via + ;; `%-80s', which libghostty records as written + ;; space cells, so row 1 stays exactly 80 chars. (should (= 80 (length (car (split-string c "\n")))))) ;; Simulate what the resize function does. @@ -1296,8 +1481,11 @@ app redraws all rows at new width via the filter pipeline." (should (string-match-p "NARROW-R05" content)) ;; No old wide content. (should-not (string-match-p "WIDE-R" content)) - ;; Rows should be 40 chars wide (new terminal width). - (should (= 40 (length (car (split-string content "\n"))))))) + ;; Each row is at most 40 chars (the new terminal + ;; width) — the app wrote 10 chars then stopped, + ;; so the renderer trims at the content end. + (dolist (row (split-string content "\n")) + (should (<= (length row) 40))))) (when (process-live-p proc) (delete-process proc))))) (kill-buffer buf)))) @@ -1612,52 +1800,6 @@ buffer and hand nil to the native module." (ghostel-project '(4)) (should (equal '(4) result))))) -;; ----------------------------------------------------------------------- -;; Test: copy-mode-load-all state management -;; ----------------------------------------------------------------------- - -(ert-deftest ghostel-test-copy-mode-load-all () - "Test that `ghostel-copy-mode-load-all' sets full-buffer state." - (let ((buf (generate-new-buffer " *ghostel-test-load-all*"))) - (unwind-protect - (with-current-buffer buf - (ghostel-mode) - (let ((ghostel--copy-mode-active nil) - (ghostel--redraw-timer nil) - (ghostel--term 'fake-term)) - ;; Enter copy mode - (ghostel-copy-mode) - (should ghostel--copy-mode-active) ; in copy mode - (should-not ghostel--copy-mode-full-buffer) ; not full yet - ;; Simulate a 3-line viewport with point on line 2, column 3 - (let ((inhibit-read-only t)) - (erase-buffer) - (insert "aaa\nbbbXbb\nccc")) - (goto-char (point-min)) - (forward-line 1) - (move-to-column 3) ; on 'X' in line 2 - ;; Stub the native function and recenter (no window in batch). - ;; The stub must NOT bind inhibit-read-only itself — the real - ;; native function doesn't, so the caller must have it set. - ;; Returns viewport-line=3 (viewport starts at line 3 in full buffer) - (cl-letf (((symbol-function 'ghostel--redraw-full-scrollback) - (lambda (_term) - (erase-buffer) - (insert "sb1\nsb2\naaa\nbbbXbb\nccc") - 3)) - ((symbol-function 'recenter) #'ignore)) - (ghostel-copy-mode-load-all) - (should ghostel--copy-mode-full-buffer) ; now full - ;; Point should be on line 4 (viewport-line 3 + saved offset 1) - (should (= 4 (line-number-at-pos))) ; preserved line - (should (= 3 (current-column)))) - ;; Exit resets full-buffer state - (cl-letf (((symbol-function 'ghostel--scroll-bottom) #'ignore) - ((symbol-function 'ghostel--redraw) #'ignore)) - (ghostel-copy-mode-exit)) - (should-not ghostel--copy-mode-full-buffer))) ; reset on exit - (kill-buffer buf)))) - ;; ----------------------------------------------------------------------- ;; Test: ghostel-copy-all copies to kill ring ;; ----------------------------------------------------------------------- @@ -1678,32 +1820,28 @@ buffer and hand nil to the native module." (kill-buffer buf)))) ;; ----------------------------------------------------------------------- -;; Test: copy-mode scroll commands in full-buffer mode +;; Test: copy-mode scroll commands use Emacs navigation ;; ----------------------------------------------------------------------- -(ert-deftest ghostel-test-copy-mode-full-buffer-scroll () - "Test that scroll commands use Emacs navigation in full-buffer mode." - (let ((buf (generate-new-buffer " *ghostel-test-full-scroll*"))) +(ert-deftest ghostel-test-copy-mode-buffer-navigation () + "Copy-mode navigation commands operate on the Emacs buffer directly." + (let ((buf (generate-new-buffer " *ghostel-test-copy-nav*"))) (unwind-protect (with-current-buffer buf (ghostel-mode) (let ((ghostel--copy-mode-active t) - (ghostel--copy-mode-full-buffer t) (ghostel--term 'fake-term) (inhibit-read-only t)) - ;; Insert content (insert (mapconcat #'number-to-string (number-sequence 1 20) "\n")) (goto-char (point-min)) - ;; Test beginning/end of buffer (ghostel-copy-mode-end-of-buffer) - (should (= (point) (point-max))) ; jumped to end + (should (= (point) (point-max))) (ghostel-copy-mode-beginning-of-buffer) - (should (= (point) (point-min))) ; jumped to beginning - ;; Test line navigation + (should (= (point) (point-min))) (ghostel-copy-mode-next-line) - (should (= 2 (line-number-at-pos))) ; moved to line 2 + (should (= 2 (line-number-at-pos))) (ghostel-copy-mode-previous-line) - (should (= 1 (line-number-at-pos))))) ; moved back to line 1 + (should (= 1 (line-number-at-pos))))) (kill-buffer buf)))) ;; ----------------------------------------------------------------------- @@ -1999,8 +2137,6 @@ buffer and hand nil to the native module." (let ((ghostel--term 'fake) (ghostel--process 'fake) (ghostel--copy-mode-active nil) - (ghostel--copy-mode-full-buffer nil) - (ghostel--force-next-redraw nil) (mouse-event-args nil) (scroll-called nil) ;; Fake wheel-up event at row 5, col 10 @@ -2010,8 +2146,8 @@ buffer and hand nil to the native module." (lambda (_term action button row col mods) (setq mouse-event-args (list action button row col mods)) t)) - ((symbol-function 'ghostel--scroll) - (lambda (_term _delta) (setq scroll-called t))) + ((symbol-function 'scroll-down) + (lambda (&optional _) (setq scroll-called t))) ((symbol-function 'process-live-p) (lambda (_p) t))) (ghostel--scroll-up fake-event) (should mouse-event-args) @@ -2027,8 +2163,8 @@ buffer and hand nil to the native module." (lambda (_term action button row col mods) (setq mouse-event-args (list action button row col mods)) t)) - ((symbol-function 'ghostel--scroll) - (lambda (_term _delta) (setq scroll-called t))) + ((symbol-function 'scroll-up) + (lambda (&optional _) (setq scroll-called t))) ((symbol-function 'process-live-p) (lambda (_p) t))) (ghostel--scroll-down fake-down-event) (should mouse-event-args) @@ -2036,29 +2172,25 @@ buffer and hand nil to the native module." (should-not scroll-called))))) (ert-deftest ghostel-test-scroll-fallback-no-mouse-tracking () - "Scroll-up/down fall back to viewport scroll when mouse tracking is off." + "Scroll-up/down fall back to Emacs window scroll when mouse tracking is off." (let ((ghostel--term 'fake) (ghostel--process 'fake) (ghostel--copy-mode-active nil) - (ghostel--copy-mode-full-buffer nil) - (ghostel--force-next-redraw nil) - (scroll-delta nil) + (scroll-down-arg nil) + (scroll-up-arg nil) (fake-up-event `(wheel-up (,(selected-window) 1 (10 . 5) 0))) (fake-down-event `(wheel-down (,(selected-window) 1 (10 . 5) 0)))) (cl-letf (((symbol-function 'ghostel--mouse-event) (lambda (_term _action _button _row _col _mods) nil)) - ((symbol-function 'ghostel--scroll) - (lambda (_term delta) (setq scroll-delta delta))) - ((symbol-function 'ghostel--invalidate) #'ignore) + ((symbol-function 'scroll-down) + (lambda (&optional n) (setq scroll-down-arg n))) + ((symbol-function 'scroll-up) + (lambda (&optional n) (setq scroll-up-arg n))) ((symbol-function 'process-live-p) (lambda (_p) t))) (ghostel--scroll-up fake-up-event) - (should (equal -3 scroll-delta)) - (should ghostel--force-next-redraw) - ;; Reset and test scroll-down fallback - (setq scroll-delta nil ghostel--force-next-redraw nil) + (should (equal 3 scroll-down-arg)) (ghostel--scroll-down fake-down-event) - (should (equal 3 scroll-delta)) - (should ghostel--force-next-redraw)))) + (should (equal 3 scroll-up-arg))))) (ert-deftest ghostel-test-control-key-bindings () "All non-exception C- keys should be bound in ghostel-mode-map." @@ -2090,67 +2222,12 @@ buffer and hand nil to the native module." ;; ----------------------------------------------------------------------- (ert-deftest ghostel-test-copy-mode-recenter () - "Recenter scrolls terminal viewport to center the current line." - (let ((buf (generate-new-buffer " *ghostel-test-copy-mode-recenter*"))) - (unwind-protect - (with-current-buffer buf - (dotimes (i 20) (insert (format "line-%02d" i) (make-string 33 ?x) "\n")) - (setq ghostel--term 'fake-term) - (setq ghostel--copy-mode-active t) - (setq buffer-read-only t) - (let ((scroll-delta nil) - (redraw-called nil) - (recenter-called nil)) - ;; Mock redraw that changes the first line (simulates viewport shift). - (cl-letf (((symbol-function 'ghostel--scroll) - (lambda (_term delta) (setq scroll-delta delta))) - ((symbol-function 'ghostel--redraw) - (lambda (_term _full) - (setq redraw-called t) - (save-excursion - (goto-char (point-min)) - (delete-char 1) - (insert "!")))) - ((symbol-function 'window-body-height) - (lambda (&rest _) 20)) - ((symbol-function 'recenter) - (lambda (&rest _) (setq recenter-called t)))) - ;; Point on line 5 (above center 10) → scroll viewport up - (goto-char (point-min)) - (forward-line 4) - (ghostel-copy-mode-recenter) - (should (equal -5 scroll-delta)) - (should redraw-called) - (should recenter-called)) - - ;; Mock redraw that does NOT change buffer (simulates clamped scroll). - (cl-letf (((symbol-function 'ghostel--scroll) - (lambda (_term delta) (setq scroll-delta delta))) - ((symbol-function 'ghostel--redraw) - (lambda (_term _full) (setq redraw-called t))) - ((symbol-function 'window-body-height) - (lambda (&rest _) 20)) - ((symbol-function 'recenter) - (lambda (&rest _) (setq recenter-called t)))) - ;; Point on line 15 (below center), scroll clamped → no-op - (setq scroll-delta nil redraw-called nil recenter-called nil) - (goto-char (point-min)) - (forward-line 14) - (ghostel-copy-mode-recenter) - (should (equal 5 scroll-delta)) - (should redraw-called) - (should-not recenter-called) - (should (= 15 (line-number-at-pos))) - - ;; Point on line 10 (at center) → no scroll at all - (setq scroll-delta nil redraw-called nil recenter-called nil) - (goto-char (point-min)) - (forward-line 9) - (ghostel-copy-mode-recenter) - (should-not scroll-delta) - (should-not redraw-called) - (should-not recenter-called)))) - (kill-buffer buf)))) + "Copy-mode recenter delegates to the standard `recenter' command." + (let ((called nil)) + (cl-letf (((symbol-function 'recenter) + (lambda (&rest _) (setq called t)))) + (ghostel-copy-mode-recenter) + (should called)))) ;; ----------------------------------------------------------------------- ;; Test: ghostel-send-next-key @@ -2544,9 +2621,8 @@ while :; do sleep 0.1; done'\n") ghostel-test-copy-mode-hl-line ghostel-test-project-buffer-name ghostel-test-project-universal-arg - ghostel-test-copy-mode-load-all ghostel-test-copy-all - ghostel-test-copy-mode-full-buffer-scroll + ghostel-test-copy-mode-buffer-navigation ghostel-test-package-version ghostel-test-module-version-match ghostel-test-module-version-mismatch