diff --git a/app/src/main.ts b/app/src/main.ts index 4a3cfaf..9782c17 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -89,16 +89,22 @@ function renderSnapshot(snap: Snapshot) { // Insert mode: draw a thin caret between characters. Prefer // decorating the character *at* the cursor with a left border so // the caret is pixel-accurate against the monospace grid. When - // the cursor sits past the last character, fall back to an empty - // caret span. + // the cursor sits past the last character *or* on a newline, + // decorate a non-breaking space stand-in — a `\n` inside a + // border-decorated span has no visible glyph, so the caret + // wouldn't show. el.append(document.createTextNode(text.slice(0, curStart))); - if (curStart < len) { - el.append(span("caret-on", text.slice(curStart, curStart + 1))); + const nextChar = curStart < len ? text[curStart] : null; + if (nextChar !== null && nextChar !== "\n" && nextChar !== "\r") { + el.append(span("caret-on", nextChar)); el.append(document.createTextNode(text.slice(curStart + 1))); } else { - // Caret at EOF: draw a zero-width marker so the visual position - // is still visible after the last character. + // Caret at EOF or on a line-terminator: render a zero-width + // border-only span, then whatever came at the cursor position + // (the newline itself, if any) as a plain text node so the + // line still breaks correctly. el.append(span("caret-eof", "")); + el.append(document.createTextNode(text.slice(curStart))); } } else { // Normal/select mode: block cursor + optional selection body @@ -111,11 +117,23 @@ function renderSnapshot(snap: Snapshot) { if (bodyStart < curStart) { el.append(span("selection", text.slice(bodyStart, curStart))); } - // Block cursor. Fall back to a non-breaking space when the range - // lands past the last character (i.e. append-at-EOF); otherwise - // slicing gives us the exact grapheme. - const cursorText = text.slice(curStart, curEnd) || "\u00a0"; - el.append(span("cursor", cursorText)); + // Block cursor. The cursor grapheme is a `\n` when the cursor is + // on a line-terminator; highlighting a newline inside `
`
+ // produces a zero-width invisible span, so substitute a visible
+ // stand-in (nbsp) and emit the actual newline separately so the
+ // line still breaks. Same substitute for the end-of-buffer case
+ // where the slice is empty.
+ const cursorSlice = text.slice(curStart, curEnd);
+ if (cursorSlice === "\n" || cursorSlice === "\r\n" || cursorSlice === "") {
+ el.append(span("cursor", "\u00a0"));
+ // Emit the trailing newline (if any) as plain text so the line
+ // break still happens.
+ if (cursorSlice.length > 0) {
+ el.append(document.createTextNode(cursorSlice));
+ }
+ } else {
+ el.append(span("cursor", cursorSlice));
+ }
if (curEnd < bodyEnd) {
el.append(span("selection", text.slice(curEnd, bodyEnd)));
}
diff --git a/crates/fresnel-core/src/actor.rs b/crates/fresnel-core/src/actor.rs
index a85ed17..7aa20fb 100644
--- a/crates/fresnel-core/src/actor.rs
+++ b/crates/fresnel-core/src/actor.rs
@@ -381,6 +381,29 @@ mod tests {
);
}
+ #[test]
+ fn cursor_can_sit_on_line_terminator_after_moving_past_last_char() {
+ // Moving `l` off the last visible character of a line lands the
+ // cursor on the newline itself. Verify: after 5 `l`s on
+ // "hello\n" (starting at 0-1 by default), the cursor grapheme
+ // is the '\n' at index 5..6. This is the case the frontend
+ // needs to render specially (a `\n`
+ // is invisible in ``); the wire contract must still
+ // faithfully report where the cursor is.
+ let handle = spawn("hello\n".into());
+ for _ in 0..5 {
+ handle.handle_key("l").expect("valid key");
+ }
+ let snap = handle.snapshot();
+ assert_eq!(snap.mode, "normal");
+ // Cursor is on the newline character.
+ assert_eq!(snap.cursor_start, 5);
+ assert_eq!(snap.cursor_end, 6);
+ // Sanity: the character at that position really is a line
+ // terminator.
+ assert_eq!(&snap.text[snap.cursor_start..snap.cursor_end], "\n");
+ }
+
#[test]
fn insert_mode_reports_zero_width_cursor_at_head() {
// Helix's `insert_mode` command doesn't collapse the underlying