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

Let a backlog keep its place, and leave room for what is under it 68910bd · on 258bbc5161b93d580a4c84363acabe63b0624e88 · nandi · 17d ago
ui.rs · 496 lines · 18.1 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
//! 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
    }

    /// 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) 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;
            }
            "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 => 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) {
            Tag::Button => 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;
        };
        let now = 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.
        let key = self.scroll_key(node);
        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.
    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, which is enough for a wheel.
        let painted = self.painted.scrolled.iter().any(|(n, _, _)| *n == id);
        if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y)
        {
            return Some(id);
        }
        None
    }
}