diff --git a/evil-ghostel.el b/evil-ghostel.el index 169af52..e70d938 100644 --- a/evil-ghostel.el +++ b/evil-ghostel.el @@ -102,24 +102,42 @@ placement math the native module performs in `src/render.zig'." ((< dx 0) (dotimes (_ (abs dx)) (ghostel--send-encoded "left" ""))))))) ;; --------------------------------------------------------------------------- -;; Redraw: preserve point in normal state +;; Redraw: preserve point and evil visual markers across the native call ;; --------------------------------------------------------------------------- (defun evil-ghostel--around-redraw (orig-fn term &optional full) - "Preserve Emacs point during redraws in evil normal state. + "Preserve point and evil visual markers across the native redraw call. +Native `ghostel--redraw' in `src/render.zig' rewrites the viewport +region, moving every marker in the buffer. `point' (non-terminal +states) and the evil-specific visual range markers are restored here; +`mark' is preserved by the native module itself and needs no handling +at this layer. + + - `point' in non-terminal states. In `insert' and `emacs' point + intentionally follows the TUI cursor. + - `evil-visual-beginning' and `evil-visual-end' in `visual' state. + ORIG-FN is the advised `ghostel--redraw' called with TERM and FULL. -Without this, the ~30fps redraw timer would snap point back to -the terminal cursor, undoing any evil `normal-mode' navigation. -`emacs-state' is evil's vanilla-Emacs escape hatch; point should -follow the terminal cursor there just like it does in insert-state, -otherwise the cursor gets stuck wherever it was on state entry while -the TUI keeps redrawing elsewhere." +Skipped when the terminal is in alt-screen mode (1049); apps there +own the screen and drive their own redraw cycle." (if (and evil-ghostel-mode - (not (memq evil-state '(insert emacs))) (not (ghostel--mode-enabled term 1049))) - (let ((saved-point (point))) + (let* ((preserve-point (not (memq evil-state '(insert emacs)))) + (visual-p (eq evil-state 'visual)) + (saved-point (and preserve-point (point))) + (saved-vb (and visual-p (bound-and-true-p evil-visual-beginning) + (marker-position evil-visual-beginning))) + (saved-ve (and visual-p (bound-and-true-p evil-visual-end) + (marker-position evil-visual-end)))) (funcall orig-fn term full) - (goto-char (min saved-point (point-max)))) + (when preserve-point + (goto-char (min saved-point (point-max)))) + (when visual-p + (let ((pmax (point-max))) + (when saved-vb + (set-marker evil-visual-beginning (min saved-vb pmax))) + (when saved-ve + (set-marker evil-visual-end (min saved-ve pmax)))))) (funcall orig-fn term full))) ;; --------------------------------------------------------------------------- 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/evil-ghostel-test.el b/test/evil-ghostel-test.el index 80842ab..fa08dbb 100644 --- a/test/evil-ghostel-test.el +++ b/test/evil-ghostel-test.el @@ -127,6 +127,93 @@ Regression guard: the minor-mode body used to call (evil-ghostel-test--with-evil-buffer (should-not evil-move-cursor-back))) +;; ----------------------------------------------------------------------- +;; Test: around-redraw preserves point / mark / visual markers +;; ----------------------------------------------------------------------- + +(defmacro evil-ghostel-test--simulating-redraw (&rest body) + "Run BODY with `ghostel--redraw' replaced by a buffer-rewriter. +The mock erases the buffer and reinserts the same text, which is what +the native full-redraw path does at the Emacs level — every marker in +the buffer snaps to `point-min' across the call." + `(cl-letf (((symbol-function 'ghostel--redraw) + (lambda (_term &optional _full) + (let ((text (buffer-string))) + (erase-buffer) + (insert text)))) + ((symbol-function 'ghostel--mode-enabled) + (lambda (_term _mode) nil))) + ,@body)) + +(ert-deftest evil-ghostel-test-around-redraw-preserves-point-in-normal () + "Point is restored in non-terminal states after the native redraw call." + (evil-ghostel-test--with-evil-buffer + (insert "one\ntwo\nthree\nfour\nfive\n") + (evil-normal-state) + (goto-char (point-min)) + (search-forward "three") + (let ((target (point))) + (evil-ghostel-test--simulating-redraw + (evil-ghostel--around-redraw (symbol-function 'ghostel--redraw) nil)) + (should (= target (point)))))) + +(ert-deftest evil-ghostel-test-around-redraw-lets-point-follow-in-emacs () + "Point is NOT preserved in `emacs'/`insert' — it follows the TUI cursor." + (evil-ghostel-test--with-evil-buffer + (insert "one\ntwo\nthree\nfour\nfive\n") + (evil-emacs-state) + (goto-char (point-min)) + (search-forward "three") + (evil-ghostel-test--simulating-redraw + ;; Mock redraw places point at point-min (like eraseBuffer does). + (evil-ghostel--around-redraw + (lambda (_term &optional _full) + (let ((text (buffer-string))) + (erase-buffer) + (insert text) + (goto-char (point-min)))) + nil)) + (should (= (point-min) (point))))) + +(ert-deftest evil-ghostel-test-around-redraw-preserves-visual-markers () + "`evil-visual-beginning'/`evil-visual-end' are restored in visual state." + (evil-ghostel-test--with-evil-buffer + (insert "one\ntwo\nthree\nfour\nfive\n") + (goto-char (point-min)) + (search-forward "two") + (let ((vb-target (point))) + (search-forward "four") + (let ((ve-target (point))) + (setq-local evil-visual-beginning (copy-marker vb-target)) + (setq-local evil-visual-end (copy-marker ve-target t)) + (let ((evil-state 'visual)) + (evil-ghostel-test--simulating-redraw + (evil-ghostel--around-redraw + (symbol-function 'ghostel--redraw) nil))) + (should (= vb-target (marker-position evil-visual-beginning))) + (should (= ve-target (marker-position evil-visual-end))))))) + +(ert-deftest evil-ghostel-test-around-redraw-bypassed-in-alt-screen () + "Advice is a passthrough when the terminal is in alt-screen mode (1049). +Fullscreen TUIs own the screen and drive their own redraw cycle; the +advice must not restore point or visual markers there." + (evil-ghostel-test--with-evil-buffer + (insert "one\ntwo\nthree\nfour\nfive\n") + (evil-normal-state) + (goto-char (point-min)) + (search-forward "three") + (cl-letf (((symbol-function 'ghostel--redraw) + (lambda (_term &optional _full) + (let ((text (buffer-string))) + (erase-buffer) + (insert text) + (goto-char (point-min))))) + ((symbol-function 'ghostel--mode-enabled) + (lambda (_term mode) (= mode 1049)))) + (evil-ghostel--around-redraw (symbol-function 'ghostel--redraw) nil)) + ;; Advice bypassed → the mock's point placement (point-min) wins. + (should (= (point-min) (point))))) + ;; ----------------------------------------------------------------------- ;; Test: reset-cursor-point ;; ----------------------------------------------------------------------- @@ -382,20 +469,6 @@ redrawing elsewhere." (ghostel--set-cursor-style 0 t) (should evil-called))))) -;; ----------------------------------------------------------------------- -;; Test: normal-state-entry hook -;; ----------------------------------------------------------------------- - -(ert-deftest evil-ghostel-test-normal-entry-snaps-point () - "Test that entering normal state snaps point to terminal cursor." - (evil-ghostel-test--with-buffer 5 40 "hello world" - (evil-insert-state) - ;; Move point away - (goto-char (point-min)) - ;; Enter normal state — should snap to terminal cursor - (evil-normal-state) - (should (= 11 (current-column))))) - ;; ----------------------------------------------------------------------- ;; Test: delete-region primitive ;; ----------------------------------------------------------------------- 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)))