Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions evil-ghostel.el
Original file line number Diff line number Diff line change
Expand Up @@ -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)))

;; ---------------------------------------------------------------------------
Expand Down
15 changes: 15 additions & 0 deletions src/emacs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/render.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 87 additions & 14 deletions test/evil-ghostel-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -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
;; -----------------------------------------------------------------------
Expand Down Expand Up @@ -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
;; -----------------------------------------------------------------------
Expand Down
22 changes: 22 additions & 0 deletions test/ghostel-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
Loading