nandi/jolt-nativepublic Fork 0
e40b795acdf08fdae31fc3f95e29fcfc01e29eea
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 · 566 lines · 21.3 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
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago84 /// The pictures the last frame wants on screen, for whoever can draw one.
85 pub fn images(&self) -> &[crate::graphics::Placement] {
86 &self.painted.images
87 }
88
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago89 /// Paint one frame, then settle the things painting decided: what is
90 /// focusable now, and how far each scroll area really is.
91 pub fn frame(&mut self) {
92 self.tick = self.tick.wrapping_add(1);
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago93 self.restore_scrolls(self.tree.root());
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago94 self.paint_once();
95 if self.settle_focus() {
96 // Focus is decided by what the paint found, so the frame that
97 // gives it away has to be drawn again — otherwise the first frame
98 // of a screen shows nothing focused and the second one does.
99 self.paint_once();
100 }
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago101 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 ago102 // Painting clamps the viewport to the content; write the clamped
103 // value back so the caller's next `+1` starts from the truth.
104 if self.tree.props(node).cells("offset", 0) != offset {
105 self.tree.set(node, "offset", Value::Num(offset as f64));
106 }
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago107 // And remember it under its key, which is what survives the
108 // re-render that is about to clear the prop. Being at the bottom
109 // is what pins it there: a reader who scrolls back down has said
110 // they want to follow again, and never has to say so twice.
111 let key = self.scroll_key(node);
112 let sticky = self.tree.props(node).bool("stick-to-bottom", false);
113 self.scrolls.insert(
114 key,
115 Scrolled {
116 offset,
117 pinned: sticky && offset >= max,
118 },
119 );
120 }
121 }
122
123 /// What a scroll area is remembered by. Its `:scroll-key` when it has one,
124 /// because that is a name the caller chose and means the same viewport
125 /// after a re-mount; its handle otherwise, which at least survives a
126 /// re-render that leaves the node where it was.
127 fn scroll_key(&self, node: u32) -> String {
128 let props = self.tree.props(node);
129 let key = props.str("scroll-key");
130 if key.is_empty() {
131 format!("#{node}")
132 } else {
133 key.to_owned()
134 }
135 }
136
137 /// Put every scroll area back where it was before the tree is painted.
138 ///
139 /// A pinned one is asked for an offset past the end and painting clamps it
140 /// to the bottom, which is how it follows content that grew since the last
141 /// frame without this having to measure anything.
142 fn restore_scrolls(&mut self, id: u32) {
143 if matches!(self.tree.tag(id), Tag::Scroll) {
144 let key = self.scroll_key(id);
145 let sticky = self.tree.props(id).bool("stick-to-bottom", false);
146 let to = match self.scrolls.get(&key) {
147 Some(state) if sticky && state.pinned => u16::MAX,
148 Some(state) => state.offset,
149 // Never seen: a sticky viewport opens at the bottom, which for
150 // a backlog is the message that just arrived.
151 None if sticky => u16::MAX,
152 None => return,
153 };
154 self.tree.set(id, "offset", Value::Num(to as f64));
155 }
156 for child in self.tree.children(id) {
157 self.restore_scrolls(child);
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago158 }
159 }
160
161 fn paint_once(&mut self) {
162 self.painted = paint::frame(
163 &self.tree,
164 &mut self.screen,
165 self.focus,
166 self.caret,
167 self.tick,
168 );
169 }
170
171 /// Put the focus somewhere real. Answers whether it moved.
172 fn settle_focus(&mut self) -> bool {
173 let was = self.focus;
174 // A focused widget that has since been unmounted — or dimmed — leaves
175 // the ring, and focus lands on the first thing that is still there
176 // rather than on nothing.
177 if self.focus != 0 && !self.painted.ring.contains(&self.focus) {
178 self.focus = 0;
179 }
180 if self.focus == 0 {
181 let wants = self
182 .painted
183 .ring
184 .iter()
185 .find(|id| self.tree.props(**id).bool("autofocus", false))
186 .copied();
187 if let Some(id) = wants.or_else(|| self.painted.ring.first().copied()) {
188 self.set_focus(id);
189 }
190 }
191 self.focus != was
192 }
193
194 fn set_focus(&mut self, id: u32) {
195 if self.focus == id {
196 return;
197 }
198 self.focus = id;
199 // The caret goes to the end of whatever it just entered, which is where
200 // someone tabbing into a field with text in it expects to type.
201 self.caret = self.tree.props(id).str("text").chars().count();
202 }
203
204 fn move_focus(&mut self, forward: bool) {
205 if self.painted.ring.is_empty() {
206 return;
207 }
208 let ring = self.painted.ring.clone();
209 let at = ring.iter().position(|id| *id == self.focus);
210 let next = match (at, forward) {
211 (Some(i), true) => (i + 1) % ring.len(),
212 (Some(i), false) => (i + ring.len() - 1) % ring.len(),
213 (None, true) => 0,
214 (None, false) => ring.len() - 1,
215 };
216 self.set_focus(ring[next]);
217 }
218
219 // ── keys ────────────────────────────────────────────────────────────────
220
221 /// Handle one key by name. Answers false when nothing here wanted it, in
222 /// which case it has been emitted as a `key` event for the caller to route.
223 pub fn key(&mut self, name: &str) -> bool {
224 if matches!(name, "ctrl+c" | "ctrl+q") {
225 self.quit = true;
226 return true;
227 }
228 match name {
229 "tab" => {
230 self.move_focus(true);
231 return true;
232 }
233 "shift+tab" | "backtab" => {
234 self.move_focus(false);
235 return true;
236 }
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago237 // Before the focused widget is asked: a page is about the screen
238 // rather than about whatever is being typed into, and an entry
239 // that ignored these left them going out as an event nobody has a
240 // handler for.
241 "page-up" | "page-down" => {
242 if self.page(name == "page-up") {
243 return true;
244 }
245 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago246 "esc" => {
247 // Esc belongs to the topmost overlay when there is one: that is
248 // what closes a modal everywhere else.
249 if let Some(overlay) = self.topmost_overlay() {
250 self.tree.emit(overlay, "close", String::new(), 0.0);
251 return true;
252 }
253 }
254 _ => {}
255 }
256
257 let focus = self.focus;
258 let handled = match self.tree.tag(focus) {
259 Tag::Entry => self.entry_key(focus, name),
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago260 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 ago261 Tag::CheckButton => {
262 if matches!(name, "enter" | "space") {
263 self.toggle(focus);
264 true
265 } else {
266 false
267 }
268 }
269 Tag::Listbox => self.listbox_key(focus, name),
270 _ => false,
271 };
272 if !handled {
273 // Unhandled keys go to the caller as an event on the focused node,
274 // or on the window when nothing has focus. glimmer bubbles from
275 // there; it holds the handlers and knows the tree.
276 let target = if focus != 0 { focus } else { self.tree.root() };
277 self.tree.emit(target, "key", name.to_owned(), 0.0);
278 }
279 handled
280 }
281
282 fn topmost_overlay(&self) -> Option<u32> {
283 fn walk(tree: &Tree, id: u32, found: &mut Option<u32>) {
284 if matches!(tree.tag(id), Tag::Overlay) {
285 *found = Some(id);
286 }
287 for child in tree.children(id) {
288 walk(tree, child, found);
289 }
290 }
291 let mut found = None;
292 walk(&self.tree, self.tree.root(), &mut found);
293 found
294 }
295
296 fn activate_key(&mut self, node: u32, name: &str, event: &'static str) -> bool {
297 if matches!(name, "enter" | "space") {
298 self.tree.emit(node, event, String::new(), 0.0);
299 true
300 } else {
301 false
302 }
303 }
304
305 fn toggle(&mut self, node: u32) {
306 let now = !self.tree.props(node).bool("active", false);
307 // The widget does not own its value, but it does keep working when the
308 // caller ignores the event: the new state is written back here, and the
309 // next prop write from the reconciler is what settles it.
310 self.tree.set(node, "active", Value::Bool(now));
311 self.tree
312 .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 });
313 }
314
315 fn entry_key(&mut self, node: u32, name: &str) -> bool {
316 let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect();
317 let mut at = self.caret.min(text.len());
318 let mut changed = false;
319 match name {
320 "enter" => {
321 let now: String = text.iter().collect();
322 self.tree.emit(node, "activate", now, 0.0);
323 return true;
324 }
325 "left" | "ctrl+b" => at = at.saturating_sub(1),
326 "right" | "ctrl+f" => at = (at + 1).min(text.len()),
327 "home" | "ctrl+a" => at = 0,
328 "end" | "ctrl+e" => at = text.len(),
329 "alt+b" => at = keys::word_left(&text, at),
330 "alt+f" => at = keys::word_right(&text, at),
331 "backspace" => {
332 if at > 0 {
333 text.remove(at - 1);
334 at -= 1;
335 changed = true;
336 }
337 }
338 "delete" | "ctrl+d" => {
339 if at < text.len() {
340 text.remove(at);
341 changed = true;
342 }
343 }
344 "ctrl+w" | "alt+backspace" => {
345 let from = keys::word_left(&text, at);
346 if from < at {
347 text.drain(from..at);
348 at = from;
349 changed = true;
350 }
351 }
352 "ctrl+u" => {
353 if at > 0 {
354 text.drain(0..at);
355 at = 0;
356 changed = true;
357 }
358 }
359 "ctrl+k" => {
360 if at < text.len() {
361 text.truncate(at);
362 changed = true;
363 }
364 }
365 "space" => {
366 text.insert(at, ' ');
367 at += 1;
368 changed = true;
369 }
370 other => {
371 // A single character with no modifier on it is text.
372 let mut chars = other.chars();
373 match (chars.next(), chars.next()) {
374 (Some(ch), None) if !ch.is_control() => {
375 text.insert(at, ch);
376 at += 1;
377 changed = true;
378 }
379 _ => return false,
380 }
381 }
382 }
383 self.caret = at;
384 if changed {
385 let now: String = text.iter().collect();
386 self.tree.set(node, "text", Value::Str(now.clone()));
387 self.tree.emit(node, "change", now, 0.0);
388 }
389 true
390 }
391
392 fn listbox_key(&mut self, node: u32, name: &str) -> bool {
393 let count = self.tree.child_count(node) as i64;
394 if count == 0 {
395 return false;
396 }
397 let page = self
398 .painted
399 .hits
400 .iter()
401 .find(|(id, _)| *id == node)
402 .map_or(1, |(_, rect)| rect.h.max(1) as i64);
403 let at = self.tree.props(node).num("selected", 0.0) as i64;
404 let to = match name {
405 "down" | "j" | "ctrl+n" => at + 1,
406 "up" | "k" | "ctrl+p" => at - 1,
407 "page-down" | "ctrl+d" => at + page,
408 "page-up" | "ctrl+u" => at - page,
409 "home" | "g" => 0,
410 "end" | "G" => count - 1,
411 "enter" | "space" => {
412 let index = at.clamp(0, count - 1);
413 let item = self.tree.child_at(node, index as usize);
414 let label = self.tree.props(item).label().to_owned();
415 self.tree.emit(node, "activate", label, index as f64);
416 return true;
417 }
418 _ => return false,
419 };
420 self.select(node, to.clamp(0, count - 1));
421 true
422 }
423
424 fn select(&mut self, node: u32, index: i64) {
425 if self.tree.props(node).num("selected", -1.0) as i64 == index {
426 return;
427 }
428 self.tree.set(node, "selected", Value::Num(index as f64));
429 let item = self.tree.child_at(node, index as usize);
430 let label = self.tree.props(item).label().to_owned();
431 self.tree.emit(node, "select", label, index as f64);
432 }
433
434 // ── mouse ───────────────────────────────────────────────────────────────
435
436 /// A click at a cell. Focuses whatever is under it and activates it, which
437 /// is the whole of button 1 in a terminal: there is no press and release to
438 /// tell apart at this level.
439 pub fn click(&mut self, x: u16, y: u16) -> bool {
440 let Some((node, rect)) = self
441 .painted
442 .hits
443 .iter()
444 .find(|(_, rect)| rect.contains(x, y))
445 .copied()
446 else {
447 return false;
448 };
449 self.set_focus(node);
450 match self.tree.tag(node) {
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago451 // A pill is pressed the way a button is: the caller's `:on-click`
452 // is what puts a reaction on or takes it off again.
453 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 ago454 Tag::CheckButton => self.toggle(node),
455 Tag::Listbox => {
456 let row = (y - rect.y) as i64;
457 let count = self.tree.child_count(node) as i64;
458 if count > 0 {
459 self.select(node, row.clamp(0, count - 1));
460 }
461 }
462 Tag::Entry => {
463 // Put the caret where it was clicked, not at the end.
464 let text = self.tree.props(node).str("text").chars().count();
465 self.caret = ((x - rect.x) as usize).min(text);
466 }
467 _ => {}
468 }
469 true
470 }
471
472 /// The wheel, `by` rows — negative is up. It moves the innermost `:scroll`
473 /// under the pointer, which is the one a reader means.
474 pub fn wheel(&mut self, x: u16, y: u16, by: i32) -> bool {
475 let Some(node) = self.scroll_at(self.tree.root(), x, y) else {
476 return false;
477 };
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago478 self.scroll_by(node, by)
479 }
480
481 /// Page-up and page-down, for a reader with no pointer to point with.
482 ///
483 /// A terminal has no scrollbar to drag and the wheel is not on every desk,
484 /// so these are the way back through a backlog; a page is the viewport
485 /// less a row, which is the line that says where you were.
486 ///
487 /// The page belongs to the biggest thing on screen. There is no pointer to
488 /// aim with and the focus is rarely inside the list — in frq it is the
489 /// compose entry, under a backlog nobody would call the smaller half of
490 /// the screen — so area is the question, and the reading list wins it.
491 fn page(&mut self, up: bool) -> bool {
492 let Some((node, height)) = self
493 .painted
494 .scrolled
495 .iter()
496 .max_by_key(|(_, _, _, area)| (area.w as u32) * (area.h as u32))
497 .map(|(node, _, _, area)| (*node, area.h))
498 else {
499 return false;
500 };
501 let rows = height.saturating_sub(1).max(1) as i32;
502 self.scroll_by(node, if up { -rows } else { rows })
503 }
504
505 /// Move one `:scroll` by `by` rows, and remember where that put it.
506 ///
507 /// Where it is now comes from what was remembered under its key rather
508 /// than from the node, because the caller may have re-rendered it since
509 /// the last frame: a render clears a node's props and sets them again, and
510 /// a scroll caught between the two reads as an offset of zero. That is how
511 /// a page-down a moment after a message arrived answered with the top of
512 /// the buffer — it was a page down from a list that had forgotten where it
513 /// was. The prop is still written, for the paint that is about to read it.
514 fn scroll_by(&mut self, node: u32, by: i32) -> bool {
515 let key = self.scroll_key(node);
516 let now = self
517 .scrolls
518 .get(&key)
519 .map(|state| state.offset as i32)
520 .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 ago521 let to = (now + by).max(0) as f64;
522 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 ago523 // Unpin on the way up, and let the next frame decide whether this put
524 // the reader back at the bottom — painting is what knows how far down
525 // that is.
526 self.scrolls.insert(
527 key,
528 Scrolled {
529 offset: to as u16,
530 pinned: false,
531 },
532 );
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago533 self.tree.emit(node, "scroll", String::new(), to);
534 true
535 }
536
537 /// The innermost `:scroll` whose painted area holds this cell.
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago538 ///
539 /// Its own area, not the screen's. Asking whether the pointer was anywhere
540 /// on the terminal is a question every scroll answers yes to, so the first
541 /// one the walk reached took every wheel: on a wide screen that is the
542 /// chats list, and a reader wheeling over the conversation beside it moved
543 /// 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 ago544 fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> {
545 for child in self.tree.children(id) {
546 if let Some(inner) = self.scroll_at(child, x, y) {
547 return Some(inner);
548 }
549 }
550 // 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 ago551 // frame records the ones it painted, and where, which is enough for a
552 // wheel.
553 let painted = self
554 .painted
555 .scrolled
556 .iter()
557 .find(|(n, _, _, _)| *n == id)
558 .map(|(_, _, _, area)| *area);
559 if let Some(area) = painted {
560 if matches!(self.tree.tag(id), Tag::Scroll) && area.contains(x, y) {
561 return Some(id);
562 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago563 }
564 None
565 }
566}