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.

Give the terminal's entry a caret that means what it says 361b4dc · on 121e5f1d751e8003ea229e70debf486b9bea3286 · nandi · 9d ago
entry.rs · 333 lines · 11.5 KBRust Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
//! Where the characters of an entry's text land on the cells of its field.
//!
//! One answer, used by everything that has a question about a text field: the
//! painter draws these lines, the caret is drawn at the cell this says it is
//! on, a click is turned back into a character index by asking which line a
//! row is, and up and down are a step through this list. They cannot disagree,
//! because there is only the one layout.
//!
//! It is written in characters rather than in glyphs on purpose: the caret is
//! an index into the text, which is what an edit is made against, and columns
//! are worked out from the characters rather than the other way round. A wide
//! glyph is two cells and one index, and both halves of that are true here.

use crate::screen;

/// One visual row of a field: the half-open range of characters on it.
///
/// A row ends either at a hard newline — the newline itself is not part of the
/// range — or where the wrap fell, in which case the space that was wrapped on
/// *is* part of the range and is not drawn. That is what makes a caret just
/// past a wrapped space belong to the row below rather than hanging off the
/// end of the row above, which is the behaviour every editor has.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Line {
    pub start: usize,
    pub end: usize,
}

/// How a field's text sits in the rect it was given.
#[derive(Clone, Debug)]
pub struct View {
    /// The characters, so callers do not re-collect them.
    pub text: Vec<char>,
    pub lines: Vec<Line>,
    /// The first row painted — a multi-line field taller than its rect scrolls
    /// down to keep the caret on screen.
    pub top: usize,
    /// The first *column* painted on each row. Only a single-line field ever
    /// has one: a field that wraps has nothing to scroll sideways to.
    pub left: usize,
    /// How many rows the rect holds.
    pub rows: usize,
}

fn char_cols(ch: char) -> u16 {
    if ch == '\n' {
        0
    } else {
        screen::glyph_cols(&ch.to_string())
    }
}

/// The columns `text[from..to]` takes.
pub fn cols(text: &[char], from: usize, to: usize) -> u16 {
    text[from.min(text.len())..to.min(text.len())]
        .iter()
        .map(|ch| char_cols(*ch))
        .sum()
}

/// Break one paragraph — a run with no newline in it — into rows of `width`
/// columns, on spaces where it can and mid-word where it must.
fn wrap_paragraph(text: &[char], start: usize, end: usize, width: u16, out: &mut Vec<Line>) {
    let width = width.max(1);
    let mut at = start;
    loop {
        let mut used = 0u16;
        let mut to = at;
        // The character after the last space that fits, which is where the row
        // breaks when a break on a space is possible at all.
        let mut after_space = None;
        while to < end {
            let next = used.saturating_add(char_cols(text[to]));
            if next > width {
                break;
            }
            used = next;
            to += 1;
            if text[to - 1] == ' ' {
                after_space = Some(to);
            }
        }
        if to >= end {
            out.push(Line { start: at, end });
            return;
        }
        // Something is left over, so this row has to break. On the space when
        // there was one — but never on a space that is the whole row, or a
        // paragraph of spaces would never advance.
        let brk = match after_space {
            Some(space) if space > at => space,
            _ => to.max(at + 1),
        };
        out.push(Line {
            start: at,
            end: brk,
        });
        at = brk;
    }
}

/// Lay `text` out in a field `width` columns across.
///
/// A single-line field is one row however long the text is; the sideways
/// scroll in [`View::left`] is what keeps the caret on screen there.
pub fn lines(text: &[char], width: u16, multiline: bool) -> Vec<Line> {
    if !multiline {
        return vec![Line {
            start: 0,
            end: text.len(),
        }];
    }
    let mut out = Vec::new();
    let mut at = 0usize;
    for (i, ch) in text.iter().enumerate() {
        if *ch == '\n' {
            wrap_paragraph(text, at, i, width, &mut out);
            at = i + 1;
        }
    }
    wrap_paragraph(text, at, text.len(), width, &mut out);
    out
}

impl View {
    /// The whole answer for one field: its rows, and how far they are scrolled
    /// so that `caret` is on screen.
    ///
    /// `top` is where the field was scrolled to on the last frame. Keeping it
    /// is what stops a field jumping about: it moves only when the caret has
    /// gone off one end, and then only far enough to bring it back.
    pub fn of(
        text: &str,
        width: u16,
        height: u16,
        multiline: bool,
        caret: usize,
        was_top: usize,
    ) -> Self {
        let chars: Vec<char> = text.chars().collect();
        let width = width.max(1);
        let rows = height.max(1) as usize;
        let lines = lines(&chars, width, multiline);
        let caret = caret.min(chars.len());
        let row = row_of(&lines, caret);
        let last = lines.len().saturating_sub(rows);
        let mut top = was_top.min(last);
        if row < top {
            top = row;
        } else if row >= top + rows {
            top = row + 1 - rows;
        }
        // Sideways, for the one line a single-line field has. The window is a
        // column short of the field so the caret at the end of the text has a
        // cell of its own to sit in.
        let window = width.saturating_sub(1).max(1);
        let mut left = 0usize;
        if !multiline {
            while cols(&chars, left, caret) > window {
                left += 1;
            }
        }
        Self {
            text: chars,
            lines,
            top,
            left,
            rows,
        }
    }

    /// The row `at` is on, and how many columns into it it sits.
    pub fn caret_at(&self, at: usize) -> (usize, u16) {
        let row = row_of(&self.lines, at.min(self.text.len()));
        let from = self.skip(self.lines[row]);
        (row, cols(&self.text, from, at.max(from)))
    }

