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

Draw a picture in a terminal, over the Kitty graphics protocol c2d912f · on 7b6f7013392de8fe2e8c3c36d5c0182974b1a2c4 · nandi · 16d ago
ui.rs · 566 lines · 21.3 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
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
//! The session: a tree, a screen, and where the focus and the caret are.
//!
//! This is the whole backend minus the terminal. It paints into a grid, takes
//! keys and clicks by name, and answers events — so the entire widget layer,
//! keyboard navigation included, runs in a test with no TTY, no raw mode and no
//! display. `tui_headless` opens exactly this and nothing else.
//!
//! Keys arrive already named (`"ctrl+u"`, `"page-down"`, `"a"`); turning a
//! terminal's bytes into those names is [`crate::keys`]'s job, and a caller
//! synthesising one for a test writes the name directly.

use crate::keys;
use crate::paint::{self, Painted};
use crate::screen::Screen;
use crate::tree::{Tag, Tree, Value};
use std::collections::HashMap;

/// Where one scroll area is, and whether it is following its own bottom.
///
/// It lives here rather than in the tree because a re-render clears a node's
/// props: glimmer writes what the component said and nothing else, which is
/// right — the component's state is the truth — and it means a viewport that
/// kept its position in a prop loses it the moment anything above it changes.
/// A chat backlog changes on every message, which is exactly when a reader
/// cares where they were.
#[derive(Clone, Copy)]
struct Scrolled {
    offset: u16,
    /// Following the bottom. A `:stick-to-bottom` viewport starts this way,
    /// stops when the reader scrolls up, and starts again when they come back
    /// down — which is the behaviour that lets a new message arrive without
    /// dragging the screen out from under someone reading history.
    pinned: bool,
}

pub struct Ui {
    pub tree: Tree,
    pub screen: Screen,
    /// The node the focus ring is on, 0 for none.
    focus: u32,
    /// The caret in the focused entry, in characters from the start.
    caret: usize,
    painted: Painted,
    tick: u64,
    quit: bool,
    /// Scroll positions by `:scroll-key`, across re-renders.
    scrolls: HashMap<String, Scrolled>,
}

impl Ui {
    pub fn new(width: u16, height: u16) -> Self {
        Self {
            tree: Tree::new(),
            screen: Screen::new(width.max(1), height.max(1)),
            focus: 0,
            caret: 0,
            painted: Painted::default(),
            tick: 0,
            quit: false,
            scrolls: HashMap::new(),
        }
    }

    pub fn resize(&mut self, width: u16, height: u16) {
        self.screen.resize(width.max(1), height.max(1));
    }

    pub fn should_close(&self) -> bool {
        self.quit
    }

    pub fn quit(&mut self) {
        self.quit = true;
    }

    pub fn focus(&self) -> u32 {
        self.focus
    }

    pub fn cursor(&self) -> Option<(u16, u16)> {
        self.painted.cursor
    }

    /// The pictures the last frame wants on screen, for whoever can draw one.
    pub fn images(&self) -> &[crate::graphics::Placement] {
        &self.painted.images
    }

    /// Paint one frame, then settle the things painting decided: what is
    /// focusable now, and how far each scroll area really is.
    pub fn frame(&mut self) {
        self.tick = self.tick.wrapping_add(1);
        self.restore_scrolls(self.tree.root());
        self.paint_once();
        if self.settle_focus() {
            // Focus is decided by what the paint found, so the frame that
            // gives it away has to be drawn again — otherwise the first frame
            // of a screen shows nothing focused and the second one does.
            self.paint_once();
        }
        for (node, offset, max, _area) in self.painted.scrolled.clone() {
            // Painting clamps the viewport to the content; write the clamped
            // value back so the caller's next `+1` starts from the truth.
            if self.tree.props(node).cells("offset", 0) != offset {
                self.tree.set(node, "offset", Value::Num(offset as f64));
            }
            // And remember it under its key, which is what survives the
            // re-render that is about to clear the prop. Being at the bottom
            // is what pins it there: a reader who scrolls back down has said
            // they want to follow again, and never has to say so twice.
            let key = self.scroll_key(node);
            let sticky = self.tree.props(node).bool("stick-to-bottom", false);
            self.scrolls.insert(
                key,
                Scrolled {
                    offset,
                    pinned: sticky && offset >= max,
                },
            );
        }
    }

    /// What a scroll area is remembered by. Its `:scroll-key` when it has one,
    /// because that is a name the caller chose and means the same viewport
    /// after a re-mount; its handle otherwise, which at least survives a
    /// re-render that leaves the node where it was.
    fn scroll_key(&self, node: u32) -> String {
        let props = self.tree.props(node);
        let key = props.str("scroll-key");
        if key.is_empty() {
            format!("#{node}")
        } else {
            key.to_owned()
        }
    }

