From 34645e26c3d5b4d229fc8ea30d45c668dae17b46 Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Fri, 10 Apr 2026 16:09:49 +0200 Subject: [PATCH 1/5] Materialize libghostty scrollback into the Emacs buffer (vterm parity) The Emacs buffer now mirrors libghostty's full scrollback above the viewport, so isearch, consult-line, swiper, occur, and any other buffer-based command work over the entire history without entering copy mode. How it works (src/render.zig redraw): - Track scrollback_in_buffer in the Terminal struct (src/terminal.zig). - Each redraw polls libghostty's total_rows. When new rows have scrolled off, promote the existing top viewport rows to scrollback by bumping the counter -- the buffer text is never touched, so any text properties applied while the row was the viewport (URL detection, ghostel-prompt) survive automatically. - Bootstrap fallback: when the buffer doesn't have enough viewport rows yet (cold start, post-resize, large bursts), fetch the remaining rows from libghostty via insertScrollbackRange, which pages the libghostty viewport across the requested range. Each row is built into text_buf with its trailing newline appended in-buffer so the row + newline goes through a single env.insert call. - forward-line counts the position past the last char as a moveable "line" when the buffer doesn't end in \\n -- that position is the terminal cursor row, not a real scrollback row, so detect it via char-before and decrement the promotion count. - Trim from the top when libghostty's scrollback cap evicts old rows. - Anchor the viewport render at viewport_start_int instead of point-min: deleteRegion(viewport_start, point-max) on full redraw, forwardLine offsets on partial redraw, applyHyperlinks gets a viewport_start parameter, cursor positioning uses it as base. - ghostel--detect-urls now takes optional (begin end) bounds and the redraw passes the viewport region only -- avoids O(N^2) scans of the growing buffer. Copy mode is now just "freeze the redraw timer + swap keymap": - Removed ghostel--copy-mode-full-buffer state and the ghostel-copy-mode-load-all command (and its C-c C-a binding). - Removed ghostel-copy-mode-auto-load-scrollback custom and the ghostel--redraw-full-scrollback module binding. - Copy-mode scroll/navigation commands use plain Emacs primitives (scroll-up/down, forward-line, recenter) instead of toggling libghostty viewport scrolling. - ghostel-copy-mode-exit moves point to point-max before invalidating so the redraw is allowed to position point at the terminal cursor (otherwise the new point-preservation logic would keep point in the scrollback region where the user was navigating). Live-output point preservation: - Track the terminal row count as ghostel--term-rows (set when the terminal is created/resized). - ghostel--delayed-redraw saves point as a marker when point is in the scrollback region (above the last term-rows lines) and restores it after the redraw, so scrolling up to read history during live output no longer yanks the user back to the cursor. Resize: - fnSetSize now erases the buffer after term.resize, and Terminal.resize resets scrollback_in_buffer = 0. Reflow invalidates the row layout, so the next redraw rebuilds via the bootstrap path. Default scrollback lowered from 20 MB to 5 MB: - The new growing-buffer model materializes scrollback into the Emacs buffer with text properties on every cell, so the cost is no longer just libghostty's compact byte storage. - Streaming throughput is bounded by scrollback size: at 20 MB it runs at ~25 MB/s, at 10 MB ~38 MB/s, at 5 MB ~48 MB/s, at 1 MB ~59 MB/s. 5 MB still holds ~5,000 rows on a typical 80-column terminal -- plenty for build outputs, grep results, log tailing -- while keeping the per-redraw cost close to the no-scrollback baseline. - Updated the docstring, README, and the C default fallback in fnNew to reflect the new memory model. Bench fix (bench/ghostel-bench.el): - ghostel-bench-scrollback is documented as "scrollback lines" and passed to vterm/term as lines, but ghostel--make-ghostel was passing it directly to ghostel--new which interprets it as bytes. At the default value of 1000 this meant vterm got 1000 lines of scrollback while ghostel got 1000 bytes (effectively zero rows), giving ghostel an unfair head start in the comparison. Convert to bytes (* 1024) so all backends test against ~1000 lines. README perf numbers refreshed against the fair comparison and the new growing-buffer model: ghostel 64 MB/s (was 72), vterm 28 MB/s (was 33). Ghostel is still ~2x faster than vterm on PTY plain ASCII. The drop versus the old 72 MB/s number reflects both (a) the new model paying a small per-redraw cost for scrollback bookkeeping even at zero scrollback and (b) the bench fix making the comparison honest. Tests (test/ghostel-test.el): - New: ghostel-test-scrollback-in-buffer (12 rows into a 5-row term, assert scrolled-off rows live in the buffer). - New: ghostel-test-scrollback-grows-incrementally (two-batch scroll, assert all earlier rows survive subsequent redraws -- exercises the partial-promotion path). - New: ghostel-test-scrollback-preserves-url-properties (write a row with a URL, redraw, scroll the row off, redraw, assert the URL's help-echo property is still attached -- the regression test for "URLs in scrollback are clickable"). - Removed ghostel-test-copy-mode-load-all (function gone). - Replaced ghostel-test-copy-mode-full-buffer-scroll with ghostel-test-copy-mode-buffer-navigation. - Rewrote ghostel-test-copy-mode-recenter from 60 lines of mocks to 6 lines verifying it delegates to recenter. - Updated the scroll-event tests to mock scroll-up/scroll-down. - Updated ghostel-test-clear-screen to check the materialized scrollback directly instead of relying on the legacy viewport-scroll behavior. --- README.md | 49 +++--- bench/ghostel-bench.el | 6 +- ghostel.el | 348 +++++++++++++++-------------------------- src/emacs.zig | 1 + src/module.zig | 16 +- src/render.zig | 345 +++++++++++++++++++++++++++------------- src/terminal.zig | 12 ++ test/ghostel-test.el | 271 +++++++++++++++----------------- 8 files changed, 526 insertions(+), 522 deletions(-) diff --git a/README.md b/README.md index 90519de..0b0f371 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 @@ -499,15 +489,17 @@ 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: +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 | 64 MB/s | 22 MB/s | +| ghostel (no detect) | 65 MB/s | 61 MB/s | +| vterm | 28 MB/s | 23 MB/s | +| eat | 4.0 MB/s | 3.1 MB/s | +| term | 5.0 MB/s | 4.2 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) | 64 MB/s | 28 MB/s | | Default redraw rate | ~30 fps | ~10 fps | ### Key differences @@ -599,10 +591,11 @@ 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.** In PTY throughput benchmarks (1 MB streamed through `cat`, +both backends configured with ~1,000 lines of scrollback), ghostel is roughly +2x faster than vterm on plain ASCII data (64 vs 28 MB/s). On URL-heavy +output the gap narrows as ghostel's link detection adds overhead, but with +detection disabled ghostel reaches 65 MB/s. See the [Performance](#performance) section above for full numbers and how to run the benchmark suite yourself. 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..95349d9 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(); @@ -537,109 +540,82 @@ 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 (content.byte_len < text_buf.len) { + 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); + const after_insert = env.extractInteger(env.point()); - // Mark prompt portion if (content.prompt_char_len > 0) { env.putTextProperty( env.makeInteger(row_start), @@ -650,40 +626,183 @@ 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). +/// +/// 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: bool) void { + // 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 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 +813,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 +831,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 +876,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 +934,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 +962,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 +990,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)); } diff --git a/src/terminal.zig b/src/terminal.zig index a4dbf9c..5b2505e 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -30,6 +30,12 @@ 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, + /// Cached Emacs env pointer — only valid during a callback from Emacs. env: ?emacs.Env = null, @@ -184,12 +190,18 @@ pub fn vtWrite(self: *Self, data: []const u8) void { } /// 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; } /// Scroll the viewport. diff --git a/test/ghostel-test.el b/test/ghostel-test.el index fc32386..cc05b29 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -172,12 +172,106 @@ (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))) + ;; Buffer line count = scrollback rows + viewport rows. + ;; 12 written lines + 1 cursor row = 13 rows total + (should (= 13 (count-lines (point-min) (point-max)))))) + (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)))) + ;; ----------------------------------------------------------------------- ;; 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 +305,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)))) @@ -1612,52 +1705,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 +1725,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 +2042,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 +2051,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 +2068,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 +2077,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 +2127,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 +2526,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 From 9ed2a76ad859c185156deb8a00528dda5d61885d Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Fri, 10 Apr 2026 17:58:24 +0200 Subject: [PATCH 2/5] Detect cap rotation via first-row hash to keep scrollback fresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up correctness fix on top of the scrollback-in-buffer commit, in a separate commit so it can be reverted independently. When libghostty's scrollback hits its byte cap and starts evicting the oldest rows in lockstep with new ones being pushed, the row at scrollback index 0 changes underneath us. The normal delta-detection in redraw() tracks `total_rows` deltas, but those don't capture content rotation — and worse, the existing trim path (delta < 0) removes our top rows under the assumption they match the rows libghostty just evicted, which isn't true after rotation has shifted the content. User-visible symptom: after sustained streaming past the cap, isearch / consult-line over the buffer's scrollback returns rows that no longer exist in libghostty. Fix: - Add `wrote_since_redraw: bool` and `first_scrollback_row_hash: u64` to the Terminal struct. - vtWrite sets `wrote_since_redraw = true`. The end of redraw clears it. resize() also clears `first_scrollback_row_hash` because reflow invalidates the row content. - Add `computeFirstScrollbackRowHash` helper: scrolls libghostty's viewport to the top, reads the first row's first ~16 cells, mixes them into an FNV-1a 64-bit hash, restores the viewport. Six libghostty calls — cheap, gated to only run when rotation is suspected (writes happened + we have scrollback + we have a stored hash). - At the start of redraw, before the existing delta sync, run the rotation check. If the stored hash differs from the freshly sampled hash, libghostty's scrollback has rotated underneath us: eraseBuffer, set scrollback_in_buffer = 0, force a full redraw. The delta-sync below will then see libghostty_sb - 0 = libghostty_sb and refetch everything fresh via insertScrollbackRange. - After the delta-sync, update the stored hash whenever we have scrollback so the next redraw has a fresh baseline. Why hash-the-row instead of comparing counts: libghostty's total_rows is allowed to plateau OR shrink when the cap is hit (it depends on page allocation and eviction semantics). Counter comparison alone misses the case where total_rows is steady at the cap with content rotating, and is wrong in the case where total_rows shrinks while content has actually rotated. Sampling the first row's content is the only signal that always tracks rotation correctly. Test (test/ghostel-test.el): - ghostel-test-scrollback-rotation-rebuild — write 5000 EARLY rows into a tiny-cap terminal (libghostty saturates at ~920 rows) + redraw, then write 5000 LATE rows without an intervening redraw, then redraw. The second redraw must detect rotation and rebuild so the buffer no longer contains any "early-" markers and shows the most recent late- rows. A previous version of this commit also included a "batched multi-row insert" optimization in insertScrollbackRange that collapsed N per-row env.insert calls into roughly N/page_rows calls. Empirically it only bought ~2-8% on the streaming bench because libghostty's vt_write parsing dominates redraw cost in that workload, not Elisp FFI. The added complexity (~140 lines: new RowMeta struct, new flushScrollbackChunk helper, cumulative char_offset arithmetic, chunk overflow handling, oversized-row fallback) wasn't worth the small gain. The batched-insert patch is preserved at .claude/batched-insert.patch and can be re-applied with `git apply .claude/batched-insert.patch` if the streaming hot path becomes more important later. --- src/render.zig | 98 +++++++++++++++++++++++++++++++++++++++++++- src/terminal.zig | 15 +++++++ test/ghostel-test.el | 48 ++++++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/render.zig b/src/render.zig index 95349d9..c4f148a 100644 --- a/src/render.zig +++ b/src/render.zig @@ -396,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, @@ -671,7 +721,9 @@ fn insertScrollbackRange( /// /// 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: bool) void { +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. @@ -693,6 +745,37 @@ pub fn redraw(env: emacs.Env, term: *Terminal, force_full: bool) void { _ = 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 @@ -1012,4 +1095,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 5b2505e..e380048 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -36,6 +36,19 @@ rows: u16, /// 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, @@ -187,6 +200,7 @@ 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. @@ -202,6 +216,7 @@ pub fn resize(self: *Self, cols: u16, rows: u16) !void { 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 cc05b29..1329499 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -263,6 +263,54 @@ detection, ghostel-prompt) stay attached." (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) ;; ----------------------------------------------------------------------- From d3acea1e668d46acf67078c219a758f481fdb12a Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Fri, 10 Apr 2026 18:26:14 +0200 Subject: [PATCH 3/5] Always insert trailing newline in insertScrollbackRange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a row's encoded bytes exactly fill text_buf, the in-buffer newline append was skipped, leaving the row without a trailing newline. The prompt/wrap property math downstream assumes `after_insert - 1` points at the row's newline, so a missed newline would misapply those properties to the last character of the row instead. For a standard 80-column terminal the max encoded row is ~321 bytes (far below the 16 KB buffer), so this was only reachable on pathological column counts — but the invariant shouldn't depend on that. Fall back to a separate env.insert("\n") when the newline couldn't fit in the text buffer, so the "one row per line" contract always holds. --- src/render.zig | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/render.zig b/src/render.zig index c4f148a..08b82b4 100644 --- a/src/render.zig +++ b/src/render.zig @@ -655,8 +655,13 @@ fn insertScrollbackRange( // 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 (content.byte_len < text_buf.len) { + // 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; @@ -664,6 +669,9 @@ fn insertScrollbackRange( 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()); if (content.prompt_char_len > 0) { From b3e86b50435aadeb743713c29ff0ef566c6272ff Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Sat, 11 Apr 2026 14:49:13 +0200 Subject: [PATCH 4/5] Trim trailing blank cells when rendering rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildRowContent' now walks the row cells and tracks the position right after the last non-blank cell, then truncates `byte_len' and `char_len' back to that position before returning. A cell is considered blank when it carries no grapheme (libghostty's unwritten-cell padding) and has default style; cells the terminal explicitly wrote — even spaces — anchor the trim point and are preserved. Cells with non-default style (colored background, underline, …) are also preserved so visible styling is not lost. This removes libghostty's full-terminal-width padding from the Emacs buffer. Short prompt rows no longer run out to column 80 with trailing spaces, and the right edge of the buffer reflects the actual last character written by the terminal. Style runs that extend past the new trim point are clipped by `insertAndStyle''s existing `content.char_len' cap — no change required there. `prompt_char_len' is capped at the new `char_len' so the leading-prompt region never points past the end of the trimmed text (fixes an out-of-range text-property call that would fire when the prompt ran to the viewport edge without further input). Three tests updated to match the new semantics and a new test added: - `ghostel-test-scrollback-in-buffer' now expects 12 lines instead of 13 (the trailing empty cursor row trims to nothing). - `ghostel-test-incremental-redraw' expects 4 lines instead of 5 for the same reason. - `ghostel-test-wide-char-no-overflow' asserts the visual width is 2 (the emoji) instead of 40 (the full terminal width). - `ghostel-test-resize-width-change-full-repaint' asserts each row is no longer than the terminal width, instead of exactly equal. - New `ghostel-test-render-trims-trailing-whitespace' covers both sides of the rule: unwritten padding is stripped, shell-written trailing spaces like `$ ' are preserved. --- src/render.zig | 35 +++++++++++++++++++++++ test/ghostel-test.el | 67 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/render.zig b/src/render.zig index 08b82b4..64c88ac 100644 --- a/src/render.zig +++ b/src/render.zig @@ -459,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, @@ -467,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; @@ -531,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; } @@ -549,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.*] = .{ diff --git a/test/ghostel-test.el b/test/ghostel-test.el index 1329499..198b384 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -196,9 +196,42 @@ This is the vterm-style growing-buffer model that lets `isearch' and (should (string-match-p "row-05" content)) ;; The most recent row is on the active screen. (should (string-match-p "row-11" content))) - ;; Buffer line count = scrollback rows + viewport rows. - ;; 12 written lines + 1 cursor row = 13 rows total - (should (= 13 (count-lines (point-min) (point-max)))))) + ;; 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 () @@ -458,7 +491,9 @@ scrolling libghostty's viewport." (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 @@ -468,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)))) ;; ----------------------------------------------------------------------- @@ -1009,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)))) ;; ----------------------------------------------------------------------- @@ -1414,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. @@ -1437,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)))) From 1a31d37197e8bbff14345b4b6fe1e16be3617e11 Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Sat, 11 Apr 2026 16:01:51 +0200 Subject: [PATCH 5/5] Update benchmark numbers after trailing-whitespace trim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-ran `bench/run-bench.sh` at the new 5 MB default size (up from 1 MB — the larger run amortizes measurement overhead and gives more stable numbers) on Apple M4 Max, Emacs 31.0.50, post-trim. Plain-ASCII PTY throughput ticked up slightly for ghostel (64 → 65 MB/s) on top of the wider engine improvements from scrollback-in-buffer; the standout jump is URL-heavy input, which doubled from 22 to 42 MB/s since the previous README snapshot thanks to the scrollback promotion path preserving URL text properties instead of re-detecting on every scroll-off. With link detection disabled ghostel holds 65 MB/s regardless of the input mix. vterm / eat / term numbers are essentially unchanged from the previous run, re-measured for consistency. --- README.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0b0f371..3c34c08 100644 --- a/README.md +++ b/README.md @@ -488,18 +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, +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 | 64 MB/s | 22 MB/s | -| ghostel (no detect) | 65 MB/s | 61 MB/s | -| vterm | 28 MB/s | 23 MB/s | -| eat | 4.0 MB/s | 3.1 MB/s | -| term | 5.0 MB/s | 4.2 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 @@ -564,7 +564,7 @@ powering Neovim's built-in terminal. | Drag-and-drop | Yes | No | | Auto module download | Yes | No | | Scrollback default | ~5,000 | 1,000 | -| PTY throughput (plain ASCII) | 64 MB/s | 28 MB/s | +| PTY throughput (plain ASCII) | 65 MB/s | 29 MB/s | | Default redraw rate | ~30 fps | ~10 fps | ### Key differences @@ -591,13 +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`, -both backends configured with ~1,000 lines of scrollback), ghostel is roughly -2x faster than vterm on plain ASCII data (64 vs 28 MB/s). On URL-heavy -output the gap narrows as ghostel's link detection adds overhead, but with -detection disabled ghostel reaches 65 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