    /// What is drawn on `row`: its text, with the space a wrap ate left off,
    /// and — on a single-line field — the columns scrolled off the left.
    pub fn painted(&self, row: usize) -> String {
        let Some(line) = self.lines.get(row) else {
            return String::new();
        };
        let end = self.line_end(row);
        let from = self.skip(*line).min(end);
        self.text[from..end].iter().collect()
    }

    /// The character a click on this cell of the field means.
    ///
    /// `x` and `y` are relative to the field's own top-left corner. A click
    /// below the last row of text is the end of the text and a click past the
    /// end of a row is the end of that row, which is what makes clicking into
    /// the empty half of a half-full field land somewhere sensible.
    pub fn hit(&self, x: u16, y: u16) -> usize {
        let row = self.top + y as usize;
        if row >= self.lines.len() {
            return self.text.len();
        }
        let line = self.lines[row];
        let mut at = self.skip(line);
        let mut used = 0u16;
        while at < line.end {
            let w = char_cols(self.text[at]);
            if used + w > x {
                break;
            }
            used += w;
            at += 1;
        }
        // Not onto the space a wrap ate: that cell is the end of this row, and
        // the character after it starts the next one.
        self.line_end(row).min(at)
    }

    /// The first character drawn on a row — the sideways scroll, which only a
    /// single-line field has.
    fn skip(&self, line: Line) -> usize {
        line.start.max(self.left.min(line.end))
    }

    /// The first and last character of the row `at` is on — what Home and End
    /// mean in a box of text, and what a kill to the start or the end of the
    /// line takes.
    pub fn caret_row(&self, at: usize) -> (usize, usize) {
        let row = row_of(&self.lines, at.min(self.text.len()));
        (self.lines[row].start, self.line_end(row))
    }

    /// Where the text on `row` ends, not counting the space a wrap ate.
    pub fn line_end(&self, row: usize) -> usize {
        let Some(line) = self.lines.get(row) else {
            return self.text.len();
        };
        let wrapped = row + 1 < self.lines.len() && self.lines[row + 1].start == line.end;
        if wrapped && line.end > line.start && self.text[line.end - 1] == ' ' {
            line.end - 1
        } else {
            line.end
        }
    }

    /// The character at `col` columns into `row` — how up and down keep their
    /// place across rows of different lengths.
    pub fn at_col(&self, row: usize, col: u16) -> usize {
        let row = row.min(self.lines.len().saturating_sub(1));
        let line = self.lines[row];
        let mut at = line.start;
        let mut used = 0u16;
        while at < line.end && used < col {
            used += char_cols(self.text[at]);
            at += 1;
        }
        self.line_end(row).min(at)
    }
}

/// Which row holds `at`.
///
/// The last row that starts at or before it, so a caret exactly on a break
/// belongs to the row below — which is where the next character typed goes.
pub fn row_of(lines: &[Line], at: usize) -> usize {
    lines.iter().rposition(|line| line.start <= at).unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn view(text: &str, width: u16, height: u16, caret: usize) -> View {
        View::of(text, width, height, true, caret, 0)
    }

    #[test]
    fn a_wrapped_row_ends_after_the_space_it_broke_on() {
        let v = view("one two three", 8, 3, 0);
        assert_eq!(v.painted(0), "one two");
        assert_eq!(v.painted(1), "three");
        // The space is on the first row's range but is not drawn, so a caret
        // just past it is at the start of the second row.
        assert_eq!(v.caret_at(8), (1, 0));
    }

    #[test]
    fn a_newline_is_a_row_of_its_own_even_when_it_is_empty() {
        let v = view("a\n\nb", 8, 4, 0);
        assert_eq!(v.lines.len(), 3);
        assert_eq!(v.painted(1), "");
        assert_eq!(v.caret_at(2), (1, 0));
        assert_eq!(v.caret_at(3), (2, 0));
    }

    #[test]
    fn a_click_lands_on_the_character_under_it() {
        let v = view("one two three", 8, 3, 0);
        assert_eq!(v.hit(0, 0), 0);
        assert_eq!(v.hit(4, 0), 4);
        // Past the end of a row is the end of that row, not the row below.
        assert_eq!(v.hit(20, 0), 7);
        assert_eq!(v.hit(2, 1), 10);
        // Below the text is the end of it.
        assert_eq!(v.hit(0, 7), 13);
    }

    #[test]
    fn a_caret_off_the_bottom_scrolls_the_field_by_one_row() {
        let text = "a\nb\nc\nd";
        let v = View::of(text, 8, 2, true, 6, 0);
        assert_eq!(v.top, 2, "two rows of four, with the caret on the last");
        // And it stays where it was while the caret is still on screen.
        let v = View::of(text, 8, 2, true, 4, 2);
        assert_eq!(v.top, 2);
    }

    #[test]
    fn a_single_line_field_scrolls_sideways_to_the_caret() {
        let v = View::of("abcdefghij", 5, 1, false, 10, 0);
        assert_eq!(v.lines.len(), 1);
        assert_eq!(v.painted(0), "ghij");
        assert_eq!(v.caret_at(10), (0, 4));
        assert_eq!(v.hit(0, 0), 6);
    }

    #[test]
    fn a_wide_glyph_is_two_cells_and_one_character() {
        let v = view("a😀b", 8, 2, 0);
        assert_eq!(v.caret_at(2), (0, 3));
        assert_eq!(v.hit(1, 0), 1);
        assert_eq!(v.hit(2, 0), 1, "the second cell is still the emoji");
        assert_eq!(v.hit(3, 0), 2);
    }
}