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

ui.rs · 561 lines · 21.1 KBRust Blame HistoryRaw
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago1//! The session: a tree, a screen, and where the focus and the caret are.
2//!
3//! This is the whole backend minus the terminal. It paints into a grid, takes
4//! keys and clicks by name, and answers events — so the entire widget layer,
5//! keyboard navigation included, runs in a test with no TTY, no raw mode and no
6//! display. `tui_headless` opens exactly this and nothing else.
7//!
8//! Keys arrive already named (`"ctrl+u"`, `"page-down"`, `"a"`); turning a
9//! terminal's bytes into those names is [`crate::keys`]'s job, and a caller
10//! synthesising one for a test writes the name directly.
11
12use crate::keys;
13use crate::paint::{self, Painted};
14use crate::screen::Screen;
15use crate::tree::{Tag, Tree, Value};
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago16use std::collections::HashMap;
17
18/// Where one scroll area is, and whether it is following its own bottom.
19///
20/// It lives here rather than in the tree because a re-render clears a node's
21/// props: glimmer writes what the component said and nothing else, which is
22/// right — the component's state is the truth — and it means a viewport that
23/// kept its position in a prop loses it the moment anything above it changes.
24/// A chat backlog changes on every message, which is exactly when a reader
25/// cares where they were.
26#[derive(Clone, Copy)]
27struct Scrolled {
28 offset: u16,
29 /// Following the bottom. A `:stick-to-bottom` viewport starts this way,
30 /// stops when the reader scrolls up, and starts again when they come back
31 /// down — which is the behaviour that lets a new message arrive without
32 /// dragging the screen out from under someone reading history.
33 pinned: bool,
34}
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago35
36pub struct Ui {
37 pub tree: Tree,
38 pub screen: Screen,
39 /// The node the focus ring is on, 0 for none.
40 focus: u32,
41 /// The caret in the focused entry, in characters from the start.
42 caret: usize,
43 painted: Painted,
44 tick: u64,
45 quit: bool,
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago46 /// Scroll positions by `:scroll-key`, across re-renders.
47 scrolls: HashMap<String, Scrolled>,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago48}
49
50impl Ui {
51 pub fn new(width: u16, height: u16) -> Self {
52 Self {
53 tree: Tree::new(),
54 screen: Screen::new(width.max(1), height.max(1)),
55 focus: 0,
56 caret: 0,
57 painted: Painted::default(),
58 tick: 0,
59 quit: false,
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago60 scrolls: HashMap::new(),
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago61 }
62 }
63
64 pub fn resize(&mut self, width: u16, height: u16) {
65 self.screen.resize(width.max(1), height.max(1));
66 }
67
68 pub fn should_close(&self) -> bool {
69 self.quit
70 }
71
72 pub fn quit(&mut self) {
73 self.quit = true;
74 }
75
76 pub fn focus(&self) -> u32 {
77 self.focus
78 }
79
80 pub fn cursor(&self) -> Option<(u16, u16)> {
81 self.painted.cursor
82 }
83
84 /// Paint one frame, then settle the things painting decided: what is
85 /// focusable now, and how far each scroll area really is.
86 pub fn frame(&mut self) {
87 self.tick = self.tick.wrapping_add(1);
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago88 self.restore_scrolls(self.tree.root());
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago89 self.paint_once();
90 if self.settle_focus() {
91 // Focus is decided by what the paint found, so the frame that
92 // gives it away has to be drawn again — otherwise the first frame
93 // of a screen shows nothing focused and the second one does.
94 self.paint_once();
95 }
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago96 for (node, offset, max, _area) in self.painted.scrolled.clone() {
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago97 // Painting clamps the viewport to the content; write the clamped
98 // value back so the caller's next `+1` starts from the truth.
99 if self.tree.props(node).cells("offset", 0) != offset {
100 self.tree.set(node, "offset", Value::Num(offset as f64));
101 }
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago102 // And remember it under its key, which is what survives the
103 // re-render that is about to clear the prop. Being at the bottom
104 // is what pins it there: a reader who scrolls back down has said
105 // they want to follow again, and never has to say so twice.
106 let key = self.scroll_key(node);
107 let sticky = self.tree.props(node).bool("stick-to-bottom", false);
108 self.scrolls.insert(
109 key,
110 Scrolled {
111 offset,
112 pinned: sticky && offset >= max,
113 },
114 );
115 }
116 }
117
118 /// What a scroll area is remembered by. Its `:scroll-key` when it has one,
119 /// because that is a name the caller chose and means the same viewport
120 /// after a re-mount; its handle otherwise, which at least survives a
121 /// re-render that leaves the node where it was.
122 fn scroll_key(&self, node: u32) -> String {
123 let props = self.tree.props(node);
124 let key = props.str("scroll-key");
125 if key.is_empty() {
126 format!("#{node}")
127 } else {
128 key.to_owned()
129 }
130 }
131
132 /// Put every scroll area back where it was before the tree is painted.
133 ///
134 /// A pinned one is asked for an offset past the end and painting clamps it
135 /// to the bottom, which is how it follows content that grew since the last
136 /// frame without this having to measure anything.
137 fn restore_scrolls(&mut self, id: u32) {
138 if matches!(self.tree.tag(id), Tag::Scroll) {
139 let key = self.scroll_key(id);
140 let sticky = self.tree.props(id).bool("stick-to-bottom", false);
141 let to = match self.scrolls.get(&key) {
142 Some(state) if sticky && state.pinned => u16::MAX,
143 Some(state) => state.offset,
144 // Never seen: a sticky viewport opens at the bottom, which for
145 // a backlog is the message that just arrived.
146 None if sticky => u16::MAX,
147 None => return,
148 };
149 self.tree.set(id, "offset", Value::Num(to as f64));
150 }
151 for child in self.tree.children(id) {
152 self.restore_scrolls(child);
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago153 }
154 }
155
156 fn paint_once(&mut self) {
157 self.painted = paint::frame(
158 &self.tree,
159 &mut self.screen,
160 self.focus,
161 self.caret,
162 self.tick,
163 );
164 }
165
166 /// Put the focus somewhere real. Answers whether it moved.
167 fn settle_focus(&mut self) -> bool {
168 let was = self.focus;
169 // A focused widget that has since been unmounted — or dimmed — leaves
170 // the ring, and focus lands on the first thing that is still there
171 // rather than on nothing.
172 if self.focus != 0 && !self.painted.ring.contains(&self.focus) {
173 self.focus = 0;
174 }
175 if self.focus == 0 {
176 let wants = self
177 .painted
178 .ring
179 .iter()
180 .find(|id| self.tree.props(**id).bool("autofocus", false))
181 .copied();
182 if let Some(id) = wants.or_else(|| self.painted.ring.first().copied()) {
183 self.set_focus(id);
184 }
185 }
186 self.focus != was
187 }
188
189 fn set_focus(&mut self, id: u32) {
190 if self.focus == id {
191 return;
192 }
193 self.focus = id;
194 // The caret goes to the end of whatever it just entered, which is where
195 // someone tabbing into a field with text in it expects to type.
196 self.caret = self.tree.props(id).str("text").chars().count();
197 }
198
199 fn move_focus(&mut self, forward: bool) {
200 if self.painted.ring.is_empty() {
201 return;
202 }
203 let ring = self.painted.ring.clone();
204 let at = ring.iter().position(|id| *id == self.focus);
205 let next = match (at, forward) {
206 (Some(i), true) => (i + 1) % ring.len(),
207 (Some(i), false) => (i + ring.len() - 1) % ring.len(),
208 (None, true) => 0,
209 (None, false) => ring.len() - 1,
210 };
211 self.set_focus(ring[next]);
212 }
213
214 // ── keys ────────────────────────────────────────────────────────────────
215
216 /// Handle one key by name. Answers false when nothing here wanted it, in
217 /// which case it has been emitted as a `key` event for the caller to route.
218 pub fn key(&mut self, name: &str) -> bool {
219 if matches!(name, "ctrl+c" | "ctrl+q") {
220 self.quit = true;
221 return true;
222 }
223 match name {
224 "tab" => {
225 self.move_focus(true);
226 return true;
227 }
228 "shift+tab" | "backtab" => {
229 self.move_focus(false);
230 return true;
231 }
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago232 // Before the focused widget is asked: a page is about the screen
233 // rather than about whatever is being typed into, and an entry
234 // that ignored these left them going out as an event nobody has a
235 // handler for.
236 "page-up" | "page-down" => {
237 if self.page(name == "page-up") {
238 return true;
239 }
240 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago241 "esc" => {
242 // Esc belongs to the topmost overlay when there is one: that is
243 // what closes a modal everywhere else.
244 if let Some(overlay) = self.topmost_overlay() {
245 self.tree.emit(overlay, "close", String::new(), 0.0);
246 return true;
247 }
248 }
249 _ => {}
250 }
251
252 let focus = self.focus;
253 let handled = match self.tree.tag(focus) {
254 Tag::Entry => self.entry_key(focus, name),
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago255 Tag::Button | Tag::Reaction => self.activate_key(focus, name, "click"),
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago256 Tag::CheckButton => {
257 if matches!(name, "enter" | "space") {
258 self.toggle(focus);
259 true
260 } else {
261 false
262 }
263 }
264 Tag::Listbox => self.listbox_key(focus, name),
265 _ => false,
266 };
267 if !handled {
268 // Unhandled keys go to the caller as an event on the focused node,
269 // or on the window when nothing has focus. glimmer bubbles from
270 // there; it holds the handlers and knows the tree.
271 let target = if focus != 0 { focus } else { self.tree.root() };
272 self.tree.emit(target, "key", name.to_owned(), 0.0);
273 }
274 handled
275 }
276
277 fn topmost_overlay(&self) -> Option<u32> {
278 fn walk(tree: &Tree, id: u32, found: &mut Option<u32>) {
279 if matches!(tree.tag(id), Tag::Overlay) {
280 *found = Some(id);
281 }
282 for child in tree.children(id) {
283 walk(tree, child, found);
284 }
285 }
286 let mut found = None;
287 walk(&self.tree, self.tree.root(), &mut found);
288 found
289 }
290
291 fn activate_key(&mut self, node: u32, name: &str, event: &'static str) -> bool {
292 if matches!(name, "enter" | "space") {
293 self.tree.emit(node, event, String::new(), 0.0);
294 true
295 } else {
296 false
297 }
298 }
299
300 fn toggle(&mut self, node: u32) {
301 let now = !self.tree.props(node).bool("active", false);
302 // The widget does not own its value, but it does keep working when the
303 // caller ignores the event: the new state is written back here, and the
304 // next prop write from the reconciler is what settles it.
305 self.tree.set(node, "active", Value::Bool(now));
306 self.tree
307 .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 });
308 }
309
310 fn entry_key(&mut self, node: u32, name: &str) -> bool {
311 let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect();
312 let mut at = self.caret.min(text.len());
313 let mut changed = false;
314 match name {
315 "enter" => {
316 let now: String = text.iter().collect();
317 self.tree.emit(node, "activate", now, 0.0);
318 return true;
319 }
320 "left" | "ctrl+b" => at = at.saturating_sub(1),
321 "right" | "ctrl+f" => at = (at + 1).min(text.len()),
322 "home" | "ctrl+a" => at = 0,
323 "end" | "ctrl+e" => at = text.len(),
324 "alt+b" => at = keys::word_left(&text, at),
325 "alt+f" => at = keys::word_right(&text, at),
326 "backspace" => {
327 if at > 0 {
328 text.remove(at - 1);
329 at -= 1;
330 changed = true;
331 }
332 }
333 "delete" | "ctrl+d" => {
334 if at < text.len() {
335 text.remove(at);
336 changed = true;
337 }
338 }
339 "ctrl+w" | "alt+backspace" => {
340 let from = keys::word_left(&text, at);
341 if from < at {
342 text.drain(from..at);
343 at = from;
344 changed = true;
345 }
346 }
347 "ctrl+u" => {
348 if at > 0 {
349 text.drain(0..at);
350 at = 0;
351 changed = true;
352 }
353 }
354 "ctrl+k" => {
355 if at < text.len() {
356 text.truncate(at);
357 changed = true;
358 }
359 }
360 "space" => {
361 text.insert(at, ' ');
362 at += 1;
363 changed = true;
364 }
365 other => {
366 // A single character with no modifier on it is text.
367 let mut chars = other.chars();
368 match (chars.next(), chars.next()) {
369 (Some(ch), None) if !ch.is_control() => {
370 text.insert(at, ch);
371 at += 1;
372 changed = true;
373 }
374 _ => return false,
375 }
376 }
377 }
378 self.caret = at;
379 if changed {
380 let now: String = text.iter().collect();
381 self.tree.set(node, "text", Value::Str(now.clone()));
382 self.tree.emit(node, "change", now, 0.0);
383 }
384 true
385 }
386
387 fn listbox_key(&mut self, node: u32, name: &str) -> bool {
388 let count = self.tree.child_count(node) as i64;
389 if count == 0 {
390 return false;
391 }
392 let page = self
393 .painted
394 .hits
395 .iter()
396 .find(|(id, _)| *id == node)
397 .map_or(1, |(_, rect)| rect.h.max(1) as i64);
398 let at = self.tree.props(node).num("selected", 0.0) as i64;
399 let to = match name {
400 "down" | "j" | "ctrl+n" => at + 1,
401 "up" | "k" | "ctrl+p" => at - 1,
402 "page-down" | "ctrl+d" => at + page,
403 "page-up" | "ctrl+u" => at - page,
404 "home" | "g" => 0,
405 "end" | "G" => count - 1,
406 "enter" | "space" => {
407 let index = at.clamp(0, count - 1);
408 let item = self.tree.child_at(node, index as usize);
409 let label = self.tree.props(item).label().to_owned();
410 self.tree.emit(node, "activate", label, index as f64);
411 return true;
412 }
413 _ => return false,
414 };
415 self.select(node, to.clamp(0, count - 1));
416 true
417 }
418
419 fn select(&mut self, node: u32, index: i64) {
420 if self.tree.props(node).num("selected", -1.0) as i64 == index {
421 return;
422 }
423 self.tree.set(node, "selected", Value::Num(index as f64));
424 let item = self.tree.child_at(node, index as usize);
425 let label = self.tree.props(item).label().to_owned();
426 self.tree.emit(node, "select", label, index as f64);
427 }
428
429 // ── mouse ───────────────────────────────────────────────────────────────
430
431 /// A click at a cell. Focuses whatever is under it and activates it, which
432 /// is the whole of button 1 in a terminal: there is no press and release to
433 /// tell apart at this level.
434 pub fn click(&mut self, x: u16, y: u16) -> bool {
435 let Some((node, rect)) = self
436 .painted
437 .hits
438 .iter()
439 .find(|(_, rect)| rect.contains(x, y))
440 .copied()
441 else {
442 return false;
443 };
444 self.set_focus(node);
445 match self.tree.tag(node) {
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago446 // A pill is pressed the way a button is: the caller's `:on-click`
447 // is what puts a reaction on or takes it off again.
448 Tag::Button | Tag::Reaction => self.tree.emit(node, "click", String::new(), 0.0),
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago449 Tag::CheckButton => self.toggle(node),
450 Tag::Listbox => {
451 let row = (y - rect.y) as i64;
452 let count = self.tree.child_count(node) as i64;
453 if count > 0 {
454 self.select(node, row.clamp(0, count - 1));
455 }
456 }
457 Tag::Entry => {
458 // Put the caret where it was clicked, not at the end.
459 let text = self.tree.props(node).str("text").chars().count();
460 self.caret = ((x - rect.x) as usize).min(text);
461 }
462 _ => {}
463 }
464 true
465 }
466
467 /// The wheel, `by` rows — negative is up. It moves the innermost `:scroll`
468 /// under the pointer, which is the one a reader means.
469 pub fn wheel(&mut self, x: u16, y: u16, by: i32) -> bool {
470 let Some(node) = self.scroll_at(self.tree.root(), x, y) else {
471 return false;
472 };
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago473 self.scroll_by(node, by)
474 }
475
476 /// Page-up and page-down, for a reader with no pointer to point with.
477 ///
478 /// A terminal has no scrollbar to drag and the wheel is not on every desk,
479 /// so these are the way back through a backlog; a page is the viewport
480 /// less a row, which is the line that says where you were.
481 ///
482 /// The page belongs to the biggest thing on screen. There is no pointer to
483 /// aim with and the focus is rarely inside the list — in frq it is the
484 /// compose entry, under a backlog nobody would call the smaller half of
485 /// the screen — so area is the question, and the reading list wins it.
486 fn page(&mut self, up: bool) -> bool {
487 let Some((node, height)) = self
488 .painted
489 .scrolled
490 .iter()
491 .max_by_key(|(_, _, _, area)| (area.w as u32) * (area.h as u32))
492 .map(|(node, _, _, area)| (*node, area.h))
493 else {
494 return false;
495 };
496 let rows = height.saturating_sub(1).max(1) as i32;
497 self.scroll_by(node, if up { -rows } else { rows })
498 }
499
500 /// Move one `:scroll` by `by` rows, and remember where that put it.
501 ///
502 /// Where it is now comes from what was remembered under its key rather
503 /// than from the node, because the caller may have re-rendered it since
504 /// the last frame: a render clears a node's props and sets them again, and
505 /// a scroll caught between the two reads as an offset of zero. That is how
506 /// a page-down a moment after a message arrived answered with the top of
507 /// the buffer — it was a page down from a list that had forgotten where it
508 /// was. The prop is still written, for the paint that is about to read it.
509 fn scroll_by(&mut self, node: u32, by: i32) -> bool {
510 let key = self.scroll_key(node);
511 let now = self
512 .scrolls
513 .get(&key)
514 .map(|state| state.offset as i32)
515 .unwrap_or_else(|| self.tree.props(node).cells("offset", 0) as i32);
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago516 let to = (now + by).max(0) as f64;
517 self.tree.set(node, "offset", Value::Num(to));
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago518 // Unpin on the way up, and let the next frame decide whether this put
519 // the reader back at the bottom — painting is what knows how far down
520 // that is.
521 self.scrolls.insert(
522 key,
523 Scrolled {
524 offset: to as u16,
525 pinned: false,
526 },
527 );
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago528 self.tree.emit(node, "scroll", String::new(), to);
529 true
530 }
531
532 /// The innermost `:scroll` whose painted area holds this cell.
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago533 ///
534 /// Its own area, not the screen's. Asking whether the pointer was anywhere
535 /// on the terminal is a question every scroll answers yes to, so the first
536 /// one the walk reached took every wheel: on a wide screen that is the
537 /// chats list, and a reader wheeling over the conversation beside it moved
538 /// the sidebar instead — which reads as a backlog that will not scroll.
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago539 fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> {
540 for child in self.tree.children(id) {
541 if let Some(inner) = self.scroll_at(child, x, y) {
542 return Some(inner);
543 }
544 }
545 // Scroll areas take no focus, so they are not in the hit list; the
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago546 // frame records the ones it painted, and where, which is enough for a
547 // wheel.
548 let painted = self
549 .painted
550 .scrolled
551 .iter()
552 .find(|(n, _, _, _)| *n == id)
553 .map(|(_, _, _, area)| *area);
554 if let Some(area) = painted {
555 if matches!(self.tree.tag(id), Tag::Scroll) && area.contains(x, y) {
556 return Some(id);
557 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago558 }
559 None
560 }
561}