Give the terminal's entry a caret that means what it says
A field of more than one row painted every row scrolled to its own end and
put the caret on the last row it drew, wherever the caret actually was. So a
draft that wrapped showed its tail three times over, typing in the middle of
it moved a cursor drawn somewhere else, and a click anywhere in the box
landed on the row the paint happened to finish on. Multi-line was a field
that had the height for it and none of the rest.
There is one layout now — crates/jolt-tui/src/entry.rs — and everything with
a question about a text field asks it: which cell each character is painted
in, which row the caret is on, which character a click landed on, and which
character is directly above the one the caret is on. They cannot disagree,
because there is only the one answer. It knows hard newlines, wrapping on
spaces and mid-word where it must, and that an emoji is two cells and one
index.
What that buys, in the field:
* a click puts the caret where it was clicked — the right row, the right
character; past the end of a row is that row's end, and below the text is
the end of the text;
* up and down step a visual row and come home to the column they left,
rather than being dropped at the end of whatever short row they crossed;
* Shift-Enter breaks the line where Enter is spoken for, with Alt-Enter and
Ctrl-J for a terminal that cannot tell the two apart — and the keyboard
protocol's disambiguate flag is pushed where it is supported, which is
what makes Shift-Enter a key of its own at all;
* Home, End, Ctrl-U and Ctrl-K are about the row the caret is on, and
Ctrl-Home and Ctrl-End reach the whole of the text;
* a field with more text than rows scrolls to keep the caret in view, and
stays where it was put while the caret is still on screen;
* up and down off the ends are not the field's keys and go to the caller,
so arrowing out of one still works.
A field is a box rather than a line when `:rows` is above one *or* its text
has a newline in it: text that arrived with a break in it is a paragraph
whatever the field was declared as, and drawing it on one line hides
everything after the first break behind a hole.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>361b4dc parent: 356d73f modified
crates/jolt-tui/README.md +10 -0 | @@ -66,9 +66,19 @@ insensitive gives it up rather than stranding the focus on nothing. | ||
| 66 | 66 | | Enter, Space | activate the focused widget | |
| 67 | 67 | | arrows, `j`/`k`, Page Up/Down, `g`/`G` | move a list's cursor | |
| 68 | 68 | | Ctrl-A/E, Ctrl-W, Ctrl-U/K, Alt-B/F, Home/End | readline editing in an entry | |
| 69 | +| up/down, Shift-Enter (Alt-Enter, Ctrl-J) | a row at a time, and a new row, in an `:entry` of more than one `:rows` | | |
| 69 | 70 | | Esc | closes the topmost `:overlay` | |
| 70 | 71 | | Ctrl-C, Ctrl-Q | quit | |
| 71 | 72 | |
| 73 | +An `:entry` with `:rows` above one — or with a newline already in its text — is | |
| 74 | +a box rather than a line: it wraps, it scrolls to keep the caret in view, Home | |
| 75 | +and End are about the row the caret is on, and a click lands on the character it | |
| 76 | +landed on rather than on the same row every time. Enter still activates, so the | |
| 77 | +compose bar of a chat client sends on Enter and breaks the line on Shift-Enter; | |
| 78 | +Alt-Enter and Ctrl-J do the same on a terminal that cannot tell Shift-Enter from | |
| 79 | +Enter. Up and down off the ends of the text are not the field's keys and go to | |
| 80 | +the caller, so arrowing out of a field still works. | |
| 81 | + | |
| 72 | 82 | Anything nothing here wanted comes out as a `key` event on the focused node, |
| 73 | 83 | named the way it is fed in — `"ctrl+u"`, `"page-down"`, `"f5"`. Bubbling it to a |
| 74 | 84 | container's `:on-key` belongs to the caller: it holds the handlers. |
| @@ -66,9 +66,19 @@ insensitive gives it up rather than stranding the focus on nothing. | |||
| 66 | | Enter, Space | activate the focused widget | | 66 | | Enter, Space | activate the focused widget | |
| 67 | | arrows, `j`/`k`, Page Up/Down, `g`/`G` | move a list's cursor | | 67 | | arrows, `j`/`k`, Page Up/Down, `g`/`G` | move a list's cursor | |
| 68 | | Ctrl-A/E, Ctrl-W, Ctrl-U/K, Alt-B/F, Home/End | readline editing in an entry | | 68 | | Ctrl-A/E, Ctrl-W, Ctrl-U/K, Alt-B/F, Home/End | readline editing in an entry | |
| 69 | +| up/down, Shift-Enter (Alt-Enter, Ctrl-J) | a row at a time, and a new row, in an `:entry` of more than one `:rows` | | ||
| 69 | | Esc | closes the topmost `:overlay` | | 70 | | Esc | closes the topmost `:overlay` | |
| 70 | | Ctrl-C, Ctrl-Q | quit | | 71 | | Ctrl-C, Ctrl-Q | quit | |
| 71 | 72 | ||
| 73 | +An `:entry` with `:rows` above one — or with a newline already in its text — is | ||
| 74 | +a box rather than a line: it wraps, it scrolls to keep the caret in view, Home | ||
| 75 | +and End are about the row the caret is on, and a click lands on the character it | ||
| 76 | +landed on rather than on the same row every time. Enter still activates, so the | ||
| 77 | +compose bar of a chat client sends on Enter and breaks the line on Shift-Enter; | ||
| 78 | +Alt-Enter and Ctrl-J do the same on a terminal that cannot tell Shift-Enter from | ||
| 79 | +Enter. Up and down off the ends of the text are not the field's keys and go to | ||
| 80 | +the caller, so arrowing out of a field still works. | ||
| 81 | + | ||
| 72 | Anything nothing here wanted comes out as a `key` event on the focused node, | 82 | Anything nothing here wanted comes out as a `key` event on the focused node, |
| 73 | named the way it is fed in — `"ctrl+u"`, `"page-down"`, `"f5"`. Bubbling it to a | 83 | named the way it is fed in — `"ctrl+u"`, `"page-down"`, `"f5"`. Bubbling it to a |
| 74 | container's `:on-key` belongs to the caller: it holds the handlers. | 84 | container's `:on-key` belongs to the caller: it holds the handlers. |
added
crates/jolt-tui/src/entry.rs +333 -0 | new file mode 100644 | ||
| @@ -0,0 +1,333 @@ | ||
| 1 | +//! Where the characters of an entry's text land on the cells of its field. | |
| 2 | +//! | |
| 3 | +//! One answer, used by everything that has a question about a text field: the | |
| 4 | +//! painter draws these lines, the caret is drawn at the cell this says it is | |
| 5 | +//! on, a click is turned back into a character index by asking which line a | |
| 6 | +//! row is, and up and down are a step through this list. They cannot disagree, | |
| 7 | +//! because there is only the one layout. | |
| 8 | +//! | |
| 9 | +//! It is written in characters rather than in glyphs on purpose: the caret is | |
| 10 | +//! an index into the text, which is what an edit is made against, and columns | |
| 11 | +//! are worked out from the characters rather than the other way round. A wide | |
| 12 | +//! glyph is two cells and one index, and both halves of that are true here. | |
| 13 | + | |
| 14 | +use crate::screen; | |
| 15 | + | |
| 16 | +/// One visual row of a field: the half-open range of characters on it. | |
| 17 | +/// | |
| 18 | +/// A row ends either at a hard newline — the newline itself is not part of the | |
| 19 | +/// range — or where the wrap fell, in which case the space that was wrapped on | |
| 20 | +/// *is* part of the range and is not drawn. That is what makes a caret just | |
| 21 | +/// past a wrapped space belong to the row below rather than hanging off the | |
| 22 | +/// end of the row above, which is the behaviour every editor has. | |
| 23 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 24 | +pub struct Line { | |
| 25 | + pub start: usize, | |
| 26 | + pub end: usize, | |
| 27 | +} | |
| 28 | + | |
| 29 | +/// How a field's text sits in the rect it was given. | |
| 30 | +#[derive(Clone, Debug)] | |
| 31 | +pub struct View { | |
| 32 | + /// The characters, so callers do not re-collect them. | |
| 33 | + pub text: Vec<char>, | |
| 34 | + pub lines: Vec<Line>, | |
| 35 | + /// The first row painted — a multi-line field taller than its rect scrolls | |
| 36 | + /// down to keep the caret on screen. | |
| 37 | + pub top: usize, | |
| 38 | + /// The first *column* painted on each row. Only a single-line field ever | |
| 39 | + /// has one: a field that wraps has nothing to scroll sideways to. | |
| 40 | + pub left: usize, | |
| 41 | + /// How many rows the rect holds. | |
| 42 | + pub rows: usize, | |
| 43 | +} | |
| 44 | + | |
| 45 | +fn char_cols(ch: char) -> u16 { | |
| 46 | + if ch == '\n' { | |
| 47 | + 0 | |
| 48 | + } else { | |
| 49 | + screen::glyph_cols(&ch.to_string()) | |
| 50 | + } | |
| 51 | +} | |
| 52 | + | |
| 53 | +/// The columns `text[from..to]` takes. | |
| 54 | +pub fn cols(text: &[char], from: usize, to: usize) -> u16 { | |
| 55 | + text[from.min(text.len())..to.min(text.len())] | |
| 56 | + .iter() | |
| 57 | + .map(|ch| char_cols(*ch)) | |
| 58 | + .sum() | |
| 59 | +} | |
| 60 | + | |
| 61 | +/// Break one paragraph — a run with no newline in it — into rows of `width` | |
| 62 | +/// columns, on spaces where it can and mid-word where it must. | |
| 63 | +fn wrap_paragraph(text: &[char], start: usize, end: usize, width: u16, out: &mut Vec<Line>) { | |
| 64 | + let width = width.max(1); | |
| 65 | + let mut at = start; | |
| 66 | + loop { | |
| 67 | + let mut used = 0u16; | |
| 68 | + let mut to = at; | |
| 69 | + // The character after the last space that fits, which is where the row | |
| 70 | + // breaks when a break on a space is possible at all. | |
| 71 | + let mut after_space = None; | |
| 72 | + while to < end { | |
| 73 | + let next = used.saturating_add(char_cols(text[to])); | |
| 74 | + if next > width { | |
| 75 | + break; | |
| 76 | + } | |
| 77 | + used = next; | |
| 78 | + to += 1; | |
| 79 | + if text[to - 1] == ' ' { | |
| 80 | + after_space = Some(to); | |
| 81 | + } | |
| 82 | + } | |
| 83 | + if to >= end { | |
| 84 | + out.push(Line { start: at, end }); | |
| 85 | + return; | |
| 86 | + } | |
| 87 | + // Something is left over, so this row has to break. On the space when | |
| 88 | + // there was one — but never on a space that is the whole row, or a | |
| 89 | + // paragraph of spaces would never advance. | |
| 90 | + let brk = match after_space { | |
| 91 | + Some(space) if space > at => space, | |
| 92 | + _ => to.max(at + 1), | |
| 93 | + }; | |
| 94 | + out.push(Line { | |
| 95 | + start: at, | |
| 96 | + end: brk, | |
| 97 | + }); | |
| 98 | + at = brk; | |
| 99 | + } | |
| 100 | +} | |
| 101 | + | |
| 102 | +/// Lay `text` out in a field `width` columns across. | |
| 103 | +/// | |
| 104 | +/// A single-line field is one row however long the text is; the sideways | |
| 105 | +/// scroll in [`View::left`] is what keeps the caret on screen there. | |
| 106 | +pub fn lines(text: &[char], width: u16, multiline: bool) -> Vec<Line> { | |
| 107 | + if !multiline { | |
| 108 | + return vec![Line { | |
| 109 | + start: 0, | |
| 110 | + end: text.len(), | |
| 111 | + }]; | |
| 112 | + } | |
| 113 | + let mut out = Vec::new(); | |
| 114 | + let mut at = 0usize; | |
| 115 | + for (i, ch) in text.iter().enumerate() { | |
| 116 | + if *ch == '\n' { | |
| 117 | + wrap_paragraph(text, at, i, width, &mut out); | |
| 118 | + at = i + 1; | |
| 119 | + } | |
| 120 | + } | |
| 121 | + wrap_paragraph(text, at, text.len(), width, &mut out); | |
| 122 | + out | |
| 123 | +} | |
| 124 | + | |
| 125 | +impl View { | |
| 126 | + /// The whole answer for one field: its rows, and how far they are scrolled | |
| 127 | + /// so that `caret` is on screen. | |
| 128 | + /// | |
| 129 | + /// `top` is where the field was scrolled to on the last frame. Keeping it | |
| 130 | + /// is what stops a field jumping about: it moves only when the caret has | |
| 131 | + /// gone off one end, and then only far enough to bring it back. | |
| 132 | + pub fn of( | |
| 133 | + text: &str, | |
| 134 | + width: u16, | |
| 135 | + height: u16, | |
| 136 | + multiline: bool, | |
| 137 | + caret: usize, | |
| 138 | + was_top: usize, | |
| 139 | + ) -> Self { | |
| 140 | + let chars: Vec<char> = text.chars().collect(); | |
| 141 | + let width = width.max(1); | |
| 142 | + let rows = height.max(1) as usize; | |
| 143 | + let lines = lines(&chars, width, multiline); | |
| 144 | + let caret = caret.min(chars.len()); | |
| 145 | + let row = row_of(&lines, caret); | |
| 146 | + let last = lines.len().saturating_sub(rows); | |
| 147 | + let mut top = was_top.min(last); | |
| 148 | + if row < top { | |
| 149 | + top = row; | |
| 150 | + } else if row >= top + rows { | |
| 151 | + top = row + 1 - rows; | |
| 152 | + } | |
| 153 | + // Sideways, for the one line a single-line field has. The window is a | |
| 154 | + // column short of the field so the caret at the end of the text has a | |
| 155 | + // cell of its own to sit in. | |
| 156 | + let window = width.saturating_sub(1).max(1); | |
| 157 | + let mut left = 0usize; | |
| 158 | + if !multiline { | |
| 159 | + while cols(&chars, left, caret) > window { | |
| 160 | + left += 1; | |
| 161 | + } | |
| 162 | + } | |
| 163 | + Self { | |
| 164 | + text: chars, | |
| 165 | + lines, | |
| 166 | + top, | |
| 167 | + left, | |
| 168 | + rows, | |
| 169 | + } | |
| 170 | + } | |
| 171 | + | |
| 172 | + /// The row `at` is on, and how many columns into it it sits. | |
| 173 | + pub fn caret_at(&self, at: usize) -> (usize, u16) { | |
| 174 | + let row = row_of(&self.lines, at.min(self.text.len())); | |
| 175 | + let from = self.skip(self.lines[row]); | |
| 176 | + (row, cols(&self.text, from, at.max(from))) | |
| 177 | + } | |
| 178 | + | |
| 179 | + /// What is drawn on `row`: its text, with the space a wrap ate left off, | |
| 180 | + /// and — on a single-line field — the columns scrolled off the left. | |
| 181 | + pub fn painted(&self, row: usize) -> String { | |
| 182 | + let Some(line) = self.lines.get(row) else { | |
| 183 | + return String::new(); | |
| 184 | + }; | |
| 185 | + let end = self.line_end(row); | |
| 186 | + let from = self.skip(*line).min(end); | |
| 187 | + self.text[from..end].iter().collect() | |
| 188 | + } | |
| 189 | + | |
| 190 | + /// The character a click on this cell of the field means. | |
| 191 | + /// | |
| 192 | + /// `x` and `y` are relative to the field's own top-left corner. A click | |
| 193 | + /// below the last row of text is the end of the text and a click past the | |
| 194 | + /// end of a row is the end of that row, which is what makes clicking into | |
| 195 | + /// the empty half of a half-full field land somewhere sensible. | |
| 196 | + pub fn hit(&self, x: u16, y: u16) -> usize { | |
| 197 | + let row = self.top + y as usize; | |
| 198 | + if row >= self.lines.len() { | |
| 199 | + return self.text.len(); | |
| 200 | + } | |
| 201 | + let line = self.lines[row]; | |
| 202 | + let mut at = self.skip(line); | |
| 203 | + let mut used = 0u16; | |
| 204 | + while at < line.end { | |
| 205 | + let w = char_cols(self.text[at]); | |
| 206 | + if used + w > x { | |
| 207 | + break; | |
| 208 | + } | |
| 209 | + used += w; | |
| 210 | + at += 1; | |
| 211 | + } | |
| 212 | + // Not onto the space a wrap ate: that cell is the end of this row, and | |
| 213 | + // the character after it starts the next one. | |
| 214 | + self.line_end(row).min(at) | |
| 215 | + } | |
| 216 | + | |
| 217 | + /// The first character drawn on a row — the sideways scroll, which only a | |
| 218 | + /// single-line field has. | |
| 219 | + fn skip(&self, line: Line) -> usize { | |
| 220 | + line.start.max(self.left.min(line.end)) | |
| 221 | + } | |
| 222 | + | |
| 223 | + /// The first and last character of the row `at` is on — what Home and End | |
| 224 | + /// mean in a box of text, and what a kill to the start or the end of the | |
| 225 | + /// line takes. | |
| 226 | + pub fn caret_row(&self, at: usize) -> (usize, usize) { | |
| 227 | + let row = row_of(&self.lines, at.min(self.text.len())); | |
| 228 | + (self.lines[row].start, self.line_end(row)) | |
| 229 | + } | |
| 230 | + | |
| 231 | + /// Where the text on `row` ends, not counting the space a wrap ate. | |
| 232 | + pub fn line_end(&self, row: usize) -> usize { | |
| 233 | + let Some(line) = self.lines.get(row) else { | |
| 234 | + return self.text.len(); | |
| 235 | + }; | |
| 236 | + let wrapped = row + 1 < self.lines.len() && self.lines[row + 1].start == line.end; | |
| 237 | + if wrapped && line.end > line.start && self.text[line.end - 1] == ' ' { | |
| 238 | + line.end - 1 | |
| 239 | + } else { | |
| 240 | + line.end | |
| 241 | + } | |
| 242 | + } | |
| 243 | + | |
| 244 | + /// The character at `col` columns into `row` — how up and down keep their | |
| 245 | + /// place across rows of different lengths. | |
| 246 | + pub fn at_col(&self, row: usize, col: u16) -> usize { | |
| 247 | + let row = row.min(self.lines.len().saturating_sub(1)); | |
| 248 | + let line = self.lines[row]; | |
| 249 | + let mut at = line.start; | |
| 250 | + let mut used = 0u16; | |
| 251 | + while at < line.end && used < col { | |
| 252 | + used += char_cols(self.text[at]); | |
| 253 | + at += 1; | |
| 254 | + } | |
| 255 | + self.line_end(row).min(at) | |
| 256 | + } | |
| 257 | +} | |
| 258 | + | |
| 259 | +/// Which row holds `at`. | |
| 260 | +/// | |
| 261 | +/// The last row that starts at or before it, so a caret exactly on a break | |
| 262 | +/// belongs to the row below — which is where the next character typed goes. | |
| 263 | +pub fn row_of(lines: &[Line], at: usize) -> usize { | |
| 264 | + lines.iter().rposition(|line| line.start <= at).unwrap_or(0) | |
| 265 | +} | |
| 266 | + | |
| 267 | +#[cfg(test)] | |
| 268 | +mod tests { | |
| 269 | + use super::*; | |
| 270 | + | |
| 271 | + fn view(text: &str, width: u16, height: u16, caret: usize) -> View { | |
| 272 | + View::of(text, width, height, true, caret, 0) | |
| 273 | + } | |
| 274 | + | |
| 275 | + #[test] | |
| 276 | + fn a_wrapped_row_ends_after_the_space_it_broke_on() { | |
| 277 | + let v = view("one two three", 8, 3, 0); | |
| 278 | + assert_eq!(v.painted(0), "one two"); | |
| 279 | + assert_eq!(v.painted(1), "three"); | |
| 280 | + // The space is on the first row's range but is not drawn, so a caret | |
| 281 | + // just past it is at the start of the second row. | |
| 282 | + assert_eq!(v.caret_at(8), (1, 0)); | |
| 283 | + } | |
| 284 | + | |
| 285 | + #[test] | |
| 286 | + fn a_newline_is_a_row_of_its_own_even_when_it_is_empty() { | |
| 287 | + let v = view("a\n\nb", 8, 4, 0); | |
| 288 | + assert_eq!(v.lines.len(), 3); | |
| 289 | + assert_eq!(v.painted(1), ""); | |
| 290 | + assert_eq!(v.caret_at(2), (1, 0)); | |
| 291 | + assert_eq!(v.caret_at(3), (2, 0)); | |
| 292 | + } | |
| 293 | + | |
| 294 | + #[test] | |
| 295 | + fn a_click_lands_on_the_character_under_it() { | |
| 296 | + let v = view("one two three", 8, 3, 0); | |
| 297 | + assert_eq!(v.hit(0, 0), 0); | |
| 298 | + assert_eq!(v.hit(4, 0), 4); | |
| 299 | + // Past the end of a row is the end of that row, not the row below. | |
| 300 | + assert_eq!(v.hit(20, 0), 7); | |
| 301 | + assert_eq!(v.hit(2, 1), 10); | |
| 302 | + // Below the text is the end of it. | |
| 303 | + assert_eq!(v.hit(0, 7), 13); | |
| 304 | + } | |
| 305 | + | |
| 306 | + #[test] | |
| 307 | + fn a_caret_off_the_bottom_scrolls_the_field_by_one_row() { | |
| 308 | + let text = "a\nb\nc\nd"; | |
| 309 | + let v = View::of(text, 8, 2, true, 6, 0); | |
| 310 | + assert_eq!(v.top, 2, "two rows of four, with the caret on the last"); | |
| 311 | + // And it stays where it was while the caret is still on screen. | |
| 312 | + let v = View::of(text, 8, 2, true, 4, 2); | |
| 313 | + assert_eq!(v.top, 2); | |
| 314 | + } | |
| 315 | + | |
| 316 | + #[test] | |
| 317 | + fn a_single_line_field_scrolls_sideways_to_the_caret() { | |
| 318 | + let v = View::of("abcdefghij", 5, 1, false, 10, 0); | |
| 319 | + assert_eq!(v.lines.len(), 1); | |
| 320 | + assert_eq!(v.painted(0), "ghij"); | |
| 321 | + assert_eq!(v.caret_at(10), (0, 4)); | |
| 322 | + assert_eq!(v.hit(0, 0), 6); | |
| 323 | + } | |
| 324 | + | |
| 325 | + #[test] | |
| 326 | + fn a_wide_glyph_is_two_cells_and_one_character() { | |
| 327 | + let v = view("a😀b", 8, 2, 0); | |
| 328 | + assert_eq!(v.caret_at(2), (0, 3)); | |
| 329 | + assert_eq!(v.hit(1, 0), 1); | |
| 330 | + assert_eq!(v.hit(2, 0), 1, "the second cell is still the emoji"); | |
| 331 | + assert_eq!(v.hit(3, 0), 2); | |
| 332 | + } | |
| 333 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,333 @@ | |||
| 1 | +//! Where the characters of an entry's text land on the cells of its field. | ||
| 2 | +//! | ||
| 3 | +//! One answer, used by everything that has a question about a text field: the | ||
| 4 | +//! painter draws these lines, the caret is drawn at the cell this says it is | ||
| 5 | +//! on, a click is turned back into a character index by asking which line a | ||
| 6 | +//! row is, and up and down are a step through this list. They cannot disagree, | ||
| 7 | +//! because there is only the one layout. | ||
| 8 | +//! | ||
| 9 | +//! It is written in characters rather than in glyphs on purpose: the caret is | ||
| 10 | +//! an index into the text, which is what an edit is made against, and columns | ||
| 11 | +//! are worked out from the characters rather than the other way round. A wide | ||
| 12 | +//! glyph is two cells and one index, and both halves of that are true here. | ||
| 13 | + | ||
| 14 | +use crate::screen; | ||
| 15 | + | ||
| 16 | +/// One visual row of a field: the half-open range of characters on it. | ||
| 17 | +/// | ||
| 18 | +/// A row ends either at a hard newline — the newline itself is not part of the | ||
| 19 | +/// range — or where the wrap fell, in which case the space that was wrapped on | ||
| 20 | +/// *is* part of the range and is not drawn. That is what makes a caret just | ||
| 21 | +/// past a wrapped space belong to the row below rather than hanging off the | ||
| 22 | +/// end of the row above, which is the behaviour every editor has. | ||
| 23 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| 24 | +pub struct Line { | ||
| 25 | + pub start: usize, | ||
| 26 | + pub end: usize, | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +/// How a field's text sits in the rect it was given. | ||
| 30 | +#[derive(Clone, Debug)] | ||
| 31 | +pub struct View { | ||
| 32 | + /// The characters, so callers do not re-collect them. | ||
| 33 | + pub text: Vec<char>, | ||
| 34 | + pub lines: Vec<Line>, | ||
| 35 | + /// The first row painted — a multi-line field taller than its rect scrolls | ||
| 36 | + /// down to keep the caret on screen. | ||
| 37 | + pub top: usize, | ||
| 38 | + /// The first *column* painted on each row. Only a single-line field ever | ||
| 39 | + /// has one: a field that wraps has nothing to scroll sideways to. | ||
| 40 | + pub left: usize, | ||
| 41 | + /// How many rows the rect holds. | ||
| 42 | + pub rows: usize, | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +fn char_cols(ch: char) -> u16 { | ||
| 46 | + if ch == '\n' { | ||
| 47 | + 0 | ||
| 48 | + } else { | ||
| 49 | + screen::glyph_cols(&ch.to_string()) | ||
| 50 | + } | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +/// The columns `text[from..to]` takes. | ||
| 54 | +pub fn cols(text: &[char], from: usize, to: usize) -> u16 { | ||
| 55 | + text[from.min(text.len())..to.min(text.len())] | ||
| 56 | + .iter() | ||
| 57 | + .map(|ch| char_cols(*ch)) | ||
| 58 | + .sum() | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +/// Break one paragraph — a run with no newline in it — into rows of `width` | ||
| 62 | +/// columns, on spaces where it can and mid-word where it must. | ||
| 63 | +fn wrap_paragraph(text: &[char], start: usize, end: usize, width: u16, out: &mut Vec<Line>) { | ||
| 64 | + let width = width.max(1); | ||
| 65 | + let mut at = start; | ||
| 66 | + loop { | ||
| 67 | + let mut used = 0u16; | ||
| 68 | + let mut to = at; | ||
| 69 | + // The character after the last space that fits, which is where the row | ||
| 70 | + // breaks when a break on a space is possible at all. | ||
| 71 | + let mut after_space = None; | ||
| 72 | + while to < end { | ||
| 73 | + let next = used.saturating_add(char_cols(text[to])); | ||
| 74 | + if next > width { | ||
| 75 | + break; | ||
| 76 | + } | ||
| 77 | + used = next; | ||
| 78 | + to += 1; | ||
| 79 | + if text[to - 1] == ' ' { | ||
| 80 | + after_space = Some(to); | ||
| 81 | + } | ||
| 82 | + } | ||
| 83 | + if to >= end { | ||
| 84 | + out.push(Line { start: at, end }); | ||
| 85 | + return; | ||
| 86 | + } | ||
| 87 | + // Something is left over, so this row has to break. On the space when | ||
| 88 | + // there was one — but never on a space that is the whole row, or a | ||
| 89 | + // paragraph of spaces would never advance. | ||
| 90 | + let brk = match after_space { | ||
| 91 | + Some(space) if space > at => space, | ||
| 92 | + _ => to.max(at + 1), | ||
| 93 | + }; | ||
| 94 | + out.push(Line { | ||
| 95 | + start: at, | ||
| 96 | + end: brk, | ||
| 97 | + }); | ||
| 98 | + at = brk; | ||
| 99 | + } | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +/// Lay `text` out in a field `width` columns across. | ||
| 103 | +/// | ||
| 104 | +/// A single-line field is one row however long the text is; the sideways | ||
| 105 | +/// scroll in [`View::left`] is what keeps the caret on screen there. | ||
| 106 | +pub fn lines(text: &[char], width: u16, multiline: bool) -> Vec<Line> { | ||
| 107 | + if !multiline { | ||
| 108 | + return vec![Line { | ||
| 109 | + start: 0, | ||
| 110 | + end: text.len(), | ||
| 111 | + }]; | ||
| 112 | + } | ||
| 113 | + let mut out = Vec::new(); | ||
| 114 | + let mut at = 0usize; | ||
| 115 | + for (i, ch) in text.iter().enumerate() { | ||
| 116 | + if *ch == '\n' { | ||
| 117 | + wrap_paragraph(text, at, i, width, &mut out); | ||
| 118 | + at = i + 1; | ||
| 119 | + } | ||
| 120 | + } | ||
| 121 | + wrap_paragraph(text, at, text.len(), width, &mut out); | ||
| 122 | + out | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +impl View { | ||
| 126 | + /// The whole answer for one field: its rows, and how far they are scrolled | ||
| 127 | + /// so that `caret` is on screen. | ||
| 128 | + /// | ||
| 129 | + /// `top` is where the field was scrolled to on the last frame. Keeping it | ||
| 130 | + /// is what stops a field jumping about: it moves only when the caret has | ||
| 131 | + /// gone off one end, and then only far enough to bring it back. | ||
| 132 | + pub fn of( | ||
| 133 | + text: &str, | ||
| 134 | + width: u16, | ||
| 135 | + height: u16, | ||
| 136 | + multiline: bool, | ||
| 137 | + caret: usize, | ||
| 138 | + was_top: usize, | ||
| 139 | + ) -> Self { | ||
| 140 | + let chars: Vec<char> = text.chars().collect(); | ||
| 141 | + let width = width.max(1); | ||
| 142 | + let rows = height.max(1) as usize; | ||
| 143 | + let lines = lines(&chars, width, multiline); | ||
| 144 | + let caret = caret.min(chars.len()); | ||
| 145 | + let row = row_of(&lines, caret); | ||
| 146 | + let last = lines.len().saturating_sub(rows); | ||
| 147 | + let mut top = was_top.min(last); | ||
| 148 | + if row < top { | ||
| 149 | + top = row; | ||
| 150 | + } else if row >= top + rows { | ||
| 151 | + top = row + 1 - rows; | ||
| 152 | + } | ||
| 153 | + // Sideways, for the one line a single-line field has. The window is a | ||
| 154 | + // column short of the field so the caret at the end of the text has a | ||
| 155 | + // cell of its own to sit in. | ||
| 156 | + let window = width.saturating_sub(1).max(1); | ||
| 157 | + let mut left = 0usize; | ||
| 158 | + if !multiline { | ||
| 159 | + while cols(&chars, left, caret) > window { | ||
| 160 | + left += 1; | ||
| 161 | + } | ||
| 162 | + } | ||
| 163 | + Self { | ||
| 164 | + text: chars, | ||
| 165 | + lines, | ||
| 166 | + top, | ||
| 167 | + left, | ||
| 168 | + rows, | ||
| 169 | + } | ||
| 170 | + } | ||
| 171 | + | ||
| 172 | + /// The row `at` is on, and how many columns into it it sits. | ||
| 173 | + pub fn caret_at(&self, at: usize) -> (usize, u16) { | ||
| 174 | + let row = row_of(&self.lines, at.min(self.text.len())); | ||
| 175 | + let from = self.skip(self.lines[row]); | ||
| 176 | + (row, cols(&self.text, from, at.max(from))) | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + /// What is drawn on `row`: its text, with the space a wrap ate left off, | ||
| 180 | + /// and — on a single-line field — the columns scrolled off the left. | ||
| 181 | + pub fn painted(&self, row: usize) -> String { | ||
| 182 | + let Some(line) = self.lines.get(row) else { | ||
| 183 | + return String::new(); | ||
| 184 | + }; | ||
| 185 | + let end = self.line_end(row); | ||
| 186 | + let from = self.skip(*line).min(end); | ||
| 187 | + self.text[from..end].iter().collect() | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + /// The character a click on this cell of the field means. | ||
| 191 | + /// | ||
| 192 | + /// `x` and `y` are relative to the field's own top-left corner. A click | ||
| 193 | + /// below the last row of text is the end of the text and a click past the | ||
| 194 | + /// end of a row is the end of that row, which is what makes clicking into | ||
| 195 | + /// the empty half of a half-full field land somewhere sensible. | ||
| 196 | + pub fn hit(&self, x: u16, y: u16) -> usize { | ||
| 197 | + let row = self.top + y as usize; | ||
| 198 | + if row >= self.lines.len() { | ||
| 199 | + return self.text.len(); | ||
| 200 | + } | ||
| 201 | + let line = self.lines[row]; | ||
| 202 | + let mut at = self.skip(line); | ||
| 203 | + let mut used = 0u16; | ||
| 204 | + while at < line.end { | ||
| 205 | + let w = char_cols(self.text[at]); | ||
| 206 | + if used + w > x { | ||
| 207 | + break; | ||
| 208 | + } | ||
| 209 | + used += w; | ||
| 210 | + at += 1; | ||
| 211 | + } | ||
| 212 | + // Not onto the space a wrap ate: that cell is the end of this row, and | ||
| 213 | + // the character after it starts the next one. | ||
| 214 | + self.line_end(row).min(at) | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + /// The first character drawn on a row — the sideways scroll, which only a | ||
| 218 | + /// single-line field has. | ||
| 219 | + fn skip(&self, line: Line) -> usize { | ||
| 220 | + line.start.max(self.left.min(line.end)) | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + /// The first and last character of the row `at` is on — what Home and End | ||
| 224 | + /// mean in a box of text, and what a kill to the start or the end of the | ||
| 225 | + /// line takes. | ||
| 226 | + pub fn caret_row(&self, at: usize) -> (usize, usize) { | ||
| 227 | + let row = row_of(&self.lines, at.min(self.text.len())); | ||
| 228 | + (self.lines[row].start, self.line_end(row)) | ||
| 229 | + } | ||
| 230 | + | ||
| 231 | + /// Where the text on `row` ends, not counting the space a wrap ate. | ||
| 232 | + pub fn line_end(&self, row: usize) -> usize { | ||
| 233 | + let Some(line) = self.lines.get(row) else { | ||
| 234 | + return self.text.len(); | ||
| 235 | + }; | ||
| 236 | + let wrapped = row + 1 < self.lines.len() && self.lines[row + 1].start == line.end; | ||
| 237 | + if wrapped && line.end > line.start && self.text[line.end - 1] == ' ' { | ||
| 238 | + line.end - 1 | ||
| 239 | + } else { | ||
| 240 | + line.end | ||
| 241 | + } | ||
| 242 | + } | ||
| 243 | + | ||
| 244 | + /// The character at `col` columns into `row` — how up and down keep their | ||
| 245 | + /// place across rows of different lengths. | ||
| 246 | + pub fn at_col(&self, row: usize, col: u16) -> usize { | ||
| 247 | + let row = row.min(self.lines.len().saturating_sub(1)); | ||
| 248 | + let line = self.lines[row]; | ||
| 249 | + let mut at = line.start; | ||
| 250 | + let mut used = 0u16; | ||
| 251 | + while at < line.end && used < col { | ||
| 252 | + used += char_cols(self.text[at]); | ||
| 253 | + at += 1; | ||
| 254 | + } | ||
| 255 | + self.line_end(row).min(at) | ||
| 256 | + } | ||
| 257 | +} | ||
| 258 | + | ||
| 259 | +/// Which row holds `at`. | ||
| 260 | +/// | ||
| 261 | +/// The last row that starts at or before it, so a caret exactly on a break | ||
| 262 | +/// belongs to the row below — which is where the next character typed goes. | ||
| 263 | +pub fn row_of(lines: &[Line], at: usize) -> usize { | ||
| 264 | + lines.iter().rposition(|line| line.start <= at).unwrap_or(0) | ||
| 265 | +} | ||
| 266 | + | ||
| 267 | +#[cfg(test)] | ||
| 268 | +mod tests { | ||
| 269 | + use super::*; | ||
| 270 | + | ||
| 271 | + fn view(text: &str, width: u16, height: u16, caret: usize) -> View { | ||
| 272 | + View::of(text, width, height, true, caret, 0) | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + #[test] | ||
| 276 | + fn a_wrapped_row_ends_after_the_space_it_broke_on() { | ||
| 277 | + let v = view("one two three", 8, 3, 0); | ||
| 278 | + assert_eq!(v.painted(0), "one two"); | ||
| 279 | + assert_eq!(v.painted(1), "three"); | ||
| 280 | + // The space is on the first row's range but is not drawn, so a caret | ||
| 281 | + // just past it is at the start of the second row. | ||
| 282 | + assert_eq!(v.caret_at(8), (1, 0)); | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + #[test] | ||
| 286 | + fn a_newline_is_a_row_of_its_own_even_when_it_is_empty() { | ||
| 287 | + let v = view("a\n\nb", 8, 4, 0); | ||
| 288 | + assert_eq!(v.lines.len(), 3); | ||
| 289 | + assert_eq!(v.painted(1), ""); | ||
| 290 | + assert_eq!(v.caret_at(2), (1, 0)); | ||
| 291 | + assert_eq!(v.caret_at(3), (2, 0)); | ||
| 292 | + } | ||
| 293 | + | ||
| 294 | + #[test] | ||
| 295 | + fn a_click_lands_on_the_character_under_it() { | ||
| 296 | + let v = view("one two three", 8, 3, 0); | ||
| 297 | + assert_eq!(v.hit(0, 0), 0); | ||
| 298 | + assert_eq!(v.hit(4, 0), 4); | ||
| 299 | + // Past the end of a row is the end of that row, not the row below. | ||
| 300 | + assert_eq!(v.hit(20, 0), 7); | ||
| 301 | + assert_eq!(v.hit(2, 1), 10); | ||
| 302 | + // Below the text is the end of it. | ||
| 303 | + assert_eq!(v.hit(0, 7), 13); | ||
| 304 | + } | ||
| 305 | + | ||
| 306 | + #[test] | ||
| 307 | + fn a_caret_off_the_bottom_scrolls_the_field_by_one_row() { | ||
| 308 | + let text = "a\nb\nc\nd"; | ||
| 309 | + let v = View::of(text, 8, 2, true, 6, 0); | ||
| 310 | + assert_eq!(v.top, 2, "two rows of four, with the caret on the last"); | ||
| 311 | + // And it stays where it was while the caret is still on screen. | ||
| 312 | + let v = View::of(text, 8, 2, true, 4, 2); | ||
| 313 | + assert_eq!(v.top, 2); | ||
| 314 | + } | ||
| 315 | + | ||
| 316 | + #[test] | ||
| 317 | + fn a_single_line_field_scrolls_sideways_to_the_caret() { | ||
| 318 | + let v = View::of("abcdefghij", 5, 1, false, 10, 0); | ||
| 319 | + assert_eq!(v.lines.len(), 1); | ||
| 320 | + assert_eq!(v.painted(0), "ghij"); | ||
| 321 | + assert_eq!(v.caret_at(10), (0, 4)); | ||
| 322 | + assert_eq!(v.hit(0, 0), 6); | ||
| 323 | + } | ||
| 324 | + | ||
| 325 | + #[test] | ||
| 326 | + fn a_wide_glyph_is_two_cells_and_one_character() { | ||
| 327 | + let v = view("a😀b", 8, 2, 0); | ||
| 328 | + assert_eq!(v.caret_at(2), (0, 3)); | ||
| 329 | + assert_eq!(v.hit(1, 0), 1); | ||
| 330 | + assert_eq!(v.hit(2, 0), 1, "the second cell is still the emoji"); | ||
| 331 | + assert_eq!(v.hit(3, 0), 2); | ||
| 332 | + } | ||
| 333 | +} | ||
modified
crates/jolt-tui/src/layout.rs +14 -1 | @@ -242,6 +242,15 @@ pub fn entry_text(props: &Props) -> String { | ||
| 242 | 242 | } |
| 243 | 243 | } |
| 244 | 244 | |
| 245 | +/// Whether an entry is a box of text rather than a line of it. | |
| 246 | +/// | |
| 247 | +/// Asked for the rows it was given, and also of the text itself: a field with | |
| 248 | +/// a newline in it is a box whatever it was declared as, and drawing that text | |
| 249 | +/// on one line would show the newline as a hole and hide everything after it. | |
| 250 | +pub fn entry_multiline(props: &Props) -> bool { | |
| 251 | + props.cells("rows", 1) > 1 || props.str("text").contains('\n') | |
| 252 | +} | |
| 253 | + | |
| 245 | 254 | /// A node's content size before its own request or inset is applied. |
| 246 | 255 | fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { |
| 247 | 256 | let tag = tree.tag_of(id); |
| @@ -251,7 +260,11 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { | ||
| 251 | 260 | Tag::Button => columns(text).saturating_add(4), |
| 252 | 261 | Tag::CheckButton => columns(text).saturating_add(4), |
| 253 | 262 | Tag::Entry => { |
| 254 | - let want = columns(&entry_text(props)).saturating_add(1).max(12); | |
| 263 | + // The longest line of it: a box of text is as wide as its widest | |
| 264 | + // row, not as wide as all its rows laid end to end. | |
| 265 | + let text = entry_text(props); | |
| 266 | + let widest = text.split('\n').map(columns).max().unwrap_or(0); | |
| 267 | + let want = widest.saturating_add(1).max(12); | |
| 255 | 268 | if minimum { |
| 256 | 269 | want.min(6) |
| 257 | 270 | } else { |
| @@ -242,6 +242,15 @@ pub fn entry_text(props: &Props) -> String { | |||
| 242 | } | 242 | } |
| 243 | } | 243 | } |
| 244 | 244 | ||
| 245 | +/// Whether an entry is a box of text rather than a line of it. | ||
| 246 | +/// | ||
| 247 | +/// Asked for the rows it was given, and also of the text itself: a field with | ||
| 248 | +/// a newline in it is a box whatever it was declared as, and drawing that text | ||
| 249 | +/// on one line would show the newline as a hole and hide everything after it. | ||
| 250 | +pub fn entry_multiline(props: &Props) -> bool { | ||
| 251 | + props.cells("rows", 1) > 1 || props.str("text").contains('\n') | ||
| 252 | +} | ||
| 253 | + | ||
| 245 | /// A node's content size before its own request or inset is applied. | 254 | /// A node's content size before its own request or inset is applied. |
| 246 | fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { | 255 | fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { |
| 247 | let tag = tree.tag_of(id); | 256 | let tag = tree.tag_of(id); |
| @@ -251,7 +260,11 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 { | |||
| 251 | Tag::Button => columns(text).saturating_add(4), | 260 | Tag::Button => columns(text).saturating_add(4), |
| 252 | Tag::CheckButton => columns(text).saturating_add(4), | 261 | Tag::CheckButton => columns(text).saturating_add(4), |
| 253 | Tag::Entry => { | 262 | Tag::Entry => { |
| 254 | - let want = columns(&entry_text(props)).saturating_add(1).max(12); | 263 | + // The longest line of it: a box of text is as wide as its widest |
| 264 | + // row, not as wide as all its rows laid end to end. | ||
| 265 | + let text = entry_text(props); | ||
| 266 | + let widest = text.split('\n').map(columns).max().unwrap_or(0); | ||
| 267 | + let want = widest.saturating_add(1).max(12); | ||
| 255 | if minimum { | 268 | if minimum { |
| 256 | want.min(6) | 269 | want.min(6) |
| 257 | } else { | 270 | } else { |
modified
crates/jolt-tui/src/lib.rs +1 -0 | @@ -31,6 +31,7 @@ | ||
| 31 | 31 | // vocabulary, not dead code, so a headless build does not warn about them. |
| 32 | 32 | #![cfg_attr(not(feature = "terminal"), allow(dead_code))] |
| 33 | 33 | |
| 34 | +mod entry; | |
| 34 | 35 | mod graphics; |
| 35 | 36 | mod keys; |
| 36 | 37 | mod layout; |
| @@ -31,6 +31,7 @@ | |||
| 31 | // vocabulary, not dead code, so a headless build does not warn about them. | 31 | // vocabulary, not dead code, so a headless build does not warn about them. |
| 32 | #![cfg_attr(not(feature = "terminal"), allow(dead_code))] | 32 | #![cfg_attr(not(feature = "terminal"), allow(dead_code))] |
| 33 | 33 | ||
| 34 | +mod entry; | ||
| 34 | mod graphics; | 35 | mod graphics; |
| 35 | mod keys; | 36 | mod keys; |
| 36 | mod layout; | 37 | mod layout; |
modified
crates/jolt-tui/src/paint.rs +69 -34 | @@ -9,9 +9,10 @@ | ||
| 9 | 9 | //! over the whole screen, so it is painted after everything else at the size it |
| 10 | 10 | //! asked for, in the middle. |
| 11 | 11 | |
| 12 | +use crate::entry::View; | |
| 12 | 13 | use crate::graphics; |
| 13 | 14 | use crate::layout::{self, wrap, Align}; |
| 14 | -use crate::screen::{self, attr, Color, Rect, Screen, Style}; | |
| 15 | +use crate::screen::{attr, Color, Rect, Screen, Style}; | |
| 15 | 16 | use crate::tree::{Props, Tag, Tree}; |
| 16 | 17 | |
| 17 | 18 | const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; |
| @@ -33,6 +34,10 @@ pub struct Painted { | ||
| 33 | 34 | pub scrolled: Vec<(u32, u16, u16, Rect)>, |
| 34 | 35 | /// Where the cursor should sit — the focused entry's caret, if any. |
| 35 | 36 | pub cursor: Option<(u16, u16)>, |
| 37 | + /// How far down the focused entry was scrolled, in rows of its own text. | |
| 38 | + /// A tall draft in a short field keeps its place between frames, and the | |
| 39 | + /// input half needs the same number to turn a click into a character. | |
| 40 | + pub entry_top: usize, | |
| 36 | 41 | /// The pictures this frame wants on screen, in the cells they were given. |
| 37 | 42 | /// Nothing was painted for them: the grid has no pixels, and the terminal |
| 38 | 43 | /// is what draws one — see [`crate::graphics`]. |
| @@ -71,6 +76,8 @@ struct Painter<'a> { | ||
| 71 | 76 | focus: u32, |
| 72 | 77 | /// Where the caret sits in the focused entry's text, in characters. |
| 73 | 78 | caret: usize, |
| 79 | + /// The row the focused entry was scrolled to on the last frame. | |
| 80 | + entry_top: usize, | |
| 74 | 81 | tick: u64, |
| 75 | 82 | out: Painted, |
| 76 | 83 | overlays: Vec<u32>, |
| @@ -78,15 +85,26 @@ struct Painter<'a> { | ||
| 78 | 85 | |
| 79 | 86 | /// Paint the whole tree. `focus` is the node the ring is currently on and |
| 80 | 87 | /// `tick` advances the spinners. |
| 81 | -pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted { | |
| 88 | +pub fn frame( | |
| 89 | + tree: &Tree, | |
| 90 | + screen: &mut Screen, | |
| 91 | + focus: u32, | |
| 92 | + caret: usize, | |
| 93 | + entry_top: usize, | |
| 94 | + tick: u64, | |
| 95 | +) -> Painted { | |
| 82 | 96 | screen.clear(); |
| 83 | 97 | let mut painter = Painter { |
| 84 | 98 | tree, |
| 85 | 99 | screen, |
| 86 | 100 | focus, |
| 87 | 101 | caret, |
| 102 | + entry_top, | |
| 88 | 103 | tick, |
| 89 | - out: Painted::default(), | |
| 104 | + out: Painted { | |
| 105 | + entry_top, | |
| 106 | + ..Painted::default() | |
| 107 | + }, | |
| 90 | 108 | overlays: Vec::new(), |
| 91 | 109 | }; |
| 92 | 110 | let area = painter.screen.rect(); |
| @@ -360,40 +378,48 @@ impl Painter<'_> { | ||
| 360 | 378 | // The field is its whole rect, not just the text in it: a reader needs |
| 361 | 379 | // to see where it can type before it has typed anything. |
| 362 | 380 | self.screen.fill(area, style); |
| 363 | - let rows = area.h.max(1); | |
| 364 | - let lines = if props.cells("rows", 1) > 1 { | |
| 365 | - wrap(&shown, area.w) | |
| 381 | + // Where the field is scrolled to, and where the caret sits in it, are | |
| 382 | + // one question with one answer — see [`crate::entry`]. The caret only | |
| 383 | + // belongs to the focused field; an unfocused one shows the end of what | |
| 384 | + // is in it, which is what was last typed there. | |
| 385 | + let caret = if focused { | |
| 386 | + self.caret.min(shown.chars().count()) | |
| 366 | 387 | } else { |
| 367 | - vec![shown.chars().collect::<String>()] | |
| 388 | + shown.chars().count() | |
| 368 | 389 | }; |
| 369 | - let caret = self.caret.min(text.chars().count()); | |
| 370 | - // A line longer than the field scrolls sideways to keep the caret in | |
| 371 | - // view — the end of it is where someone is usually typing, but not | |
| 372 | - // always, so it follows the caret rather than the end. | |
| 373 | - for (i, line) in lines.iter().take(rows as usize).enumerate() { | |
| 374 | - let len = line.chars().count(); | |
| 375 | - let last = i + 1 == lines.len().min(rows as usize); | |
| 376 | - let window = area.w.saturating_sub(1).max(1) as usize; | |
| 377 | - let from = if last && !showing_placeholder { | |
| 378 | - caret.saturating_sub(window) | |
| 390 | + let was_top = if focused { self.entry_top } else { usize::MAX }; | |
| 391 | + let view = View::of( | |
| 392 | + &shown, | |
| 393 | + area.w, | |
| 394 | + area.h, | |
| 395 | + layout::entry_multiline(props), | |
| 396 | + caret, | |
| 397 | + was_top, | |
| 398 | + ); | |
| 399 | + for row in 0..view.rows.min(view.lines.len().saturating_sub(view.top)) { | |
| 400 | + self.screen.text( | |
| 401 | + area.x, | |
| 402 | + area.y + row as u16, | |
| 403 | + area.w, | |
| 404 | + &view.painted(view.top + row), | |
| 405 | + style, | |
| 406 | + ); | |
| 407 | + } | |
| 408 | + if focused { | |
| 409 | + self.out.entry_top = view.top; | |
| 410 | + // In columns rather than characters: an emoji typed into the line | |
| 411 | + // is two cells wide, and a caret counted in characters sits a | |
| 412 | + // column left of the text for each one. | |
| 413 | + let (row, col) = if showing_placeholder { | |
| 414 | + (view.top, 0) | |
| 379 | 415 | } else { |
| 380 | - len.saturating_sub(window) | |
| 416 | + view.caret_at(caret) | |
| 381 | 417 | }; |
| 382 | - let visible: String = line.chars().skip(from).collect(); | |
| 383 | - self.screen | |
| 384 | - .text(area.x, area.y + i as u16, area.w, &visible, style); | |
| 385 | - if focused && last { | |
| 386 | - // In columns rather than characters: an emoji typed into the | |
| 387 | - // line is two cells wide, and a caret counted in characters | |
| 388 | - // sits a column left of the text for each one. | |
| 389 | - let col = if showing_placeholder { | |
| 390 | - 0 | |
| 391 | - } else { | |
| 392 | - let typed: String = visible.chars().take(caret.saturating_sub(from)).collect(); | |
| 393 | - (screen::text_cols(&typed)).min(area.w.saturating_sub(1)) | |
| 394 | - }; | |
| 395 | - self.out.cursor = Some((area.x.saturating_add(col), area.y + i as u16)); | |
| 396 | - } | |
| 418 | + let col = col.min(area.w.saturating_sub(1)); | |
| 419 | + let row = row | |
| 420 | + .saturating_sub(view.top) | |
| 421 | + .min(area.h.saturating_sub(1) as usize); | |
| 422 | + self.out.cursor = Some((area.x.saturating_add(col), area.y + row as u16)); | |
| 397 | 423 | } |
| 398 | 424 | } |
| 399 | 425 | |
| @@ -509,8 +535,12 @@ impl Painter<'_> { | ||
| 509 | 535 | screen: &mut buffer, |
| 510 | 536 | focus: self.focus, |
| 511 | 537 | caret: self.caret, |
| 538 | + entry_top: self.entry_top, | |
| 512 | 539 | tick: self.tick, |
| 513 | - out: Painted::default(), | |
| 540 | + out: Painted { | |
| 541 | + entry_top: self.entry_top, | |
| 542 | + ..Painted::default() | |
| 543 | + }, | |
| 514 | 544 | overlays: Vec::new(), |
| 515 | 545 | }; |
| 516 | 546 | for (child, rect, shown) in plan { |
| @@ -532,6 +562,11 @@ impl Painter<'_> { | ||
| 532 | 562 | } |
| 533 | 563 | let mut learned = inner.out; |
| 534 | 564 | learned.shift_down(base); |
| 565 | + // A field inside a scroll is still the focused field, and what it | |
| 566 | + // learned about its own scroll has to come back out with it. | |
| 567 | + if learned.entry_top != self.entry_top { | |
| 568 | + self.out.entry_top = learned.entry_top; | |
| 569 | + } | |
| 535 | 570 | |
| 536 | 571 | for y in 0..area.h { |
| 537 | 572 | for x in 0..area.w { |
| @@ -9,9 +9,10 @@ | |||
| 9 | //! over the whole screen, so it is painted after everything else at the size it | 9 | //! over the whole screen, so it is painted after everything else at the size it |
| 10 | //! asked for, in the middle. | 10 | //! asked for, in the middle. |
| 11 | 11 | ||
| 12 | +use crate::entry::View; | ||
| 12 | use crate::graphics; | 13 | use crate::graphics; |
| 13 | use crate::layout::{self, wrap, Align}; | 14 | use crate::layout::{self, wrap, Align}; |
| 14 | -use crate::screen::{self, attr, Color, Rect, Screen, Style}; | 15 | +use crate::screen::{attr, Color, Rect, Screen, Style}; |
| 15 | use crate::tree::{Props, Tag, Tree}; | 16 | use crate::tree::{Props, Tag, Tree}; |
| 16 | 17 | ||
| 17 | const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; | 18 | const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; |
| @@ -33,6 +34,10 @@ pub struct Painted { | |||
| 33 | pub scrolled: Vec<(u32, u16, u16, Rect)>, | 34 | pub scrolled: Vec<(u32, u16, u16, Rect)>, |
| 34 | /// Where the cursor should sit — the focused entry's caret, if any. | 35 | /// Where the cursor should sit — the focused entry's caret, if any. |
| 35 | pub cursor: Option<(u16, u16)>, | 36 | pub cursor: Option<(u16, u16)>, |
| 37 | + /// How far down the focused entry was scrolled, in rows of its own text. | ||
| 38 | + /// A tall draft in a short field keeps its place between frames, and the | ||
| 39 | + /// input half needs the same number to turn a click into a character. | ||
| 40 | + pub entry_top: usize, | ||
| 36 | /// The pictures this frame wants on screen, in the cells they were given. | 41 | /// The pictures this frame wants on screen, in the cells they were given. |
| 37 | /// Nothing was painted for them: the grid has no pixels, and the terminal | 42 | /// Nothing was painted for them: the grid has no pixels, and the terminal |
| 38 | /// is what draws one — see [`crate::graphics`]. | 43 | /// is what draws one — see [`crate::graphics`]. |
| @@ -71,6 +76,8 @@ struct Painter<'a> { | |||
| 71 | focus: u32, | 76 | focus: u32, |
| 72 | /// Where the caret sits in the focused entry's text, in characters. | 77 | /// Where the caret sits in the focused entry's text, in characters. |
| 73 | caret: usize, | 78 | caret: usize, |
| 79 | + /// The row the focused entry was scrolled to on the last frame. | ||
| 80 | + entry_top: usize, | ||
| 74 | tick: u64, | 81 | tick: u64, |
| 75 | out: Painted, | 82 | out: Painted, |
| 76 | overlays: Vec<u32>, | 83 | overlays: Vec<u32>, |
| @@ -78,15 +85,26 @@ struct Painter<'a> { | |||
| 78 | 85 | ||
| 79 | /// Paint the whole tree. `focus` is the node the ring is currently on and | 86 | /// Paint the whole tree. `focus` is the node the ring is currently on and |
| 80 | /// `tick` advances the spinners. | 87 | /// `tick` advances the spinners. |
| 81 | -pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted { | 88 | +pub fn frame( |
| 89 | + tree: &Tree, | ||
| 90 | + screen: &mut Screen, | ||
| 91 | + focus: u32, | ||
| 92 | + caret: usize, | ||
| 93 | + entry_top: usize, | ||
| 94 | + tick: u64, | ||
| 95 | +) -> Painted { | ||
| 82 | screen.clear(); | 96 | screen.clear(); |
| 83 | let mut painter = Painter { | 97 | let mut painter = Painter { |
| 84 | tree, | 98 | tree, |
| 85 | screen, | 99 | screen, |
| 86 | focus, | 100 | focus, |
| 87 | caret, | 101 | caret, |
| 102 | + entry_top, | ||
| 88 | tick, | 103 | tick, |
| 89 | - out: Painted::default(), | 104 | + out: Painted { |
| 105 | + entry_top, | ||
| 106 | + ..Painted::default() | ||
| 107 | + }, | ||
| 90 | overlays: Vec::new(), | 108 | overlays: Vec::new(), |
| 91 | }; | 109 | }; |
| 92 | let area = painter.screen.rect(); | 110 | let area = painter.screen.rect(); |
| @@ -360,40 +378,48 @@ impl Painter<'_> { | |||
| 360 | // The field is its whole rect, not just the text in it: a reader needs | 378 | // The field is its whole rect, not just the text in it: a reader needs |
| 361 | // to see where it can type before it has typed anything. | 379 | // to see where it can type before it has typed anything. |
| 362 | self.screen.fill(area, style); | 380 | self.screen.fill(area, style); |
| 363 | - let rows = area.h.max(1); | 381 | + // Where the field is scrolled to, and where the caret sits in it, are |
| 364 | - let lines = if props.cells("rows", 1) > 1 { | 382 | + // one question with one answer — see [`crate::entry`]. The caret only |
| 365 | - wrap(&shown, area.w) | 383 | + // belongs to the focused field; an unfocused one shows the end of what |
| 384 | + // is in it, which is what was last typed there. | ||
| 385 | + let caret = if focused { | ||
| 386 | + self.caret.min(shown.chars().count()) | ||
| 366 | } else { | 387 | } else { |
| 367 | - vec![shown.chars().collect::<String>()] | 388 | + shown.chars().count() |
| 368 | }; | 389 | }; |
| 369 | - let caret = self.caret.min(text.chars().count()); | 390 | + let was_top = if focused { self.entry_top } else { usize::MAX }; |
| 370 | - // A line longer than the field scrolls sideways to keep the caret in | 391 | + let view = View::of( |
| 371 | - // view — the end of it is where someone is usually typing, but not | 392 | + &shown, |
| 372 | - // always, so it follows the caret rather than the end. | 393 | + area.w, |
| 373 | - for (i, line) in lines.iter().take(rows as usize).enumerate() { | 394 | + area.h, |
| 374 | - let len = line.chars().count(); | 395 | + layout::entry_multiline(props), |
| 375 | - let last = i + 1 == lines.len().min(rows as usize); | 396 | + caret, |
| 376 | - let window = area.w.saturating_sub(1).max(1) as usize; | 397 | + was_top, |
| 377 | - let from = if last && !showing_placeholder { | 398 | + ); |
| 378 | - caret.saturating_sub(window) | 399 | + for row in 0..view.rows.min(view.lines.len().saturating_sub(view.top)) { |
| 400 | + self.screen.text( | ||
| 401 | + area.x, | ||
| 402 | + area.y + row as u16, | ||
| 403 | + area.w, | ||
| 404 | + &view.painted(view.top + row), | ||
| 405 | + style, | ||
| 406 | + ); | ||
| 407 | + } | ||
| 408 | + if focused { | ||
| 409 | + self.out.entry_top = view.top; | ||
| 410 | + // In columns rather than characters: an emoji typed into the line | ||
| 411 | + // is two cells wide, and a caret counted in characters sits a | ||
| 412 | + // column left of the text for each one. | ||
| 413 | + let (row, col) = if showing_placeholder { | ||
| 414 | + (view.top, 0) | ||
| 379 | } else { | 415 | } else { |
| 380 | - len.saturating_sub(window) | 416 | + view.caret_at(caret) |
| 381 | }; | 417 | }; |
| 382 | - let visible: String = line.chars().skip(from).collect(); | 418 | + let col = col.min(area.w.saturating_sub(1)); |
| 383 | - self.screen | 419 | + let row = row |
| 384 | - .text(area.x, area.y + i as u16, area.w, &visible, style); | 420 | + .saturating_sub(view.top) |
| 385 | - if focused && last { | 421 | + .min(area.h.saturating_sub(1) as usize); |
| 386 | - // In columns rather than characters: an emoji typed into the | 422 | + self.out.cursor = Some((area.x.saturating_add(col), area.y + row as u16)); |
| 387 | - // line is two cells wide, and a caret counted in characters | ||
| 388 | - // sits a column left of the text for each one. | ||
| 389 | - let col = if showing_placeholder { | ||
| 390 | - 0 | ||
| 391 | - } else { | ||
| 392 | - let typed: String = visible.chars().take(caret.saturating_sub(from)).collect(); | ||
| 393 | - (screen::text_cols(&typed)).min(area.w.saturating_sub(1)) | ||
| 394 | - }; | ||
| 395 | - self.out.cursor = Some((area.x.saturating_add(col), area.y + i as u16)); | ||
| 396 | - } | ||
| 397 | } | 423 | } |
| 398 | } | 424 | } |
| 399 | 425 | ||
| @@ -509,8 +535,12 @@ impl Painter<'_> { | |||
| 509 | screen: &mut buffer, | 535 | screen: &mut buffer, |
| 510 | focus: self.focus, | 536 | focus: self.focus, |
| 511 | caret: self.caret, | 537 | caret: self.caret, |
| 538 | + entry_top: self.entry_top, | ||
| 512 | tick: self.tick, | 539 | tick: self.tick, |
| 513 | - out: Painted::default(), | 540 | + out: Painted { |
| 541 | + entry_top: self.entry_top, | ||
| 542 | + ..Painted::default() | ||
| 543 | + }, | ||
| 514 | overlays: Vec::new(), | 544 | overlays: Vec::new(), |
| 515 | }; | 545 | }; |
| 516 | for (child, rect, shown) in plan { | 546 | for (child, rect, shown) in plan { |
| @@ -532,6 +562,11 @@ impl Painter<'_> { | |||
| 532 | } | 562 | } |
| 533 | let mut learned = inner.out; | 563 | let mut learned = inner.out; |
| 534 | learned.shift_down(base); | 564 | learned.shift_down(base); |
| 565 | + // A field inside a scroll is still the focused field, and what it | ||
| 566 | + // learned about its own scroll has to come back out with it. | ||
| 567 | + if learned.entry_top != self.entry_top { | ||
| 568 | + self.out.entry_top = learned.entry_top; | ||
| 569 | + } | ||
| 535 | 570 | ||
| 536 | for y in 0..area.h { | 571 | for y in 0..area.h { |
| 537 | for x in 0..area.w { | 572 | for x in 0..area.w { |
modified
crates/jolt-tui/src/term.rs +23 -0 | @@ -11,6 +11,10 @@ use std::io::{self, Stdout, Write}; | ||
| 11 | 11 | use crossterm::event::{ |
| 12 | 12 | DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind, |
| 13 | 13 | }; |
| 14 | +use crossterm::event::{ | |
| 15 | + KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, | |
| 16 | +}; | |
| 17 | +use crossterm::terminal::supports_keyboard_enhancement; | |
| 14 | 18 | use crossterm::terminal::{ |
| 15 | 19 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, |
| 16 | 20 | }; |
| @@ -39,6 +43,8 @@ pub struct Term { | ||
| 39 | 43 | mouse: bool, |
| 40 | 44 | /// The pictures the terminal has been given, and where they are. |
| 41 | 45 | graphics: Graphics, |
| 46 | + /// Whether the keyboard protocol was pushed, and so has to be popped. | |
| 47 | + enhanced: bool, | |
| 42 | 48 | } |
| 43 | 49 | |
| 44 | 50 | impl Term { |
| @@ -52,11 +58,25 @@ impl Term { | ||
| 52 | 58 | if mouse { |
| 53 | 59 | execute!(out, EnableMouseCapture)?; |
| 54 | 60 | } |
| 61 | + // Shift+Enter is a newline in the compose bar and Enter sends the | |
| 62 | + // line, which a terminal can only tell apart when it is asked to: the | |
| 63 | + // legacy encoding gives both of them the same byte. This is the kitty | |
| 64 | + // keyboard protocol's first flag and nothing more — the terminals that | |
| 65 | + // have it answer the query, and the ones that do not are left as they | |
| 66 | + // were, with Alt+Enter and Ctrl+J as the way to break a line there. | |
| 67 | + let enhanced = matches!(supports_keyboard_enhancement(), Ok(true)); | |
| 68 | + if enhanced { | |
| 69 | + let _ = execute!( | |
| 70 | + out, | |
| 71 | + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) | |
| 72 | + ); | |
| 73 | + } | |
| 55 | 74 | graphics::set_cell(measure_cell()); |
| 56 | 75 | Ok(Self { |
| 57 | 76 | out, |
| 58 | 77 | last: Screen::new(w, h), |
| 59 | 78 | mouse, |
| 79 | + enhanced, | |
| 60 | 80 | graphics: Graphics::default(), |
| 61 | 81 | }) |
| 62 | 82 | } |
| @@ -76,6 +96,9 @@ impl Term { | ||
| 76 | 96 | if self.mouse { |
| 77 | 97 | let _ = execute!(self.out, DisableMouseCapture); |
| 78 | 98 | } |
| 99 | + if self.enhanced { | |
| 100 | + let _ = execute!(self.out, PopKeyboardEnhancementFlags); | |
| 101 | + } | |
| 79 | 102 | let _ = execute!(self.out, cursor::Show, LeaveAlternateScreen); |
| 80 | 103 | let _ = disable_raw_mode(); |
| 81 | 104 | } |
| @@ -11,6 +11,10 @@ use std::io::{self, Stdout, Write}; | |||
| 11 | use crossterm::event::{ | 11 | use crossterm::event::{ |
| 12 | DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind, | 12 | DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind, |
| 13 | }; | 13 | }; |
| 14 | +use crossterm::event::{ | ||
| 15 | + KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, | ||
| 16 | +}; | ||
| 17 | +use crossterm::terminal::supports_keyboard_enhancement; | ||
| 14 | use crossterm::terminal::{ | 18 | use crossterm::terminal::{ |
| 15 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, | 19 | disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, |
| 16 | }; | 20 | }; |
| @@ -39,6 +43,8 @@ pub struct Term { | |||
| 39 | mouse: bool, | 43 | mouse: bool, |
| 40 | /// The pictures the terminal has been given, and where they are. | 44 | /// The pictures the terminal has been given, and where they are. |
| 41 | graphics: Graphics, | 45 | graphics: Graphics, |
| 46 | + /// Whether the keyboard protocol was pushed, and so has to be popped. | ||
| 47 | + enhanced: bool, | ||
| 42 | } | 48 | } |
| 43 | 49 | ||
| 44 | impl Term { | 50 | impl Term { |
| @@ -52,11 +58,25 @@ impl Term { | |||
| 52 | if mouse { | 58 | if mouse { |
| 53 | execute!(out, EnableMouseCapture)?; | 59 | execute!(out, EnableMouseCapture)?; |
| 54 | } | 60 | } |
| 61 | + // Shift+Enter is a newline in the compose bar and Enter sends the | ||
| 62 | + // line, which a terminal can only tell apart when it is asked to: the | ||
| 63 | + // legacy encoding gives both of them the same byte. This is the kitty | ||
| 64 | + // keyboard protocol's first flag and nothing more — the terminals that | ||
| 65 | + // have it answer the query, and the ones that do not are left as they | ||
| 66 | + // were, with Alt+Enter and Ctrl+J as the way to break a line there. | ||
| 67 | + let enhanced = matches!(supports_keyboard_enhancement(), Ok(true)); | ||
| 68 | + if enhanced { | ||
| 69 | + let _ = execute!( | ||
| 70 | + out, | ||
| 71 | + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) | ||
| 72 | + ); | ||
| 73 | + } | ||
| 55 | graphics::set_cell(measure_cell()); | 74 | graphics::set_cell(measure_cell()); |
| 56 | Ok(Self { | 75 | Ok(Self { |
| 57 | out, | 76 | out, |
| 58 | last: Screen::new(w, h), | 77 | last: Screen::new(w, h), |
| 59 | mouse, | 78 | mouse, |
| 79 | + enhanced, | ||
| 60 | graphics: Graphics::default(), | 80 | graphics: Graphics::default(), |
| 61 | }) | 81 | }) |
| 62 | } | 82 | } |
| @@ -76,6 +96,9 @@ impl Term { | |||
| 76 | if self.mouse { | 96 | if self.mouse { |
| 77 | let _ = execute!(self.out, DisableMouseCapture); | 97 | let _ = execute!(self.out, DisableMouseCapture); |
| 78 | } | 98 | } |
| 99 | + if self.enhanced { | ||
| 100 | + let _ = execute!(self.out, PopKeyboardEnhancementFlags); | ||
| 101 | + } | ||
| 79 | let _ = execute!(self.out, cursor::Show, LeaveAlternateScreen); | 102 | let _ = execute!(self.out, cursor::Show, LeaveAlternateScreen); |
| 80 | let _ = disable_raw_mode(); | 103 | let _ = disable_raw_mode(); |
| 81 | } | 104 | } |
modified
crates/jolt-tui/src/tests.rs +113 -0 | @@ -870,3 +870,116 @@ fn backlog_cost() { | ||
| 870 | 870 | ); |
| 871 | 871 | } |
| 872 | 872 | } |
| 873 | + | |
| 874 | +/// A field of three rows in a screen of 24 columns: the compose bar frq puts | |
| 875 | +/// under its backlog, and the shape every assertion below is about. | |
| 876 | +fn compose(ui: &mut Ui, text: &str) -> u32 { | |
| 877 | + let root = ui.tree.root(); | |
| 878 | + let entry = node(ui, root, "entry", &[("text", text)]); | |
| 879 | + ui.tree.set(entry, "rows", Value::Num(3.0)); | |
| 880 | + ui.frame(); | |
| 881 | + entry | |
| 882 | +} | |
| 883 | + | |
| 884 | +#[test] | |
| 885 | +fn a_multi_line_field_paints_its_text_down_its_own_rows() { | |
| 886 | + let mut ui = ui(); | |
| 887 | + compose(&mut ui, "the tree ABI is the same one libvidya exports"); | |
| 888 | + assert_eq!(ui.screen.line(0), "the tree ABI is the"); | |
| 889 | + assert_eq!(ui.screen.line(1), "same one libvidya"); | |
| 890 | + assert_eq!(ui.screen.line(2), "exports"); | |
| 891 | +} | |
| 892 | + | |
| 893 | +#[test] | |
| 894 | +fn a_click_in_a_wrapped_field_puts_the_caret_on_the_row_it_landed_on() { | |
| 895 | + let mut ui = ui(); | |
| 896 | + let entry = compose(&mut ui, "the tree ABI is the same one libvidya exports"); | |
| 897 | + // The "one" on the second row: "same one libvidya", column 5. | |
| 898 | + ui.click(5, 1); | |
| 899 | + assert_eq!(ui.focus(), entry); | |
| 900 | + ui.key("x"); | |
| 901 | + assert_eq!( | |
| 902 | + ui.tree.props(entry).str("text"), | |
| 903 | + "the tree ABI is the same xone libvidya exports" | |
| 904 | + ); | |
| 905 | + // And a click past the end of the last row is the end of the text. | |
| 906 | + ui.frame(); | |
| 907 | + ui.click(20, 2); | |
| 908 | + ui.key("!"); | |
| 909 | + assert!(ui.tree.props(entry).str("text").ends_with("exports!")); | |
| 910 | +} | |
| 911 | + | |
| 912 | +#[test] | |
| 913 | +fn shift_enter_breaks_the_line_and_enter_still_sends_it() { | |
| 914 | + let mut ui = ui(); | |
| 915 | + let entry = compose(&mut ui, "one"); | |
| 916 | + ui.key("shift+enter"); | |
| 917 | + ui.key("t"); | |
| 918 | + ui.key("w"); | |
| 919 | + ui.key("o"); | |
| 920 | + assert_eq!(ui.tree.props(entry).str("text"), "one\ntwo"); | |
| 921 | + ui.frame(); | |
| 922 | + assert_eq!(ui.screen.line(0), "one"); | |
| 923 | + assert_eq!(ui.screen.line(1), "two"); | |
| 924 | + let _ = events(&mut ui); | |
| 925 | + ui.key("enter"); | |
| 926 | + assert_eq!( | |
| 927 | + events(&mut ui), | |
| 928 | + vec![(entry, "activate".into(), "one\ntwo".into(), 0.0)] | |
| 929 | + ); | |
| 930 | +} | |
| 931 | + | |
| 932 | +#[test] | |
| 933 | +fn up_and_down_step_between_rows_and_keep_the_column_they_left() { | |
| 934 | + let mut ui = ui(); | |
| 935 | + let entry = compose(&mut ui, "hello\nab\nworld"); | |
| 936 | + // The caret is at the end of the text; up twice is column 5 of a row two | |
| 937 | + // characters long, and coming back down has to find column 5 again. | |
| 938 | + ui.key("up"); | |
| 939 | + ui.key("up"); | |
| 940 | + ui.key("down"); | |
| 941 | + ui.key("down"); | |
| 942 | + ui.key("!"); | |
| 943 | + assert_eq!(ui.tree.props(entry).str("text"), "hello\nab\nworld!"); | |
| 944 | + // And up off the first row is not the field's key: it goes to the caller. | |
| 945 | + let _ = events(&mut ui); | |
| 946 | + ui.frame(); | |
| 947 | + ui.key("ctrl+home"); | |
| 948 | + ui.key("up"); | |
| 949 | + assert_eq!( | |
| 950 | + events(&mut ui), | |
| 951 | + vec![(entry, "key".into(), "up".into(), 0.0)] | |
| 952 | + ); | |
| 953 | +} | |
| 954 | + | |
| 955 | +#[test] | |
| 956 | +fn a_field_shorter_than_its_text_scrolls_to_keep_the_caret_in_view() { | |
| 957 | + let mut ui = ui(); | |
| 958 | + let entry = compose(&mut ui, "one\ntwo\nthree\nfour"); | |
| 959 | + // Four rows in three: the caret is at the end, so the first row is off the | |
| 960 | + // top rather than the last off the bottom. | |
| 961 | + assert_eq!(ui.screen.line(0), "two"); | |
| 962 | + assert_eq!(ui.screen.line(2), "four"); | |
| 963 | + // A click on the top row is "two", not "one". | |
| 964 | + ui.click(0, 0); | |
| 965 | + ui.key("!"); | |
| 966 | + assert_eq!(ui.tree.props(entry).str("text"), "one\n!two\nthree\nfour"); | |
| 967 | + // And walking back up brings the row above into view. | |
| 968 | + ui.frame(); | |
| 969 | + ui.key("up"); | |
| 970 | + ui.frame(); | |
| 971 | + assert_eq!(ui.screen.line(0), "one"); | |
| 972 | +} | |
| 973 | + | |
| 974 | +#[test] | |
| 975 | +fn home_and_end_are_about_the_row_the_caret_is_on() { | |
| 976 | + let mut ui = ui(); | |
| 977 | + let entry = compose(&mut ui, "one\ntwo"); | |
| 978 | + ui.key("home"); | |
| 979 | + ui.key("x"); | |
| 980 | + assert_eq!(ui.tree.props(entry).str("text"), "one\nxtwo"); | |
| 981 | + ui.frame(); | |
| 982 | + ui.key("end"); | |
| 983 | + ui.key("ctrl+u"); | |
| 984 | + assert_eq!(ui.tree.props(entry).str("text"), "one\n"); | |
| 985 | +} | |
| @@ -870,3 +870,116 @@ fn backlog_cost() { | |||
| 870 | ); | 870 | ); |
| 871 | } | 871 | } |
| 872 | } | 872 | } |
| 873 | + | ||
| 874 | +/// A field of three rows in a screen of 24 columns: the compose bar frq puts | ||
| 875 | +/// under its backlog, and the shape every assertion below is about. | ||
| 876 | +fn compose(ui: &mut Ui, text: &str) -> u32 { | ||
| 877 | + let root = ui.tree.root(); | ||
| 878 | + let entry = node(ui, root, "entry", &[("text", text)]); | ||
| 879 | + ui.tree.set(entry, "rows", Value::Num(3.0)); | ||
| 880 | + ui.frame(); | ||
| 881 | + entry | ||
| 882 | +} | ||
| 883 | + | ||
| 884 | +#[test] | ||
| 885 | +fn a_multi_line_field_paints_its_text_down_its_own_rows() { | ||
| 886 | + let mut ui = ui(); | ||
| 887 | + compose(&mut ui, "the tree ABI is the same one libvidya exports"); | ||
| 888 | + assert_eq!(ui.screen.line(0), "the tree ABI is the"); | ||
| 889 | + assert_eq!(ui.screen.line(1), "same one libvidya"); | ||
| 890 | + assert_eq!(ui.screen.line(2), "exports"); | ||
| 891 | +} | ||
| 892 | + | ||
| 893 | +#[test] | ||
| 894 | +fn a_click_in_a_wrapped_field_puts_the_caret_on_the_row_it_landed_on() { | ||
| 895 | + let mut ui = ui(); | ||
| 896 | + let entry = compose(&mut ui, "the tree ABI is the same one libvidya exports"); | ||
| 897 | + // The "one" on the second row: "same one libvidya", column 5. | ||
| 898 | + ui.click(5, 1); | ||
| 899 | + assert_eq!(ui.focus(), entry); | ||
| 900 | + ui.key("x"); | ||
| 901 | + assert_eq!( | ||
| 902 | + ui.tree.props(entry).str("text"), | ||
| 903 | + "the tree ABI is the same xone libvidya exports" | ||
| 904 | + ); | ||
| 905 | + // And a click past the end of the last row is the end of the text. | ||
| 906 | + ui.frame(); | ||
| 907 | + ui.click(20, 2); | ||
| 908 | + ui.key("!"); | ||
| 909 | + assert!(ui.tree.props(entry).str("text").ends_with("exports!")); | ||
| 910 | +} | ||
| 911 | + | ||
| 912 | +#[test] | ||
| 913 | +fn shift_enter_breaks_the_line_and_enter_still_sends_it() { | ||
| 914 | + let mut ui = ui(); | ||
| 915 | + let entry = compose(&mut ui, "one"); | ||
| 916 | + ui.key("shift+enter"); | ||
| 917 | + ui.key("t"); | ||
| 918 | + ui.key("w"); | ||
| 919 | + ui.key("o"); | ||
| 920 | + assert_eq!(ui.tree.props(entry).str("text"), "one\ntwo"); | ||
| 921 | + ui.frame(); | ||
| 922 | + assert_eq!(ui.screen.line(0), "one"); | ||
| 923 | + assert_eq!(ui.screen.line(1), "two"); | ||
| 924 | + let _ = events(&mut ui); | ||
| 925 | + ui.key("enter"); | ||
| 926 | + assert_eq!( | ||
| 927 | + events(&mut ui), | ||
| 928 | + vec![(entry, "activate".into(), "one\ntwo".into(), 0.0)] | ||
| 929 | + ); | ||
| 930 | +} | ||
| 931 | + | ||
| 932 | +#[test] | ||
| 933 | +fn up_and_down_step_between_rows_and_keep_the_column_they_left() { | ||
| 934 | + let mut ui = ui(); | ||
| 935 | + let entry = compose(&mut ui, "hello\nab\nworld"); | ||
| 936 | + // The caret is at the end of the text; up twice is column 5 of a row two | ||
| 937 | + // characters long, and coming back down has to find column 5 again. | ||
| 938 | + ui.key("up"); | ||
| 939 | + ui.key("up"); | ||
| 940 | + ui.key("down"); | ||
| 941 | + ui.key("down"); | ||
| 942 | + ui.key("!"); | ||
| 943 | + assert_eq!(ui.tree.props(entry).str("text"), "hello\nab\nworld!"); | ||
| 944 | + // And up off the first row is not the field's key: it goes to the caller. | ||
| 945 | + let _ = events(&mut ui); | ||
| 946 | + ui.frame(); | ||
| 947 | + ui.key("ctrl+home"); | ||
| 948 | + ui.key("up"); | ||
| 949 | + assert_eq!( | ||
| 950 | + events(&mut ui), | ||
| 951 | + vec![(entry, "key".into(), "up".into(), 0.0)] | ||
| 952 | + ); | ||
| 953 | +} | ||
| 954 | + | ||
| 955 | +#[test] | ||
| 956 | +fn a_field_shorter_than_its_text_scrolls_to_keep_the_caret_in_view() { | ||
| 957 | + let mut ui = ui(); | ||
| 958 | + let entry = compose(&mut ui, "one\ntwo\nthree\nfour"); | ||
| 959 | + // Four rows in three: the caret is at the end, so the first row is off the | ||
| 960 | + // top rather than the last off the bottom. | ||
| 961 | + assert_eq!(ui.screen.line(0), "two"); | ||
| 962 | + assert_eq!(ui.screen.line(2), "four"); | ||
| 963 | + // A click on the top row is "two", not "one". | ||
| 964 | + ui.click(0, 0); | ||
| 965 | + ui.key("!"); | ||
| 966 | + assert_eq!(ui.tree.props(entry).str("text"), "one\n!two\nthree\nfour"); | ||
| 967 | + // And walking back up brings the row above into view. | ||
| 968 | + ui.frame(); | ||
| 969 | + ui.key("up"); | ||
| 970 | + ui.frame(); | ||
| 971 | + assert_eq!(ui.screen.line(0), "one"); | ||
| 972 | +} | ||
| 973 | + | ||
| 974 | +#[test] | ||
| 975 | +fn home_and_end_are_about_the_row_the_caret_is_on() { | ||
| 976 | + let mut ui = ui(); | ||
| 977 | + let entry = compose(&mut ui, "one\ntwo"); | ||
| 978 | + ui.key("home"); | ||
| 979 | + ui.key("x"); | ||
| 980 | + assert_eq!(ui.tree.props(entry).str("text"), "one\nxtwo"); | ||
| 981 | + ui.frame(); | ||
| 982 | + ui.key("end"); | ||
| 983 | + ui.key("ctrl+u"); | ||
| 984 | + assert_eq!(ui.tree.props(entry).str("text"), "one\n"); | ||
| 985 | +} | ||
modified
crates/jolt-tui/src/ui.rs +104 -10 | @@ -9,7 +9,9 @@ | ||
| 9 | 9 | //! terminal's bytes into those names is [`crate::keys`]'s job, and a caller |
| 10 | 10 | //! synthesising one for a test writes the name directly. |
| 11 | 11 | |
| 12 | +use crate::entry::View; | |
| 12 | 13 | use crate::keys; |
| 14 | +use crate::layout; | |
| 13 | 15 | use crate::paint::{self, Painted}; |
| 14 | 16 | use crate::screen::Screen; |
| 15 | 17 | use crate::tree::{Tag, Tree, Value}; |
| @@ -40,6 +42,16 @@ pub struct Ui { | ||
| 40 | 42 | focus: u32, |
| 41 | 43 | /// The caret in the focused entry, in characters from the start. |
| 42 | 44 | caret: usize, |
| 45 | + /// The row the focused entry is scrolled to, in rows of its own text. | |
| 46 | + entry_top: usize, | |
| 47 | + /// The column up and down are aiming for. | |
| 48 | + /// | |
| 49 | + /// Walking a caret down through rows of different lengths and back up has | |
| 50 | + /// to come home to the column it left, so the column is remembered until | |
| 51 | + /// something other than up or down moves the caret. Without it a step | |
| 52 | + /// through a short row drags the caret to that row's end and leaves it | |
| 53 | + /// there, which is the thing that makes an editor feel broken. | |
| 54 | + goal: Option<u16>, | |
| 43 | 55 | painted: Painted, |
| 44 | 56 | tick: u64, |
| 45 | 57 | quit: bool, |
| @@ -54,6 +66,8 @@ impl Ui { | ||
| 54 | 66 | screen: Screen::new(width.max(1), height.max(1)), |
| 55 | 67 | focus: 0, |
| 56 | 68 | caret: 0, |
| 69 | + entry_top: 0, | |
| 70 | + goal: None, | |
| 57 | 71 | painted: Painted::default(), |
| 58 | 72 | tick: 0, |
| 59 | 73 | quit: false, |
| @@ -164,8 +178,10 @@ impl Ui { | ||
| 164 | 178 | &mut self.screen, |
| 165 | 179 | self.focus, |
| 166 | 180 | self.caret, |
| 181 | + self.entry_top, | |
| 167 | 182 | self.tick, |
| 168 | 183 | ); |
| 184 | + self.entry_top = self.painted.entry_top; | |
| 169 | 185 | } |
| 170 | 186 | |
| 171 | 187 | /// Put the focus somewhere real. Answers whether it moved. |
| @@ -199,6 +215,10 @@ impl Ui { | ||
| 199 | 215 | // The caret goes to the end of whatever it just entered, which is where |
| 200 | 216 | // someone tabbing into a field with text in it expects to type. |
| 201 | 217 | self.caret = self.tree.props(id).str("text").chars().count(); |
| 218 | + self.goal = None; | |
| 219 | + // And the new field is scrolled to wherever that put it, not to | |
| 220 | + // wherever the last one happened to be. | |
| 221 | + self.entry_top = usize::MAX; | |
| 202 | 222 | } |
| 203 | 223 | |
| 204 | 224 | fn move_focus(&mut self, forward: bool) { |
| @@ -312,20 +332,77 @@ impl Ui { | ||
| 312 | 332 | .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 }); |
| 313 | 333 | } |
| 314 | 334 | |
| 335 | + /// How the text in `node` sits in the cells it was painted into. | |
| 336 | + /// | |
| 337 | + /// The same layout the painter used, asked again rather than kept: it is a | |
| 338 | + /// pure function of the text, the rect and the caret, and the alternative | |
| 339 | + /// is two copies of the truth that drift apart on the frame where the text | |
| 340 | + /// changed and the paint has not caught up. | |
| 341 | + fn entry_view(&self, node: u32) -> View { | |
| 342 | + let props = self.tree.props(node); | |
| 343 | + let multiline = layout::entry_multiline(&props); | |
| 344 | + let text = props.str("text").to_owned(); | |
| 345 | + let pad = layout::inset(&self.tree.tag(node), &props); | |
| 346 | + let (w, h) = self | |
| 347 | + .painted | |
| 348 | + .hits | |
| 349 | + .iter() | |
| 350 | + .find(|(id, _)| *id == node) | |
| 351 | + .map(|(_, rect)| rect.shrink(pad)) | |
| 352 | + .map_or((1, 1), |rect| (rect.w.max(1), rect.h.max(1))); | |
| 353 | + let top = if node == self.focus { | |
| 354 | + self.entry_top | |
| 355 | + } else { | |
| 356 | + usize::MAX | |
| 357 | + }; | |
| 358 | + View::of(&text, w, h, multiline, self.caret, top) | |
| 359 | + } | |
| 360 | + | |
| 315 | 361 | fn entry_key(&mut self, node: u32, name: &str) -> bool { |
| 316 | 362 | let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect(); |
| 363 | + let multiline = layout::entry_multiline(&self.tree.props(node)); | |
| 317 | 364 | let mut at = self.caret.min(text.len()); |
| 318 | 365 | let mut changed = false; |
| 366 | + // Only up and down keep the column they were aiming for; everything | |
| 367 | + // else here has said where it wants the caret. | |
| 368 | + let mut keep_goal = false; | |
| 319 | 369 | match name { |
| 320 | 370 | "enter" => { |
| 321 | 371 | let now: String = text.iter().collect(); |
| 322 | 372 | self.tree.emit(node, "activate", now, 0.0); |
| 323 | 373 | return true; |
| 324 | 374 | } |
| 375 | + // A newline where Enter is spoken for. Shift+Enter is what every | |
| 376 | + // chat box takes; Alt+Enter and Ctrl+J are the two a terminal that | |
| 377 | + // cannot tell Shift+Enter from Enter still can. | |
| 378 | + "shift+enter" | "alt+enter" | "ctrl+j" if multiline => { | |
| 379 | + text.insert(at, '\n'); | |
| 380 | + at += 1; | |
| 381 | + changed = true; | |
| 382 | + } | |
| 383 | + "up" | "ctrl+p" | "down" | "ctrl+n" if multiline => { | |
| 384 | + let up = matches!(name, "up" | "ctrl+p"); | |
| 385 | + let view = self.entry_view(node); | |
| 386 | + let (row, col) = view.caret_at(at); | |
| 387 | + // Off the top of the first row, or the bottom of the last, is | |
| 388 | + // not this field's key: it is a reader trying to leave. | |
| 389 | + if (up && row == 0) || (!up && row + 1 >= view.lines.len()) { | |
| 390 | + return false; | |
| 391 | + } | |
| 392 | + let goal = self.goal.unwrap_or(col).max(col); | |
| 393 | + let to = if up { row - 1 } else { row + 1 }; | |
| 394 | + at = view.at_col(to, goal); | |
| 395 | + self.goal = Some(goal); | |
| 396 | + keep_goal = true; | |
| 397 | + } | |
| 325 | 398 | "left" | "ctrl+b" => at = at.saturating_sub(1), |
| 326 | 399 | "right" | "ctrl+f" => at = (at + 1).min(text.len()), |
| 327 | - "home" | "ctrl+a" => at = 0, | |
| 328 | - "end" | "ctrl+e" => at = text.len(), | |
| 400 | + // Home and End are about the row the caret is on, which in a field | |
| 401 | + // of one row is the whole of the text. | |
| 402 | + "home" | "ctrl+a" => at = self.entry_view(node).caret_row(at).0, | |
| 403 | + "end" | "ctrl+e" => at = self.entry_view(node).caret_row(at).1, | |
| 404 | + "ctrl+home" => at = 0, | |
| 405 | + "ctrl+end" if multiline => at = text.len(), | |
| 329 | 406 | "alt+b" => at = keys::word_left(&text, at), |
| 330 | 407 | "alt+f" => at = keys::word_right(&text, at), |
| 331 | 408 | "backspace" => { |
| @@ -350,15 +427,22 @@ impl Ui { | ||
| 350 | 427 | } |
| 351 | 428 | } |
| 352 | 429 | "ctrl+u" => { |
| 353 | - if at > 0 { | |
| 354 | - text.drain(0..at); | |
| 355 | - at = 0; | |
| 430 | + let from = self.entry_view(node).caret_row(at).0; | |
| 431 | + if from < at { | |
| 432 | + text.drain(from..at); | |
| 433 | + at = from; | |
| 356 | 434 | changed = true; |
| 357 | 435 | } |
| 358 | 436 | } |
| 359 | 437 | "ctrl+k" => { |
| 360 | - if at < text.len() { | |
| 361 | - text.truncate(at); | |
| 438 | + let to = self.entry_view(node).caret_row(at).1; | |
| 439 | + if to > at { | |
| 440 | + text.drain(at..to); | |
| 441 | + changed = true; | |
| 442 | + } else if multiline && at < text.len() && text[at] == '\n' { | |
| 443 | + // At the end of a row already: the kill takes the break, | |
| 444 | + // which is how a line is joined to the one below it. | |
| 445 | + text.remove(at); | |
| 362 | 446 | changed = true; |
| 363 | 447 | } |
| 364 | 448 | } |
| @@ -381,6 +465,9 @@ impl Ui { | ||
| 381 | 465 | } |
| 382 | 466 | } |
| 383 | 467 | self.caret = at; |
| 468 | + if !keep_goal { | |
| 469 | + self.goal = None; | |
| 470 | + } | |
| 384 | 471 | if changed { |
| 385 | 472 | let now: String = text.iter().collect(); |
| 386 | 473 | self.tree.set(node, "text", Value::Str(now.clone())); |
| @@ -460,9 +547,16 @@ impl Ui { | ||
| 460 | 547 | } |
| 461 | 548 | } |
| 462 | 549 | Tag::Entry => { |
| 463 | - // Put the caret where it was clicked, not at the end. | |
| 464 | - let text = self.tree.props(node).str("text").chars().count(); | |
| 465 | - self.caret = ((x - rect.x) as usize).min(text); | |
| 550 | + // Put the caret where it was clicked, on the row that was | |
| 551 | + // clicked: the field knows where its own characters were | |
| 552 | + // painted, so a click in the middle of the third wrapped row | |
| 553 | + // is the character in the middle of the third wrapped row. | |
| 554 | + let props = self.tree.props(node); | |
| 555 | + let pad = layout::inset(&Tag::Entry, &props); | |
| 556 | + let inner = rect.shrink(pad); | |
| 557 | + let view = self.entry_view(node); | |
| 558 | + self.caret = view.hit(x.saturating_sub(inner.x), y.saturating_sub(inner.y)); | |
| 559 | + self.goal = None; | |
| 466 | 560 | } |
| 467 | 561 | _ => {} |
| 468 | 562 | } |
| @@ -9,7 +9,9 @@ | |||
| 9 | //! terminal's bytes into those names is [`crate::keys`]'s job, and a caller | 9 | //! terminal's bytes into those names is [`crate::keys`]'s job, and a caller |
| 10 | //! synthesising one for a test writes the name directly. | 10 | //! synthesising one for a test writes the name directly. |
| 11 | 11 | ||
| 12 | +use crate::entry::View; | ||
| 12 | use crate::keys; | 13 | use crate::keys; |
| 14 | +use crate::layout; | ||
| 13 | use crate::paint::{self, Painted}; | 15 | use crate::paint::{self, Painted}; |
| 14 | use crate::screen::Screen; | 16 | use crate::screen::Screen; |
| 15 | use crate::tree::{Tag, Tree, Value}; | 17 | use crate::tree::{Tag, Tree, Value}; |
| @@ -40,6 +42,16 @@ pub struct Ui { | |||
| 40 | focus: u32, | 42 | focus: u32, |
| 41 | /// The caret in the focused entry, in characters from the start. | 43 | /// The caret in the focused entry, in characters from the start. |
| 42 | caret: usize, | 44 | caret: usize, |
| 45 | + /// The row the focused entry is scrolled to, in rows of its own text. | ||
| 46 | + entry_top: usize, | ||
| 47 | + /// The column up and down are aiming for. | ||
| 48 | + /// | ||
| 49 | + /// Walking a caret down through rows of different lengths and back up has | ||
| 50 | + /// to come home to the column it left, so the column is remembered until | ||
| 51 | + /// something other than up or down moves the caret. Without it a step | ||
| 52 | + /// through a short row drags the caret to that row's end and leaves it | ||
| 53 | + /// there, which is the thing that makes an editor feel broken. | ||
| 54 | + goal: Option<u16>, | ||
| 43 | painted: Painted, | 55 | painted: Painted, |
| 44 | tick: u64, | 56 | tick: u64, |
| 45 | quit: bool, | 57 | quit: bool, |
| @@ -54,6 +66,8 @@ impl Ui { | |||
| 54 | screen: Screen::new(width.max(1), height.max(1)), | 66 | screen: Screen::new(width.max(1), height.max(1)), |
| 55 | focus: 0, | 67 | focus: 0, |
| 56 | caret: 0, | 68 | caret: 0, |
| 69 | + entry_top: 0, | ||
| 70 | + goal: None, | ||
| 57 | painted: Painted::default(), | 71 | painted: Painted::default(), |
| 58 | tick: 0, | 72 | tick: 0, |
| 59 | quit: false, | 73 | quit: false, |
| @@ -164,8 +178,10 @@ impl Ui { | |||
| 164 | &mut self.screen, | 178 | &mut self.screen, |
| 165 | self.focus, | 179 | self.focus, |
| 166 | self.caret, | 180 | self.caret, |
| 181 | + self.entry_top, | ||
| 167 | self.tick, | 182 | self.tick, |
| 168 | ); | 183 | ); |
| 184 | + self.entry_top = self.painted.entry_top; | ||
| 169 | } | 185 | } |
| 170 | 186 | ||
| 171 | /// Put the focus somewhere real. Answers whether it moved. | 187 | /// Put the focus somewhere real. Answers whether it moved. |
| @@ -199,6 +215,10 @@ impl Ui { | |||
| 199 | // The caret goes to the end of whatever it just entered, which is where | 215 | // The caret goes to the end of whatever it just entered, which is where |
| 200 | // someone tabbing into a field with text in it expects to type. | 216 | // someone tabbing into a field with text in it expects to type. |
| 201 | self.caret = self.tree.props(id).str("text").chars().count(); | 217 | self.caret = self.tree.props(id).str("text").chars().count(); |
| 218 | + self.goal = None; | ||
| 219 | + // And the new field is scrolled to wherever that put it, not to | ||
| 220 | + // wherever the last one happened to be. | ||
| 221 | + self.entry_top = usize::MAX; | ||
| 202 | } | 222 | } |
| 203 | 223 | ||
| 204 | fn move_focus(&mut self, forward: bool) { | 224 | fn move_focus(&mut self, forward: bool) { |
| @@ -312,20 +332,77 @@ impl Ui { | |||
| 312 | .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 }); | 332 | .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 }); |
| 313 | } | 333 | } |
| 314 | 334 | ||
| 335 | + /// How the text in `node` sits in the cells it was painted into. | ||
| 336 | + /// | ||
| 337 | + /// The same layout the painter used, asked again rather than kept: it is a | ||
| 338 | + /// pure function of the text, the rect and the caret, and the alternative | ||
| 339 | + /// is two copies of the truth that drift apart on the frame where the text | ||
| 340 | + /// changed and the paint has not caught up. | ||
| 341 | + fn entry_view(&self, node: u32) -> View { | ||
| 342 | + let props = self.tree.props(node); | ||
| 343 | + let multiline = layout::entry_multiline(&props); | ||
| 344 | + let text = props.str("text").to_owned(); | ||
| 345 | + let pad = layout::inset(&self.tree.tag(node), &props); | ||
| 346 | + let (w, h) = self | ||
| 347 | + .painted | ||
| 348 | + .hits | ||
| 349 | + .iter() | ||
| 350 | + .find(|(id, _)| *id == node) | ||
| 351 | + .map(|(_, rect)| rect.shrink(pad)) | ||
| 352 | + .map_or((1, 1), |rect| (rect.w.max(1), rect.h.max(1))); | ||
| 353 | + let top = if node == self.focus { | ||
| 354 | + self.entry_top | ||
| 355 | + } else { | ||
| 356 | + usize::MAX | ||
| 357 | + }; | ||
| 358 | + View::of(&text, w, h, multiline, self.caret, top) | ||
| 359 | + } | ||
| 360 | + | ||
| 315 | fn entry_key(&mut self, node: u32, name: &str) -> bool { | 361 | fn entry_key(&mut self, node: u32, name: &str) -> bool { |
| 316 | let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect(); | 362 | let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect(); |
| 363 | + let multiline = layout::entry_multiline(&self.tree.props(node)); | ||
| 317 | let mut at = self.caret.min(text.len()); | 364 | let mut at = self.caret.min(text.len()); |
| 318 | let mut changed = false; | 365 | let mut changed = false; |
| 366 | + // Only up and down keep the column they were aiming for; everything | ||
| 367 | + // else here has said where it wants the caret. | ||
| 368 | + let mut keep_goal = false; | ||
| 319 | match name { | 369 | match name { |
| 320 | "enter" => { | 370 | "enter" => { |
| 321 | let now: String = text.iter().collect(); | 371 | let now: String = text.iter().collect(); |
| 322 | self.tree.emit(node, "activate", now, 0.0); | 372 | self.tree.emit(node, "activate", now, 0.0); |
| 323 | return true; | 373 | return true; |
| 324 | } | 374 | } |
| 375 | + // A newline where Enter is spoken for. Shift+Enter is what every | ||
| 376 | + // chat box takes; Alt+Enter and Ctrl+J are the two a terminal that | ||
| 377 | + // cannot tell Shift+Enter from Enter still can. | ||
| 378 | + "shift+enter" | "alt+enter" | "ctrl+j" if multiline => { | ||
| 379 | + text.insert(at, '\n'); | ||
| 380 | + at += 1; | ||
| 381 | + changed = true; | ||
| 382 | + } | ||
| 383 | + "up" | "ctrl+p" | "down" | "ctrl+n" if multiline => { | ||
| 384 | + let up = matches!(name, "up" | "ctrl+p"); | ||
| 385 | + let view = self.entry_view(node); | ||
| 386 | + let (row, col) = view.caret_at(at); | ||
| 387 | + // Off the top of the first row, or the bottom of the last, is | ||
| 388 | + // not this field's key: it is a reader trying to leave. | ||
| 389 | + if (up && row == 0) || (!up && row + 1 >= view.lines.len()) { | ||
| 390 | + return false; | ||
| 391 | + } | ||
| 392 | + let goal = self.goal.unwrap_or(col).max(col); | ||
| 393 | + let to = if up { row - 1 } else { row + 1 }; | ||
| 394 | + at = view.at_col(to, goal); | ||
| 395 | + self.goal = Some(goal); | ||
| 396 | + keep_goal = true; | ||
| 397 | + } | ||
| 325 | "left" | "ctrl+b" => at = at.saturating_sub(1), | 398 | "left" | "ctrl+b" => at = at.saturating_sub(1), |
| 326 | "right" | "ctrl+f" => at = (at + 1).min(text.len()), | 399 | "right" | "ctrl+f" => at = (at + 1).min(text.len()), |
| 327 | - "home" | "ctrl+a" => at = 0, | 400 | + // Home and End are about the row the caret is on, which in a field |
| 328 | - "end" | "ctrl+e" => at = text.len(), | 401 | + // of one row is the whole of the text. |
| 402 | + "home" | "ctrl+a" => at = self.entry_view(node).caret_row(at).0, | ||
| 403 | + "end" | "ctrl+e" => at = self.entry_view(node).caret_row(at).1, | ||
| 404 | + "ctrl+home" => at = 0, | ||
| 405 | + "ctrl+end" if multiline => at = text.len(), | ||
| 329 | "alt+b" => at = keys::word_left(&text, at), | 406 | "alt+b" => at = keys::word_left(&text, at), |
| 330 | "alt+f" => at = keys::word_right(&text, at), | 407 | "alt+f" => at = keys::word_right(&text, at), |
| 331 | "backspace" => { | 408 | "backspace" => { |
| @@ -350,15 +427,22 @@ impl Ui { | |||
| 350 | } | 427 | } |
| 351 | } | 428 | } |
| 352 | "ctrl+u" => { | 429 | "ctrl+u" => { |
| 353 | - if at > 0 { | 430 | + let from = self.entry_view(node).caret_row(at).0; |
| 354 | - text.drain(0..at); | 431 | + if from < at { |
| 355 | - at = 0; | 432 | + text.drain(from..at); |
| 433 | + at = from; | ||
| 356 | changed = true; | 434 | changed = true; |
| 357 | } | 435 | } |
| 358 | } | 436 | } |
| 359 | "ctrl+k" => { | 437 | "ctrl+k" => { |
| 360 | - if at < text.len() { | 438 | + let to = self.entry_view(node).caret_row(at).1; |
| 361 | - text.truncate(at); | 439 | + if to > at { |
| 440 | + text.drain(at..to); | ||
| 441 | + changed = true; | ||
| 442 | + } else if multiline && at < text.len() && text[at] == '\n' { | ||
| 443 | + // At the end of a row already: the kill takes the break, | ||
| 444 | + // which is how a line is joined to the one below it. | ||
| 445 | + text.remove(at); | ||
| 362 | changed = true; | 446 | changed = true; |
| 363 | } | 447 | } |
| 364 | } | 448 | } |
| @@ -381,6 +465,9 @@ impl Ui { | |||
| 381 | } | 465 | } |
| 382 | } | 466 | } |
| 383 | self.caret = at; | 467 | self.caret = at; |
| 468 | + if !keep_goal { | ||
| 469 | + self.goal = None; | ||
| 470 | + } | ||
| 384 | if changed { | 471 | if changed { |
| 385 | let now: String = text.iter().collect(); | 472 | let now: String = text.iter().collect(); |
| 386 | self.tree.set(node, "text", Value::Str(now.clone())); | 473 | self.tree.set(node, "text", Value::Str(now.clone())); |
| @@ -460,9 +547,16 @@ impl Ui { | |||
| 460 | } | 547 | } |
| 461 | } | 548 | } |
| 462 | Tag::Entry => { | 549 | Tag::Entry => { |
| 463 | - // Put the caret where it was clicked, not at the end. | 550 | + // Put the caret where it was clicked, on the row that was |
| 464 | - let text = self.tree.props(node).str("text").chars().count(); | 551 | + // clicked: the field knows where its own characters were |
| 465 | - self.caret = ((x - rect.x) as usize).min(text); | 552 | + // painted, so a click in the middle of the third wrapped row |
| 553 | + // is the character in the middle of the third wrapped row. | ||
| 554 | + let props = self.tree.props(node); | ||
| 555 | + let pad = layout::inset(&Tag::Entry, &props); | ||
| 556 | + let inner = rect.shrink(pad); | ||
| 557 | + let view = self.entry_view(node); | ||
| 558 | + self.caret = view.hit(x.saturating_sub(inner.x), y.saturating_sub(inner.y)); | ||
| 559 | + self.goal = None; | ||
| 466 | } | 560 | } |
| 467 | _ => {} | 561 | _ => {} |
| 468 | } | 562 | } |