    /// Put every scroll area back where it was before the tree is painted.
    ///
    /// A pinned one is asked for an offset past the end and painting clamps it
    /// to the bottom, which is how it follows content that grew since the last
    /// frame without this having to measure anything.
    fn restore_scrolls(&mut self, id: u32) {
        if matches!(self.tree.tag(id), Tag::Scroll) {
            let key = self.scroll_key(id);
            let sticky = self.tree.props(id).bool("stick-to-bottom", false);
            let to = match self.scrolls.get(&key) {
                Some(state) if sticky && state.pinned => u16::MAX,
                Some(state) => state.offset,
                // Never seen: a sticky viewport opens at the bottom, which for
                // a backlog is the message that just arrived.
                None if sticky => u16::MAX,
                None => return,
            };
            self.tree.set(id, "offset", Value::Num(to as f64));
        }
        for child in self.tree.children(id) {
            self.restore_scrolls(child);
        }
    }

    fn paint_once(&mut self) {
        self.painted = paint::frame(
            &self.tree,
            &mut self.screen,
            self.focus,
            self.caret,
            self.tick,
        );
    }

    /// Put the focus somewhere real. Answers whether it moved.
    fn settle_focus(&mut self) -> bool {
        let was = self.focus;
        // A focused widget that has since been unmounted — or dimmed — leaves
        // the ring, and focus lands on the first thing that is still there
        // rather than on nothing.
        if self.focus != 0 && !self.painted.ring.contains(&self.focus) {
            self.focus = 0;
        }
        if self.focus == 0 {
            let wants = self
                .painted
                .ring
                .iter()
                .find(|id| self.tree.props(**id).bool("autofocus", false))
                .copied();
            if let Some(id) = wants.or_else(|| self.painted.ring.first().copied()) {
                self.set_focus(id);
            }
        }
        self.focus != was
    }

    fn set_focus(&mut self, id: u32) {
        if self.focus == id {
            return;
        }
        self.focus = id;
        // The caret goes to the end of whatever it just entered, which is where
        // someone tabbing into a field with text in it expects to type.
        self.caret = self.tree.props(id).str("text").chars().count();
    }

    fn move_focus(&mut self, forward: bool) {
        if self.painted.ring.is_empty() {
            return;
        }
        let ring = self.painted.ring.clone();
        let at = ring.iter().position(|id| *id == self.focus);
        let next = match (at, forward) {
            (Some(i), true) => (i + 1) % ring.len(),
            (Some(i), false) => (i + ring.len() - 1) % ring.len(),
            (None, true) => 0,
            (None, false) => ring.len() - 1,
        };
        self.set_focus(ring[next]);
    }

    // ── keys ────────────────────────────────────────────────────────────────

    /// Handle one key by name. Answers false when nothing here wanted it, in
    /// which case it has been emitted as a `key` event for the caller to route.
    pub fn key(&mut self, name: &str) -> bool {
        if matches!(name, "ctrl+c" | "ctrl+q") {
            self.quit = true;
            return true;
        }
        match name {
            "tab" => {
                self.move_focus(true);
                return true;
            }
            "shift+tab" | "backtab" => {
                self.move_focus(false);
                return true;
            }
            // Before the focused widget is asked: a page is about the screen
            // rather than about whatever is being typed into, and an entry
            // that ignored these left them going out as an event nobody has a
            // handler for.
            "page-up" | "page-down" => {
                if self.page(name == "page-up") {
                    return true;
                }
            }
            "esc" => {
                // Esc belongs to the topmost overlay when there is one: that is
                // what closes a modal everywhere else.
                if let Some(overlay) = self.topmost_overlay() {
                    self.tree.emit(overlay, "close", String::new(), 0.0);
                    return true;
                }
            }
            _ => {}
        }

        let focus = self.focus;
        let handled = match self.tree.tag(focus) {
            Tag::Entry => self.entry_key(focus, name),
            Tag::Button | Tag::Reaction => self.activate_key(focus, name, "click"),
            Tag::CheckButton => {
                if matches!(name, "enter" | "space") {
                    self.toggle(focus);
                    true
                } else {
                    false
                }
            }
            Tag::Listbox => self.listbox_key(focus, name),
            _ => false,
        };
        if !handled {
            // Unhandled keys go to the caller as an event on the focused node,
            // or on the window when nothing has focus. glimmer bubbles from
            // there; it holds the handlers and knows the tree.
            let target = if focus != 0 { focus } else { self.tree.root() };
            self.tree.emit(target, "key", name.to_owned(), 0.0);
        }
        handled
    }

