nandi/jolt-nativepublic Fork 0
4e714e5
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Make the entry's caret a cell rather than the whole field

A focused entry was painted in reverse video across its whole rect, which on a
field of one row is a lit box and on a field of three is a lit paragraph. It
reads as a selection over every character in it — and the one cell that is
actually the caret is reversed along with the rest, so there is nothing to see
where the caret is. Clicking into the middle of a draft looked like it had
selected the draft.

Bold is which field has the keyboard, and the caret is one reversed cell at
the character it is on. The terminal is still told to put its own cursor
there; painting it as well is what survives a terminal that hides the cursor,
a --headless screenshot, and a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-10T11:47:30-07:00 Browse files
4e714e5 parent: db7ce9f
modified crates/jolt-tui/src/paint.rs +17 -3
@@ -12,7 +12,7 @@
1212 use crate::entry::View;
1313 use crate::graphics;
1414 use crate::layout::{self, wrap, Align};
15-use crate::screen::{attr, Color, Rect, Screen, Style};
15+use crate::screen::{attr, Cell, Color, Rect, Screen, Style};
1616 use crate::tree::{Props, Tag, Tree};
1717
1818 const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
@@ -373,7 +373,12 @@ impl Painter<'_> {
373373 style = style.with(attr::DIM);
374374 }
375375 if focused {
376- style = style.with(attr::REVERSE);
376+ // Bold, not reverse. Reversing the whole rect painted the field as
377+ // one lit block — which reads as a selection over every character
378+ // in it, and hid the one cell that is actually the caret. The
379+ // caret is a cell of its own below; this is only which field has
380+ // the keyboard.
381+ style = style.with(attr::BOLD);
377382 }
378383 // The field is its whole rect, not just the text in it: a reader needs
379384 // to see where it can type before it has typed anything.
@@ -419,7 +424,16 @@ impl Painter<'_> {
419424 let row = row
420425 .saturating_sub(view.top)
421426 .min(area.h.saturating_sub(1) as usize);
422- self.out.cursor = Some((area.x.saturating_add(col), area.y + row as u16));
427+ let (cx, cy) = (area.x.saturating_add(col), area.y + row as u16);
428+ // The caret as a cell, as well as where the terminal is told to
429+ // put its own cursor. The two are the same place; this is the one
430+ // that survives a terminal that hides the cursor, a screenshot,
431+ // and a test.
432+ if let Some(under) = self.screen.cell(cx, cy).cloned() {
433+ let style = under.style.with(attr::REVERSE);
434+ self.screen.put(cx, cy, Cell { style, ..under });
435+ }
436+ self.out.cursor = Some((cx, cy));
423437 }
424438 }
425439
@@ -12,7 +12,7 @@
12 use crate::entry::View;12 use crate::entry::View;
13 use crate::graphics;13 use crate::graphics;
14 use crate::layout::{self, wrap, Align};14 use crate::layout::{self, wrap, Align};
15-use crate::screen::{attr, Color, Rect, Screen, Style};15+use crate::screen::{attr, Cell, Color, Rect, Screen, Style};
16 use crate::tree::{Props, Tag, Tree};16 use crate::tree::{Props, Tag, Tree};
17 17
18 const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];18 const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
@@ -373,7 +373,12 @@ impl Painter<'_> {
373 style = style.with(attr::DIM);373 style = style.with(attr::DIM);
374 }374 }
375 if focused {375 if focused {
376- style = style.with(attr::REVERSE);376+ // Bold, not reverse. Reversing the whole rect painted the field as
377+ // one lit block — which reads as a selection over every character
378+ // in it, and hid the one cell that is actually the caret. The
379+ // caret is a cell of its own below; this is only which field has
380+ // the keyboard.
381+ style = style.with(attr::BOLD);
377 }382 }
378 // The field is its whole rect, not just the text in it: a reader needs383 // The field is its whole rect, not just the text in it: a reader needs
379 // to see where it can type before it has typed anything.384 // to see where it can type before it has typed anything.
@@ -419,7 +424,16 @@ impl Painter<'_> {
419 let row = row424 let row = row
420 .saturating_sub(view.top)425 .saturating_sub(view.top)
421 .min(area.h.saturating_sub(1) as usize);426 .min(area.h.saturating_sub(1) as usize);
422- self.out.cursor = Some((area.x.saturating_add(col), area.y + row as u16));427+ let (cx, cy) = (area.x.saturating_add(col), area.y + row as u16);
428+ // The caret as a cell, as well as where the terminal is told to
429+ // put its own cursor. The two are the same place; this is the one
430+ // that survives a terminal that hides the cursor, a screenshot,
431+ // and a test.
432+ if let Some(under) = self.screen.cell(cx, cy).cloned() {
433+ let style = under.style.with(attr::REVERSE);
434+ self.screen.put(cx, cy, Cell { style, ..under });
435+ }
436+ self.out.cursor = Some((cx, cy));
423 }437 }
424 }438 }
425 439
modified crates/jolt-tui/src/tests.rs +23 -0
@@ -983,3 +983,26 @@ fn home_and_end_are_about_the_row_the_caret_is_on() {
983983 ui.key("ctrl+u");
984984 assert_eq!(ui.tree.props(entry).str("text"), "one\n");
985985 }
986+
987+#[test]
988+fn a_focused_field_marks_the_caret_cell_and_not_the_whole_field() {
989+ let mut ui = ui();
990+ let entry = compose(&mut ui, "one\ntwo");
991+ // The caret is at the end of the text: the cell past "two" on the second
992+ // row, and nothing else in the field.
993+ let reversed: Vec<(u16, u16)> = (0..3)
994+ .flat_map(|y| (0..24).map(move |x| (x, y)))
995+ .filter(|(x, y)| {
996+ ui.screen
997+ .cell(*x, *y)
998+ .is_some_and(|cell| cell.style.has(attr::REVERSE))
999+ })
1000+ .collect();
1001+ assert_eq!(reversed, vec![(3, 1)]);
1002+ assert_eq!(ui.cursor(), Some((3, 1)));
1003+ // And it follows a click rather than staying where the paint ended.
1004+ ui.click(1, 0);
1005+ ui.frame();
1006+ assert_eq!(ui.cursor(), Some((1, 0)));
1007+ let _ = entry;
1008+}
@@ -983,3 +983,26 @@ fn home_and_end_are_about_the_row_the_caret_is_on() {
983 ui.key("ctrl+u");983 ui.key("ctrl+u");
984 assert_eq!(ui.tree.props(entry).str("text"), "one\n");984 assert_eq!(ui.tree.props(entry).str("text"), "one\n");
985 }985 }
986+
987+#[test]
988+fn a_focused_field_marks_the_caret_cell_and_not_the_whole_field() {
989+ let mut ui = ui();
990+ let entry = compose(&mut ui, "one\ntwo");
991+ // The caret is at the end of the text: the cell past "two" on the second
992+ // row, and nothing else in the field.
993+ let reversed: Vec<(u16, u16)> = (0..3)
994+ .flat_map(|y| (0..24).map(move |x| (x, y)))
995+ .filter(|(x, y)| {
996+ ui.screen
997+ .cell(*x, *y)
998+ .is_some_and(|cell| cell.style.has(attr::REVERSE))
999+ })
1000+ .collect();
1001+ assert_eq!(reversed, vec![(3, 1)]);
1002+ assert_eq!(ui.cursor(), Some((3, 1)));
1003+ // And it follows a click rather than staying where the paint ended.
1004+ ui.click(1, 0);
1005+ ui.frame();
1006+ assert_eq!(ui.cursor(), Some((1, 0)));
1007+ let _ = entry;
1008+}