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>
4e714e5 parent: db7ce9f modified
crates/jolt-tui/src/paint.rs +17 -3 | @@ -12,7 +12,7 @@ | ||
| 12 | 12 | use crate::entry::View; |
| 13 | 13 | use crate::graphics; |
| 14 | 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 | 16 | use crate::tree::{Props, Tag, Tree}; |
| 17 | 17 | |
| 18 | 18 | const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; |
| @@ -373,7 +373,12 @@ impl Painter<'_> { | ||
| 373 | 373 | style = style.with(attr::DIM); |
| 374 | 374 | } |
| 375 | 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 | 383 | // The field is its whole rect, not just the text in it: a reader needs |
| 379 | 384 | // to see where it can type before it has typed anything. |
| @@ -419,7 +424,16 @@ impl Painter<'_> { | ||
| 419 | 424 | let row = row |
| 420 | 425 | .saturating_sub(view.top) |
| 421 | 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 | |
| @@ -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 needs | 383 | // 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 = row | 424 | 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() { | ||
| 983 | 983 | ui.key("ctrl+u"); |
| 984 | 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 | +} | |
| @@ -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 | +} | ||