Skip to content

Commit d4fdc8e

Browse files
committed
Broaden anchor-window clamp to cover CUP-park on empty trailing row
ad8536e narrowed the `ghostel--anchor-window' clamp to fire only when libghostty reports pending-wrap, which fixed the #146 regression but reopened a variant of #138 for TUIs that move their cursor to the bottom of the screen via absolute positioning (CUP) rather than via writing-then-wrapping. In that case `pt' equals `point-max' and the cursor sits on the last viewport row, but pending-wrap is nil — so the clamp doesn't fire and Emacs shifts `window-start' by one row to make `pt' "visible," fighting the viewport pin. Expose a second terminal-side predicate `ghostel--cursor-on-empty-row-p' backed by a new `render.isRowEmptyAt' helper, and widen the clamp guard to `(or pending-wrap cursor-on-empty-row)'. The predicate returns t iff the row containing the cursor has no written cells and no cells with non-default styling — exactly the condition under which `buildRowContent' produces `byte_len == 0'. Wide-spacer-tail cells are skipped to mirror `buildRowContent' (defensive — in practice spacer tails always follow a wide grapheme). Source `cy' from `RS_DATA_CURSOR_VIEWPORT_Y' gated by `...HAS_VALUE' so the coordinate space matches the viewport row iterator that `isRowEmptyAt' walks, and the predicate returns nil when the cursor isn't visible in the current viewport. Caller owns the `ghostty_render_state_update' refresh so it only happens once per call. An earlier draft (#158) tried `pos-visible-in-window-p' but it reflects the previous redisplay rather than the just-pinned `window-start', and breaks in batch. The terminal-side predicate answers the real question — is there anything on this row to anchor `pt' to? — without consulting Emacs redisplay state. Bump module version to 0.16.3 (new exported function). Test changes: - `ghostel-test-cursor-on-empty-row-p' covers the predicate across fresh-terminal / post-write / post-CRLF / post-CUP cursor positions. - `ghostel-test-anchor-window-clamps-on-empty-row' is the #157 regression test: feeds "foo\r\nbar\r\n" to park the cursor on an empty last row, asserts PT lands at `point-max', pending-wrap is nil, empty-row is t, and the clamp fires. - `ghostel-test-anchor-window-no-clamp-on-populated-last-row' complements #146: cursor at `point-max' on a last row that does have content (e.g. a shell prompt) must NOT be clamped regardless of which predicate is consulted. Closes #157.
1 parent 44aaf67 commit d4fdc8e

6 files changed

Lines changed: 174 additions & 13 deletions

File tree

