nandi/jolt-nativepublic Fork 0
121e5f1d751e8003ea229e70debf486b9bea3286
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.

entry.rs · 333 lines · 11.5 KBRust Blame HistoryRaw
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago1//! 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
14use 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)]
24pub 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)]
31pub 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
45fn 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.
54pub 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.
63fn 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.
106pub 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
125impl 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.
263pub fn row_of(lines: &[Line], at: usize) -> usize {
264 lines.iter().rposition(|line| line.start <= at).unwrap_or(0)
265}
266
267#[cfg(test)]
268mod 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}