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

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

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>
nandi committed 2026-09-10T10:09:59-07:00 Browse files
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.
6666 | Enter, Space | activate the focused widget |
6767 | arrows, `j`/`k`, Page Up/Down, `g`/`G` | move a list's cursor |
6868 | 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` |
6970 | Esc | closes the topmost `:overlay` |
7071 | Ctrl-C, Ctrl-Q | quit |
7172
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+
7282 Anything nothing here wanted comes out as a `key` event on the focused node,
7383 named the way it is fed in — `"ctrl+u"`, `"page-down"`, `"f5"`. Bubbling it to a
7484 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 a83 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 {
242242 }
243243 }
244244
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+
245254 /// A node's content size before its own request or inset is applied.
246255 fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
247256 let tag = tree.tag_of(id);
@@ -251,7 +260,11 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
251260 Tag::Button => columns(text).saturating_add(4),
252261 Tag::CheckButton => columns(text).saturating_add(4),
253262 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);
255268 if minimum {
256269 want.min(6)
257270 } 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 @@
3131 // vocabulary, not dead code, so a headless build does not warn about them.
3232 #![cfg_attr(not(feature = "terminal"), allow(dead_code))]
3333
34+mod entry;
3435 mod graphics;
3536 mod keys;
3637 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 @@
99 //! over the whole screen, so it is painted after everything else at the size it
1010 //! asked for, in the middle.
1111
12+use crate::entry::View;
1213 use crate::graphics;
1314 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};
1516 use crate::tree::{Props, Tag, Tree};
1617
1718 const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
@@ -33,6 +34,10 @@ pub struct Painted {
3334 pub scrolled: Vec<(u32, u16, u16, Rect)>,
3435 /// Where the cursor should sit — the focused entry's caret, if any.
3536 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,
3641 /// The pictures this frame wants on screen, in the cells they were given.
3742 /// Nothing was painted for them: the grid has no pixels, and the terminal
3843 /// is what draws one — see [`crate::graphics`].
@@ -71,6 +76,8 @@ struct Painter<'a> {
7176 focus: u32,
7277 /// Where the caret sits in the focused entry's text, in characters.
7378 caret: usize,
79+ /// The row the focused entry was scrolled to on the last frame.
80+ entry_top: usize,
7481 tick: u64,
7582 out: Painted,
7683 overlays: Vec<u32>,
@@ -78,15 +85,26 @@ struct Painter<'a> {
7885
7986 /// Paint the whole tree. `focus` is the node the ring is currently on and
8087 /// `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 {
8296 screen.clear();
8397 let mut painter = Painter {
8498 tree,
8599 screen,
86100 focus,
87101 caret,
102+ entry_top,
88103 tick,
89- out: Painted::default(),
104+ out: Painted {
105+ entry_top,
106+ ..Painted::default()
107+ },
90108 overlays: Vec::new(),
91109 };
92110 let area = painter.screen.rect();
@@ -360,40 +378,48 @@ impl Painter<'_> {
360378 // The field is its whole rect, not just the text in it: a reader needs
361379 // to see where it can type before it has typed anything.
362380 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())
366387 } else {
367- vec![shown.chars().collect::<String>()]
388+ shown.chars().count()
368389 };
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)
379415 } else {
380- len.saturating_sub(window)
416+ view.caret_at(caret)
381417 };
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));
397423 }
398424 }
399425
@@ -509,8 +535,12 @@ impl Painter<'_> {
509535 screen: &mut buffer,
510536 focus: self.focus,
511537 caret: self.caret,
538+ entry_top: self.entry_top,
512539 tick: self.tick,
513- out: Painted::default(),
540+ out: Painted {
541+ entry_top: self.entry_top,
542+ ..Painted::default()
543+ },
514544 overlays: Vec::new(),
515545 };
516546 for (child, rect, shown) in plan {
@@ -532,6 +562,11 @@ impl Painter<'_> {
532562 }
533563 let mut learned = inner.out;
534564 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+ }
535570
536571 for y in 0..area.h {
537572 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 it9 //! 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 terminal42 /// 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 and86 /// 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 needs378 // 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 in391+ let view = View::of(
371- // view — the end of it is where someone is usually typing, but not392+ &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.screen419+ 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 the422+ 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};
1111 use crossterm::event::{
1212 DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind,
1313 };
14+use crossterm::event::{
15+ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
16+};
17+use crossterm::terminal::supports_keyboard_enhancement;
1418 use crossterm::terminal::{
1519 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
1620 };
@@ -39,6 +43,8 @@ pub struct Term {
3943 mouse: bool,
4044 /// The pictures the terminal has been given, and where they are.
4145 graphics: Graphics,
46+ /// Whether the keyboard protocol was pushed, and so has to be popped.
47+ enhanced: bool,
4248 }
4349
4450 impl Term {
@@ -52,11 +58,25 @@ impl Term {
5258 if mouse {
5359 execute!(out, EnableMouseCapture)?;
5460 }
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+ }
5574 graphics::set_cell(measure_cell());
5675 Ok(Self {
5776 out,
5877 last: Screen::new(w, h),
5978 mouse,
79+ enhanced,
6080 graphics: Graphics::default(),
6181 })
6282 }
@@ -76,6 +96,9 @@ impl Term {
7696 if self.mouse {
7797 let _ = execute!(self.out, DisableMouseCapture);
7898 }
99+ if self.enhanced {
100+ let _ = execute!(self.out, PopKeyboardEnhancementFlags);
101+ }
79102 let _ = execute!(self.out, cursor::Show, LeaveAlternateScreen);
80103 let _ = disable_raw_mode();
81104 }
@@ -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() {
870870 );
871871 }
872872 }
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 @@
99 //! terminal's bytes into those names is [`crate::keys`]'s job, and a caller
1010 //! synthesising one for a test writes the name directly.
1111
12+use crate::entry::View;
1213 use crate::keys;
14+use crate::layout;
1315 use crate::paint::{self, Painted};
1416 use crate::screen::Screen;
1517 use crate::tree::{Tag, Tree, Value};
@@ -40,6 +42,16 @@ pub struct Ui {
4042 focus: u32,
4143 /// The caret in the focused entry, in characters from the start.
4244 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>,
4355 painted: Painted,
4456 tick: u64,
4557 quit: bool,
@@ -54,6 +66,8 @@ impl Ui {
5466 screen: Screen::new(width.max(1), height.max(1)),
5567 focus: 0,
5668 caret: 0,
69+ entry_top: 0,
70+ goal: None,
5771 painted: Painted::default(),
5872 tick: 0,
5973 quit: false,
@@ -164,8 +178,10 @@ impl Ui {
164178 &mut self.screen,
165179 self.focus,
166180 self.caret,
181+ self.entry_top,
167182 self.tick,
168183 );
184+ self.entry_top = self.painted.entry_top;
169185 }
170186
171187 /// Put the focus somewhere real. Answers whether it moved.
@@ -199,6 +215,10 @@ impl Ui {
199215 // The caret goes to the end of whatever it just entered, which is where
200216 // someone tabbing into a field with text in it expects to type.
201217 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;
202222 }
203223
204224 fn move_focus(&mut self, forward: bool) {
@@ -312,20 +332,77 @@ impl Ui {
312332 .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 });
313333 }
314334
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+
315361 fn entry_key(&mut self, node: u32, name: &str) -> bool {
316362 let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect();
363+ let multiline = layout::entry_multiline(&self.tree.props(node));
317364 let mut at = self.caret.min(text.len());
318365 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;
319369 match name {
320370 "enter" => {
321371 let now: String = text.iter().collect();
322372 self.tree.emit(node, "activate", now, 0.0);
323373 return true;
324374 }
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+ }
325398 "left" | "ctrl+b" => at = at.saturating_sub(1),
326399 "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(),
329406 "alt+b" => at = keys::word_left(&text, at),
330407 "alt+f" => at = keys::word_right(&text, at),
331408 "backspace" => {
@@ -350,15 +427,22 @@ impl Ui {
350427 }
351428 }
352429 "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;
356434 changed = true;
357435 }
358436 }
359437 "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);
362446 changed = true;
363447 }
364448 }
@@ -381,6 +465,9 @@ impl Ui {
381465 }
382466 }
383467 self.caret = at;
468+ if !keep_goal {
469+ self.goal = None;
470+ }
384471 if changed {
385472 let now: String = text.iter().collect();
386473 self.tree.set(node, "text", Value::Str(now.clone()));
@@ -460,9 +547,16 @@ impl Ui {
460547 }
461548 }
462549 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;
466560 }
467561 _ => {}
468562 }
@@ -9,7 +9,9 @@
9 //! terminal's bytes into those names is [`crate::keys`]'s job, and a caller9 //! 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 where215 // 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 }