build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .ghostel,
3-
.version = "0.16.2",
3+
.version = "0.16.3",
44
.paths = .{""},
55
.fingerprint = 0x5a44bdd1198a0f4b,
66
.dependencies = .{

evil-ghostel.el

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
;; Author: Daniel Kraus <daniel@kraus.my>
66
;; URL: https://github.com/dakra/ghostel
7-
;; Version: 0.16.2
7+
;; Version: 0.16.3
88
;; Package-Requires: ((emacs "28.1") (evil "1.0") (ghostel "0.8.0"))
99
;; SPDX-License-Identifier: GPL-3.0-or-later
1010

ghostel.el

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
;; Author: Daniel Kraus <daniel@kraus.my>
66
;; URL: https://github.com/dakra/ghostel
7-
;; Version: 0.16.2
7+
;; Version: 0.16.3
88
;; Keywords: terminals
99
;; Package-Requires: ((emacs "28.1"))
1010
;; SPDX-License-Identifier: GPL-3.0-or-later
@@ -524,7 +524,7 @@ before sending the input."
524524
Customize this when downloading pre-built modules from a fork or mirror."
525525
:type 'string)
526526

527-
(defconst ghostel--minimum-module-version "0.16.2"
527+
(defconst ghostel--minimum-module-version "0.16.3"
528528
"Minimum native module version required by this Elisp version.
529529
Bump this only when the Elisp code requires a newer native module
530530
\(e.g. new Zig-exported function or changed calling convention).")
@@ -534,6 +534,7 @@ Bump this only when the Elisp code requires a newer native module
534534

535535
(declare-function ghostel--cursor-position "ghostel-module")
536536
(declare-function ghostel--cursor-pending-wrap-p "ghostel-module")
537+
(declare-function ghostel--cursor-on-empty-row-p "ghostel-module")
537538
(declare-function ghostel--encode-key "ghostel-module")
538539
(declare-function ghostel--focus-event "ghostel-module")
539540
(declare-function ghostel--mode-enabled "ghostel-module")
@@ -3052,12 +3053,20 @@ No-op when `ghostel--snap-requested' (user input overrides)."
30523053
Also resets pixel vscroll (pixel-scroll-precision-mode may leave a
30533054
partial offset that would clip the top line after a redraw).
30543055
3055-
When the TUI cursor is in pending-wrap state on the last visible row,
3056-
PT equals `point-max' (one past the last character). Emacs redisplay
3057-
then classifies it as off-screen, and `scroll-conservatively' shifts
3058-
`window-start' up by one row to make it visible — which fights the
3059-
viewport pin and makes the block cursor disappear. Clamp
3060-
`window-point' back by one only in that case so it sits inside the
3056+
Two terminal-side configurations land PT at `point-max' on the last
3057+
visible row in a position Emacs redisplay treats as off-screen — which
3058+
makes `scroll-conservatively' shift `window-start' up by one row,
3059+
fighting the viewport pin and hiding the block cursor:
3060+
3061+
1. Pending-wrap: the last printed character filled the rightmost
3062+
column and the next print will soft-wrap (issue #138).
3063+
3064+
2. CUP park onto an empty trailing row, no pending-wrap: the TUI
3065+
moved the cursor via absolute positioning to a row that has no
3066+
written cells, so the row renders to an empty buffer line and PT
3067+
lands at `point-max' (issue #157).
3068+
3069+
Clamp `window-point' back by one in either case so it sits inside the
30613070
viewport; buffer-point is unaffected and subsequent redraws recapture
30623071
the real cursor. We must NOT clamp for a plain shell prompt where the
30633072
cursor is legitimately at `point-max' after typing — doing so would
@@ -3068,8 +3077,10 @@ draw the block cursor on the last character instead of after it
30683077
(set-window-point win (if (and (= pt (point-max))
30693078
(> pt (point-min))
30703079
ghostel--term
3071-
(ghostel--cursor-pending-wrap-p
3072-
ghostel--term))
3080+
(or (ghostel--cursor-pending-wrap-p
3081+
ghostel--term)
3082+
(ghostel--cursor-on-empty-row-p
3083+
ghostel--term)))
30733084
(1- pt)
30743085
pt)))
30753086

src/module.zig

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const input = @import("input.zig");
1313
const c = emacs.c;
1414

1515
/// Module version — keep in sync with ghostel.el and build.zig.zon.
16-
const version = "0.16.2";
16+
const version = "0.16.3";
1717

1818
// ---------------------------------------------------------------------------
1919
// Module entry point
@@ -45,6 +45,7 @@ export fn emacs_module_init(runtime: *c.struct_emacs_runtime) callconv(.c) c_int
4545
env.bindFunction("ghostel--alt-screen-p", 1, 1, &fnAltScreen, "Return t if terminal is on the alternate screen buffer.\n\n(ghostel--alt-screen-p TERM)");
4646
env.bindFunction("ghostel--cursor-position", 1, 1, &fnCursorPosition, "Return terminal cursor position as (COL . ROW), 0-indexed.\n\n(ghostel--cursor-position TERM)");
4747
env.bindFunction("ghostel--cursor-pending-wrap-p", 1, 1, &fnCursorPendingWrap, "Return t if the cursor is in pending-wrap state.\n\n(ghostel--cursor-pending-wrap-p TERM)");
48+
env.bindFunction("ghostel--cursor-on-empty-row-p", 1, 1, &fnCursorOnEmptyRow, "Return t if the cursor row has no written cells or styled cells.\n\n(ghostel--cursor-on-empty-row-p TERM)");
4849
env.bindFunction("ghostel--debug-state", 1, 1, &fnDebugState, "Return debug info about terminal/render state.\n\n(ghostel--debug-state TERM)");
4950
env.bindFunction("ghostel--debug-feed", 2, 2, &fnDebugFeed, "Feed STR to terminal and return first row + cursor.\n\n(ghostel--debug-feed TERM STR)");
5051
env.bindFunction("ghostel--copy-all-text", 1, 1, &fnCopyAllText, "Return entire scrollback as plain text string.\n\n(ghostel--copy-all-text TERM)");
@@ -1046,6 +1047,34 @@ fn fnCursorPendingWrap(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value
10461047
return if (pending) env.t() else env.nil();
10471048
}
10481049

1050+
/// (ghostel--cursor-on-empty-row-p TERM)
1051+
/// Return t if the row containing the active cursor has no written
1052+
/// cells and no cells with non-default style — i.e. the row renders to
1053+
/// an empty Emacs buffer line. Used alongside pending-wrap by
1054+
/// `ghostel--anchor-window' to detect TUI CUP parks onto a trailing
1055+
/// empty row, which also land `point' at `point-max' on the last
1056+
/// visible row and would otherwise provoke a redisplay-induced scroll.
1057+
fn fnCursorOnEmptyRow(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*anyopaque) callconv(.c) c.emacs_value {
1058+
const env = emacs.Env.init(raw_env.?);
1059+
const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil();
1060+
1061+
if (gt.c.ghostty_render_state_update(term.render_state, term.terminal) != gt.SUCCESS) return env.nil();
1062+
1063+
// Use viewport-relative cursor Y so the row index matches the
1064+
// viewport iterator `isRowEmptyAt' walks. Falls back to nil when
1065+
// the cursor isn't visible in the current viewport (e.g. scrolled
1066+
// into scrollback), which is also the safe answer for the clamp.
1067+
var has_value: bool = false;
1068+
_ = gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_CURSOR_VIEWPORT_HAS_VALUE, @ptrCast(&has_value));
1069+
if (!has_value) return env.nil();
1070+
1071+
var cy: u16 = 0;
1072+
if (gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_CURSOR_VIEWPORT_Y, @ptrCast(&cy)) != gt.SUCCESS) {
1073+
return env.nil();
1074+
}
1075+
return if (render.isRowEmptyAt(term, cy)) env.t() else env.nil();
1076+
}
1077+
10491078
/// (ghostel--copy-all-text TERM)
10501079
/// Return the entire scrollback as a plain text string using the formatter API.
10511080
fn fnCopyAllText(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*anyopaque) callconv(.c) c.emacs_value {

src/render.zig

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,45 @@ fn isRowPrompt(term: *Terminal) bool {
425425
return semantic != 0;
426426
}
427427

428+
/// Return true if row `cy` (0-indexed, viewport-relative) renders to an
429+
/// empty Emacs buffer line — no cell has a grapheme and no cell has
430+
/// non-default styling. Matches `buildRowContent`'s trim rules: a row
431+
/// for which this returns true produces `byte_len == 0`.
432+
///
433+
/// Assumes the caller has refreshed the render state (via
434+
/// `ghostty_render_state_update`). Drives the row iterator, so callers
435+
/// must not rely on iterator position after this call.
436+
pub fn isRowEmptyAt(term: *Terminal, cy: u16) bool {
437+
if (gt.c.ghostty_render_state_get(term.render_state, gt.RS_DATA_ROW_ITERATOR, @ptrCast(&term.row_iterator)) != gt.SUCCESS) return false;
438+
439+
var ri: u16 = 0;
440+
while (ri <= cy) : (ri += 1) {
441+
if (!gt.c.ghostty_render_state_row_iterator_next(term.row_iterator)) return false;
442+
}
443+
444+
if (gt.c.ghostty_render_state_row_get(term.row_iterator, gt.RS_ROW_DATA_CELLS, @ptrCast(&term.row_cells)) != gt.SUCCESS) {
445+
return false;
446+
}
447+
448+
while (gt.c.ghostty_render_state_row_cells_next(term.row_cells)) {
449+
var graphemes_len: u32 = 0;
450+
if (gt.c.ghostty_render_state_row_cells_get(term.row_cells, gt.RS_CELLS_DATA_GRAPHEMES_LEN, @ptrCast(&graphemes_len)) == gt.SUCCESS and graphemes_len > 0) {
451+
return false;
452+
}
453+
// Mirror `buildRowContent`: wide-spacer-tail cells are skipped
454+
// outright and never contribute to buffer content, even when
455+
// they carry non-default styling.
456+
var raw_cell: gt.c.GhosttyCell = undefined;
457+
if (gt.c.ghostty_render_state_row_cells_get(term.row_cells, gt.c.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, @ptrCast(&raw_cell)) == gt.SUCCESS) {
458+
var wide: c_int = gt.c.GHOSTTY_CELL_WIDE_NARROW;
459+
_ = gt.c.ghostty_cell_get(raw_cell, gt.c.GHOSTTY_CELL_DATA_WIDE, @ptrCast(&wide));
460+
if (wide == gt.c.GHOSTTY_CELL_WIDE_SPACER_TAIL) continue;
461+
}
462+
if (!readCellStyle(term.row_cells).isDefault()) return false;
463+
}
464+
return true;
465+
}
466+
428467
/// Hash the first ~16 cells of libghostty's first scrollback row using
429468
/// FNV-1a. Returns 0 if there is no scrollback or if anything fails.
430469
///

test/ghostel-test.el

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4022,6 +4022,88 @@ skip the clamp entirely regardless of where PT sits."
40224022
(ghostel--write-input term "XYZXY")
40234023
(should (ghostel--cursor-pending-wrap-p term))))
40244024

4025+
(ert-deftest ghostel-test-cursor-on-empty-row-p ()
4026+
"`ghostel--cursor-on-empty-row-p' tracks whether the cursor row is blank."
4027+
(let ((term (ghostel--new 3 10 100)))
4028+
;; Fresh terminal: all rows empty, cursor at (0,0).
4029+
(should (ghostel--cursor-on-empty-row-p term))
4030+
;; Write a character: cursor row 0 now has a grapheme.
4031+
(ghostel--write-input term "x")
4032+
(should-not (ghostel--cursor-on-empty-row-p term))
4033+
;; CRLF twice: cursor now at (0,2) on an empty row.
4034+
(ghostel--write-input term "\r\n\r\n")
4035+
(should (ghostel--cursor-on-empty-row-p term))
4036+
;; CUP back to row 0 (1-indexed: row 1).
4037+
(ghostel--write-input term "\e[1;1H")
4038+
(should-not (ghostel--cursor-on-empty-row-p term))))
4039+
4040+
(ert-deftest ghostel-test-anchor-window-clamps-on-empty-row ()
4041+
"`ghostel--anchor-window' clamps when cursor is CUP-parked on an empty row.
4042+
Regression test for #157: after `ad8536e' narrowed the clamp to
4043+
pending-wrap only, TUIs that move the cursor via CUP to a trailing
4044+
empty row (e.g. Claude Code on focus loss) again produced the #138
4045+
symptom — PT at `point-max', pending-wrap nil, Emacs redisplay shifts
4046+
`window-start' by one row. The clamp must also fire on an empty
4047+
cursor row, not just on pending-wrap."
4048+
(let ((buf (generate-new-buffer " *ghostel-test-anchor-empty-row*"))
4049+
(orig-buf (window-buffer (selected-window))))
4050+
(unwind-protect
4051+
(with-current-buffer buf
4052+
(ghostel-mode)
4053+
(let* ((term (ghostel--new 3 10 100))
4054+
(ghostel--term term)
4055+
(ghostel--term-rows 3)
4056+
(inhibit-read-only t))
4057+
(set-window-buffer (selected-window) buf)
4058+
;; Fill rows 0 and 1, then CR/LF to land on empty row 2.
4059+
(ghostel--write-input term "foo\r\nbar\r\n")
4060+
(should-not (ghostel--cursor-pending-wrap-p term))
4061+
(should (ghostel--cursor-on-empty-row-p term))
4062+
(ghostel--redraw term t)
4063+
(let ((win (selected-window))
4064+
(pmax (point-max)))
4065+
;; Precondition: PT really does land at point-max on the
4066+
;; empty last row.
4067+
(should (= pmax (point)))
4068+
(ghostel--anchor-window win (point-min) pmax)
4069+
;; Clamp fires: window-point pulled back by one.
4070+
(should (= (1- pmax) (window-point win))))))
4071+
(when (buffer-live-p orig-buf)
4072+
(set-window-buffer (selected-window) orig-buf))
4073+
(kill-buffer buf))))
4074+
4075+
(ert-deftest ghostel-test-anchor-window-no-clamp-on-populated-last-row ()
4076+
"`ghostel--anchor-window' must NOT clamp when the cursor row has content.
4077+
Complements the #157 regression: when the cursor sits at `point-max' on
4078+
the last visible row but that row has a written grapheme (e.g. a shell
4079+
prompt after typing), neither predicate fires and the block cursor must
4080+
stay at PT so it renders after the last character (#146 contract)."
4081+
(let ((buf (generate-new-buffer " *ghostel-test-anchor-populated-last*"))
4082+
(orig-buf (window-buffer (selected-window))))
4083+
(unwind-protect
4084+
(with-current-buffer buf
4085+
(ghostel-mode)
4086+
(let* ((term (ghostel--new 3 10 100))
4087+
(ghostel--term term)
4088+
(ghostel--term-rows 3)
4089+
(inhibit-read-only t))
4090+
(set-window-buffer (selected-window) buf)
4091+
;; Walk to the last row and type a short prompt — cursor ends
4092+
;; mid-row on content, well short of pending-wrap.
4093+
(ghostel--write-input term "\r\n\r\n$ ls")
4094+
(should-not (ghostel--cursor-pending-wrap-p term))
4095+
(should-not (ghostel--cursor-on-empty-row-p term))
4096+
(ghostel--redraw term t)
4097+
(let ((win (selected-window))
4098+
(pmax (point-max)))
4099+
(should (= pmax (point)))
4100+
(ghostel--anchor-window win (point-min) pmax)
4101+
;; No clamp: window-point stays at PT.
4102+
(should (= pmax (window-point win))))))
4103+
(when (buffer-live-p orig-buf)
4104+
(set-window-buffer (selected-window) orig-buf))
4105+
(kill-buffer buf))))
4106+
40254107
(ert-deftest ghostel-test-anchor-window-clamps-on-pending-wrap ()
40264108
"`ghostel--anchor-window' clamps `window-point' only in pending-wrap state.
40274109
Regression test for #138 (clamp must fire) and #146 (clamp must NOT fire

0 commit comments

Comments
 (0)