    fn topmost_overlay(&self) -> Option<u32> {
        fn walk(tree: &Tree, id: u32, found: &mut Option<u32>) {
            if matches!(tree.tag(id), Tag::Overlay) {
                *found = Some(id);
            }
            for child in tree.children(id) {
                walk(tree, child, found);
            }
        }
        let mut found = None;
        walk(&self.tree, self.tree.root(), &mut found);
        found
    }

    fn activate_key(&mut self, node: u32, name: &str, event: &'static str) -> bool {
        if matches!(name, "enter" | "space") {
            self.tree.emit(node, event, String::new(), 0.0);
            true
        } else {
            false
        }
    }

    fn toggle(&mut self, node: u32) {
        let now = !self.tree.props(node).bool("active", false);
        // The widget does not own its value, but it does keep working when the
        // caller ignores the event: the new state is written back here, and the
        // next prop write from the reconciler is what settles it.
        self.tree.set(node, "active", Value::Bool(now));
        self.tree
            .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 });
    }

    fn entry_key(&mut self, node: u32, name: &str) -> bool {
        let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect();
        let mut at = self.caret.min(text.len());
        let mut changed = false;
        match name {
            "enter" => {
                let now: String = text.iter().collect();
                self.tree.emit(node, "activate", now, 0.0);
                return true;
            }
            "left" | "ctrl+b" => at = at.saturating_sub(1),
            "right" | "ctrl+f" => at = (at + 1).min(text.len()),
            "home" | "ctrl+a" => at = 0,
            "end" | "ctrl+e" => at = text.len(),
            "alt+b" => at = keys::word_left(&text, at),
            "alt+f" => at = keys::word_right(&text, at),
            "backspace" => {
                if at > 0 {
                    text.remove(at - 1);
                    at -= 1;
                    changed = true;
                }
            }
            "delete" | "ctrl+d" => {
                if at < text.len() {
                    text.remove(at);
                    changed = true;
                }
            }
            "ctrl+w" | "alt+backspace" => {
                let from = keys::word_left(&text, at);
                if from < at {
                    text.drain(from..at);
                    at = from;
                    changed = true;
                }
            }
            "ctrl+u" => {
                if at > 0 {
                    text.drain(0..at);
                    at = 0;
                    changed = true;
                }
            }
            "ctrl+k" => {
                if at < text.len() {
                    text.truncate(at);
                    changed = true;
                }
            }
            "space" => {
                text.insert(at, ' ');
                at += 1;
                changed = true;
            }
            other => {
                // A single character with no modifier on it is text.
                let mut chars = other.chars();
                match (chars.next(), chars.next()) {
                    (Some(ch), None) if !ch.is_control() => {
                        text.insert(at, ch);
                        at += 1;
                        changed = true;
                    }
                    _ => return false,
                }
            }
        }
        self.caret = at;
        if changed {
            let now: String = text.iter().collect();
            self.tree.set(node, "text", Value::Str(now.clone()));
            self.tree.emit(node, "change", now, 0.0);
        }
        true
    }

    fn listbox_key(&mut self, node: u32, name: &str) -> bool {
        let count = self.tree.child_count(node) as i64;
        if count == 0 {
            return false;
        }
        let page = self
            .painted
            .hits
            .iter()
            .find(|(id, _)| *id == node)
            .map_or(1, |(_, rect)| rect.h.max(1) as i64);
        let at = self.tree.props(node).num("selected", 0.0) as i64;
        let to = match name {
            "down" | "j" | "ctrl+n" => at + 1,
            "up" | "k" | "ctrl+p" => at - 1,
            "page-down" | "ctrl+d" => at + page,
            "page-up" | "ctrl+u" => at - page,
            "home" | "g" => 0,
            "end" | "G" => count - 1,
            "enter" | "space" => {
                let index = at.clamp(0, count - 1);
                let item = self.tree.child_at(node, index as usize);
                let label = self.tree.props(item).label().to_owned();
                self.tree.emit(node, "activate", label, index as f64);
                return true;
            }
            _ => return false,
        };
        self.select(node, to.clamp(0, count - 1));
        true
    }

    fn select(&mut self, node: u32, index: i64) {
        if self.tree.props(node).num("selected", -1.0) as i64 == index {
            return;
        }
        self.tree.set(node, "selected", Value::Num(index as f64));
        let item = self.tree.child_at(node, index as usize);
        let label = self.tree.props(item).label().to_owned();
        self.tree.emit(node, "select", label, index as f64);
    }

    // ── mouse ───────────────────────────────────────────────────────────────

    /// A click at a cell. Focuses whatever is under it and activates it, which
    /// is the whole of button 1 in a terminal: there is no press and release to
    /// tell apart at this level.
    pub fn click(&mut self, x: u16, y: u16) -> bool {
        let Some((node, rect)) = self
            .painted
            .hits
            .iter()
            .find(|(_, rect)| rect.contains(x, y))
            .copied()
        else {
            return false;
        };
        self.set_focus(node);
        match self.tree.tag(node) {
            // A pill is pressed the way a button is: the caller's `:on-click`
            // is what puts a reaction on or takes it off again.
            Tag::Button | Tag::Reaction => self.tree.emit(node, "click", String::new(), 0.0),
            Tag::CheckButton => self.toggle(node),
            Tag::Listbox => {
                let row = (y - rect.y) as i64;
                let count = self.tree.child_count(node) as i64;
                if count > 0 {
                    self.select(node, row.clamp(0, count - 1));
                }
            }
            Tag::Entry => {
                // Put the caret where it was clicked, not at the end.
                let text = self.tree.props(node).str("text").chars().count();
                self.caret = ((x - rect.x) as usize).min(text);
            }
            _ => {}
        }
        true
    }

    /// The wheel, `by` rows — negative is up. It moves the innermost `:scroll`
    /// under the pointer, which is the one a reader means.
    pub fn wheel(&mut self, x: u16, y: u16, by: i32) -> bool {
        let Some(node) = self.scroll_at(self.tree.root(), x, y) else {
            return false;
        };
        self.scroll_by(node, by)
    }

    /// Page-up and page-down, for a reader with no pointer to point with.
    ///
    /// A terminal has no scrollbar to drag and the wheel is not on every desk,
    /// so these are the way back through a backlog; a page is the viewport
    /// less a row, which is the line that says where you were.
    ///
    /// The page belongs to the biggest thing on screen. There is no pointer to
    /// aim with and the focus is rarely inside the list — in frq it is the
    /// compose entry, under a backlog nobody would call the smaller half of
    /// the screen — so area is the question, and the reading list wins it.
    fn page(&mut self, up: bool) -> bool {
        let Some((node, height)) = self
            .painted
            .scrolled
            .iter()
            .max_by_key(|(_, _, _, area)| (area.w as u32) * (area.h as u32))
            .map(|(node, _, _, area)| (*node, area.h))
        else {
            return false;
        };
        let rows = height.saturating_sub(1).max(1) as i32;
        self.scroll_by(node, if up { -rows } else { rows })
    }

    /// Move one `:scroll` by `by` rows, and remember where that put it.
    ///
    /// Where it is now comes from what was remembered under its key rather
    /// than from the node, because the caller may have re-rendered it since
    /// the last frame: a render clears a node's props and sets them again, and
    /// a scroll caught between the two reads as an offset of zero. That is how
    /// a page-down a moment after a message arrived answered with the top of
    /// the buffer — it was a page down from a list that had forgotten where it
    /// was. The prop is still written, for the paint that is about to read it.
    fn scroll_by(&mut self, node: u32, by: i32) -> bool {
        let key = self.scroll_key(node);
        let now = self
            .scrolls
            .get(&key)
            .map(|state| state.offset as i32)
            .unwrap_or_else(|| self.tree.props(node).cells("offset", 0) as i32);
        let to = (now + by).max(0) as f64;
        self.tree.set(node, "offset", Value::Num(to));
        // Unpin on the way up, and let the next frame decide whether this put
        // the reader back at the bottom — painting is what knows how far down
        // that is.
        self.scrolls.insert(
            key,
            Scrolled {
                offset: to as u16,
                pinned: false,
            },
        );
        self.tree.emit(node, "scroll", String::new(), to);
        true
    }

    /// The innermost `:scroll` whose painted area holds this cell.
    ///
    /// Its own area, not the screen's. Asking whether the pointer was anywhere
    /// on the terminal is a question every scroll answers yes to, so the first
    /// one the walk reached took every wheel: on a wide screen that is the
    /// chats list, and a reader wheeling over the conversation beside it moved
    /// the sidebar instead — which reads as a backlog that will not scroll.
    fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> {
        for child in self.tree.children(id) {
            if let Some(inner) = self.scroll_at(child, x, y) {
                return Some(inner);
            }
        }
        // Scroll areas take no focus, so they are not in the hit list; the
        // frame records the ones it painted, and where, which is enough for a
        // wheel.
        let painted = self
            .painted
            .scrolled
            .iter()
            .find(|(n, _, _, _)| *n == id)
            .map(|(_, _, _, area)| *area);
        if let Some(area) = painted {
            if matches!(self.tree.tag(id), Tag::Scroll) && area.contains(x, y) {
                return Some(id);
            }
        }
        None
    }
}