diff --git a/src/emacs.zig b/src/emacs.zig index 76fa01f..15c0b00 100644 --- a/src/emacs.zig +++ b/src/emacs.zig @@ -228,6 +228,18 @@ pub const Env = struct { return self.call0(sym.@"point-max"); } + pub fn markMarker(self: Env) Value { + return self.call0(sym.@"mark-marker"); + } + + pub fn markerPosition(self: Env, marker: Value) Value { + return self.call1(sym.@"marker-position", marker); + } + + pub fn setMarker(self: Env, marker: Value, pos: Value) Value { + return self.call2(sym.@"set-marker", marker, pos); + } + pub fn deleteRegion(self: Env, start: Value, end: Value) void { _ = self.call2(sym.@"delete-region", start, end); } @@ -291,6 +303,9 @@ pub const Sym = struct { @"point-max": Value, @"delete-region": Value, @"char-before": Value, + @"mark-marker": Value, + @"marker-position": Value, + @"set-marker": Value, // Text property names face: Value, diff --git a/src/render.zig b/src/render.zig index 556437e..b04f951 100644 --- a/src/render.zig +++ b/src/render.zig @@ -899,6 +899,27 @@ fn positionCursorByCell(env: emacs.Env, term: *Terminal, cx: u16, cy: u16) bool /// When `force_full` is true, the viewport region is fully re-rendered /// instead of using the incremental dirty-row path. pub fn redraw(env: emacs.Env, term: *Terminal, force_full_arg: bool) void { + // Snapshot the buffer's mark across the destructive ops below. Both + // paths — full (eraseBuffer / deleteRegion over the viewport) and + // partial (per-row deleteRegion + insert) — move every marker in the + // buffer by standard Emacs marker rules. Point is owned by the + // renderer and is placed at the TUI cursor on exit, but mark is user + // state (C-SPC, region commands) and must survive the redraw. Other + // markers (e.g. evil's visual-beginning/end) remain the caller's + // responsibility to preserve in elisp. + const saved_mark: ?i64 = blk: { + const pos = env.markerPosition(env.markMarker()); + if (!env.isNotNil(pos)) break :blk null; + break :blk env.extractInteger(pos); + }; + defer { + if (saved_mark) |pos| { + const pmax = env.extractInteger(env.pointMax()); + const clamped: i64 = if (pos > pmax) pmax else pos; + _ = env.setMarker(env.markMarker(), env.makeInteger(clamped)); + } + } + var force_full = force_full_arg; // Lock the libghostty viewport to the bottom. Users navigate history diff --git a/test/ghostel-test.el b/test/ghostel-test.el index 45ee7f4..2075cfa 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -161,6 +161,28 @@ succeeds." ;; Test: erase sequences ;; ----------------------------------------------------------------------- +(ert-deftest ghostel-test-redraw-preserves-mark () + "`ghostel--redraw' must keep `mark' stable across the destructive ops. +Full redraws call `eraseBuffer' and partial redraws `deleteRegion', +either of which would snap every marker in the buffer to `point-min'." + (let ((buf (generate-new-buffer " *ghostel-test-mark*"))) + (unwind-protect + (with-current-buffer buf + (let* ((term (ghostel--new 5 40 1000)) + (inhibit-read-only t)) + (ghostel--write-input term "line one\r\nline two\r\nline three") + (ghostel--redraw term t) + ;; Anchor mark to "two" so its position sits well past point-min. + (goto-char (point-min)) + (search-forward "two") + (let ((target (point))) + (set-marker (mark-marker) target) + ;; Trigger a full redraw (erase-buffer path). + (ghostel--write-input term " more") + (ghostel--redraw term t) + (should (= target (marker-position (mark-marker))))))) + (kill-buffer buf)))) + (ert-deftest ghostel-test-erase () "Test CSI erase sequences." (let ((term (ghostel--new 25 80 1000)))