nandi/jolt-nativepublic Fork 0
1d16366b59c457d09332a93d7337e1c80fe934c0
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 · 660 lines · 25.7 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
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago12use crate::entry::View;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago13use crate::keys;
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago14use crate::layout;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago15use crate::paint::{self, Painted};
16use crate::screen::Screen;
17use crate::tree::{Tag, Tree, Value};
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago18use std::collections::HashMap;
19
20/// Where one scroll area is, and whether it is following its own bottom.
21///
22/// It lives here rather than in the tree because a re-render clears a node's
23/// props: glimmer writes what the component said and nothing else, which is
24/// right — the component's state is the truth — and it means a viewport that
25/// kept its position in a prop loses it the moment anything above it changes.
26/// A chat backlog changes on every message, which is exactly when a reader
27/// cares where they were.
28#[derive(Clone, Copy)]
29struct Scrolled {
30 offset: u16,
31 /// Following the bottom. A `:stick-to-bottom` viewport starts this way,
32 /// stops when the reader scrolls up, and starts again when they come back
33 /// down — which is the behaviour that lets a new message arrive without
34 /// dragging the screen out from under someone reading history.
35 pinned: bool,
36}
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago37
38pub struct Ui {
39 pub tree: Tree,
40 pub screen: Screen,
41 /// The node the focus ring is on, 0 for none.
42 focus: u32,
43 /// The caret in the focused entry, in characters from the start.
44 caret: usize,
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago45 /// The row the focused entry is scrolled to, in rows of its own text.
46 entry_top: usize,
47 /// The column up and down are aiming for.
48 ///
49 /// Walking a caret down through rows of different lengths and back up has
50 /// to come home to the column it left, so the column is remembered until
51 /// something other than up or down moves the caret. Without it a step
52 /// through a short row drags the caret to that row's end and leaves it
53 /// there, which is the thing that makes an editor feel broken.
54 goal: Option<u16>,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago55 painted: Painted,
56 tick: u64,
57 quit: bool,
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago58 /// Scroll positions by `:scroll-key`, across re-renders.
59 scrolls: HashMap<String, Scrolled>,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago60}
61
62impl Ui {
63 pub fn new(width: u16, height: u16) -> Self {
64 Self {
65 tree: Tree::new(),
66 screen: Screen::new(width.max(1), height.max(1)),
67 focus: 0,
68 caret: 0,
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago69 entry_top: 0,
70 goal: None,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago71 painted: Painted::default(),
72 tick: 0,
73 quit: false,
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago74 scrolls: HashMap::new(),
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago75 }
76 }
77
78 pub fn resize(&mut self, width: u16, height: u16) {
79 self.screen.resize(width.max(1), height.max(1));
80 }
81
82 pub fn should_close(&self) -> bool {
83 self.quit
84 }
85
86 pub fn quit(&mut self) {
87 self.quit = true;
88 }
89
90 pub fn focus(&self) -> u32 {
91 self.focus
92 }
93
94 pub fn cursor(&self) -> Option<(u16, u16)> {
95 self.painted.cursor
96 }
97
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago98 /// The pictures the last frame wants on screen, for whoever can draw one.
99 pub fn images(&self) -> &[crate::graphics::Placement] {
100 &self.painted.images
101 }
102
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago103 /// Paint one frame, then settle the things painting decided: what is
104 /// focusable now, and how far each scroll area really is.
105 pub fn frame(&mut self) {
106 self.tick = self.tick.wrapping_add(1);
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago107 self.restore_scrolls(self.tree.root());
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago108 self.paint_once();
109 if self.settle_focus() {
110 // Focus is decided by what the paint found, so the frame that
111 // gives it away has to be drawn again — otherwise the first frame
112 // of a screen shows nothing focused and the second one does.
113 self.paint_once();
114 }
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago115 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 ago116 // Painting clamps the viewport to the content; write the clamped
117 // value back so the caller's next `+1` starts from the truth.
118 if self.tree.props(node).cells("offset", 0) != offset {
119 self.tree.set(node, "offset", Value::Num(offset as f64));
120 }
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago121 // And remember it under its key, which is what survives the
122 // re-render that is about to clear the prop. Being at the bottom
123 // is what pins it there: a reader who scrolls back down has said
124 // they want to follow again, and never has to say so twice.
125 let key = self.scroll_key(node);
126 let sticky = self.tree.props(node).bool("stick-to-bottom", false);
127 self.scrolls.insert(
128 key,
129 Scrolled {
130 offset,
131 pinned: sticky && offset >= max,
132 },
133 );
134 }
135 }
136
137 /// What a scroll area is remembered by. Its `:scroll-key` when it has one,
138 /// because that is a name the caller chose and means the same viewport
139 /// after a re-mount; its handle otherwise, which at least survives a
140 /// re-render that leaves the node where it was.
141 fn scroll_key(&self, node: u32) -> String {
142 let props = self.tree.props(node);
143 let key = props.str("scroll-key");
144 if key.is_empty() {
145 format!("#{node}")
146 } else {
147 key.to_owned()
148 }
149 }
150
151 /// Put every scroll area back where it was before the tree is painted.
152 ///
153 /// A pinned one is asked for an offset past the end and painting clamps it
154 /// to the bottom, which is how it follows content that grew since the last
155 /// frame without this having to measure anything.
156 fn restore_scrolls(&mut self, id: u32) {
157 if matches!(self.tree.tag(id), Tag::Scroll) {
158 let key = self.scroll_key(id);
159 let sticky = self.tree.props(id).bool("stick-to-bottom", false);
160 let to = match self.scrolls.get(&key) {
161 Some(state) if sticky && state.pinned => u16::MAX,
162 Some(state) => state.offset,
163 // Never seen: a sticky viewport opens at the bottom, which for
164 // a backlog is the message that just arrived.
165 None if sticky => u16::MAX,
166 None => return,
167 };
168 self.tree.set(id, "offset", Value::Num(to as f64));
169 }
170 for child in self.tree.children(id) {
171 self.restore_scrolls(child);
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago172 }
173 }
174
175 fn paint_once(&mut self) {
176 self.painted = paint::frame(
177 &self.tree,
178 &mut self.screen,
179 self.focus,
180 self.caret,
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago181 self.entry_top,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago182 self.tick,
183 );
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago184 self.entry_top = self.painted.entry_top;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago185 }
186
187 /// Put the focus somewhere real. Answers whether it moved.
188 fn settle_focus(&mut self) -> bool {
189 let was = self.focus;
190 // A focused widget that has since been unmounted — or dimmed — leaves
191 // the ring, and focus lands on the first thing that is still there
192 // rather than on nothing.
193 if self.focus != 0 && !self.painted.ring.contains(&self.focus) {
194 self.focus = 0;
195 }
196 if self.focus == 0 {
197 let wants = self
198 .painted
199 .ring
200 .iter()
201 .find(|id| self.tree.props(**id).bool("autofocus", false))
202 .copied();
203 if let Some(id) = wants.or_else(|| self.painted.ring.first().copied()) {
204 self.set_focus(id);
205 }
206 }
207 self.focus != was
208 }
209
210 fn set_focus(&mut self, id: u32) {
211 if self.focus == id {
212 return;
213 }
214 self.focus = id;
215 // The caret goes to the end of whatever it just entered, which is where
216 // someone tabbing into a field with text in it expects to type.
217 self.caret = self.tree.props(id).str("text").chars().count();
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago218 self.goal = None;
219 // And the new field is scrolled to wherever that put it, not to
220 // wherever the last one happened to be.
221 self.entry_top = usize::MAX;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago222 }
223
224 fn move_focus(&mut self, forward: bool) {
225 if self.painted.ring.is_empty() {
226 return;
227 }
228 let ring = self.painted.ring.clone();
229 let at = ring.iter().position(|id| *id == self.focus);
230 let next = match (at, forward) {
231 (Some(i), true) => (i + 1) % ring.len(),
232 (Some(i), false) => (i + ring.len() - 1) % ring.len(),
233 (None, true) => 0,
234 (None, false) => ring.len() - 1,
235 };
236 self.set_focus(ring[next]);
237 }
238
239 // ── keys ────────────────────────────────────────────────────────────────
240
241 /// Handle one key by name. Answers false when nothing here wanted it, in
242 /// which case it has been emitted as a `key` event for the caller to route.
243 pub fn key(&mut self, name: &str) -> bool {
244 if matches!(name, "ctrl+c" | "ctrl+q") {
245 self.quit = true;
246 return true;
247 }
248 match name {
249 "tab" => {
250 self.move_focus(true);
251 return true;
252 }
253 "shift+tab" | "backtab" => {
254 self.move_focus(false);
255 return true;
256 }
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago257 // Before the focused widget is asked: a page is about the screen
258 // rather than about whatever is being typed into, and an entry
259 // that ignored these left them going out as an event nobody has a
260 // handler for.
261 "page-up" | "page-down" => {
262 if self.page(name == "page-up") {
263 return true;
264 }
265 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago266 "esc" => {
267 // Esc belongs to the topmost overlay when there is one: that is
268 // what closes a modal everywhere else.
269 if let Some(overlay) = self.topmost_overlay() {
270 self.tree.emit(overlay, "close", String::new(), 0.0);
271 return true;
272 }
273 }
274 _ => {}
275 }
276
277 let focus = self.focus;
278 let handled = match self.tree.tag(focus) {
279 Tag::Entry => self.entry_key(focus, name),
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago280 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 ago281 Tag::CheckButton => {
282 if matches!(name, "enter" | "space") {
283 self.toggle(focus);
284 true
285 } else {
286 false
287 }
288 }
289 Tag::Listbox => self.listbox_key(focus, name),
290 _ => false,
291 };
292 if !handled {
293 // Unhandled keys go to the caller as an event on the focused node,
294 // or on the window when nothing has focus. glimmer bubbles from
295 // there; it holds the handlers and knows the tree.
296 let target = if focus != 0 { focus } else { self.tree.root() };
297 self.tree.emit(target, "key", name.to_owned(), 0.0);
298 }
299 handled
300 }
301
302 fn topmost_overlay(&self) -> Option<u32> {
303 fn walk(tree: &Tree, id: u32, found: &mut Option<u32>) {
304 if matches!(tree.tag(id), Tag::Overlay) {
305 *found = Some(id);
306 }
307 for child in tree.children(id) {
308 walk(tree, child, found);
309 }
310 }
311 let mut found = None;
312 walk(&self.tree, self.tree.root(), &mut found);
313 found
314 }
315
316 fn activate_key(&mut self, node: u32, name: &str, event: &'static str) -> bool {
317 if matches!(name, "enter" | "space") {
318 self.tree.emit(node, event, String::new(), 0.0);
319 true
320 } else {
321 false
322 }
323 }
324
325 fn toggle(&mut self, node: u32) {
326 let now = !self.tree.props(node).bool("active", false);
327 // The widget does not own its value, but it does keep working when the
328 // caller ignores the event: the new state is written back here, and the
329 // next prop write from the reconciler is what settles it.
330 self.tree.set(node, "active", Value::Bool(now));
331 self.tree
332 .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 });
333 }
334
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago335 /// How the text in `node` sits in the cells it was painted into.
336 ///
337 /// The same layout the painter used, asked again rather than kept: it is a
338 /// pure function of the text, the rect and the caret, and the alternative
339 /// is two copies of the truth that drift apart on the frame where the text
340 /// changed and the paint has not caught up.
341 fn entry_view(&self, node: u32) -> View {
342 let props = self.tree.props(node);
343 let multiline = layout::entry_multiline(&props);
344 let text = props.str("text").to_owned();
345 let pad = layout::inset(&self.tree.tag(node), &props);
346 let (w, h) = self
347 .painted
348 .hits
349 .iter()
350 .find(|(id, _)| *id == node)
351 .map(|(_, rect)| rect.shrink(pad))
352 .map_or((1, 1), |rect| (rect.w.max(1), rect.h.max(1)));
353 let top = if node == self.focus {
354 self.entry_top
355 } else {
356 usize::MAX
357 };
358 View::of(&text, w, h, multiline, self.caret, top)
359 }
360
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago361 fn entry_key(&mut self, node: u32, name: &str) -> bool {
362 let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect();
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago363 let multiline = layout::entry_multiline(&self.tree.props(node));
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago364 let mut at = self.caret.min(text.len());
365 let mut changed = false;
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago366 // Only up and down keep the column they were aiming for; everything
367 // else here has said where it wants the caret.
368 let mut keep_goal = false;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago369 match name {
370 "enter" => {
371 let now: String = text.iter().collect();
372 self.tree.emit(node, "activate", now, 0.0);
373 return true;
374 }
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago375 // A newline where Enter is spoken for. Shift+Enter is what every
376 // chat box takes; Alt+Enter and Ctrl+J are the two a terminal that
377 // cannot tell Shift+Enter from Enter still can.
378 "shift+enter" | "alt+enter" | "ctrl+j" if multiline => {
379 text.insert(at, '\n');
380 at += 1;
381 changed = true;
382 }
383 "up" | "ctrl+p" | "down" | "ctrl+n" if multiline => {
384 let up = matches!(name, "up" | "ctrl+p");
385 let view = self.entry_view(node);
386 let (row, col) = view.caret_at(at);
387 // Off the top of the first row, or the bottom of the last, is
388 // not this field's key: it is a reader trying to leave.
389 if (up && row == 0) || (!up && row + 1 >= view.lines.len()) {
390 return false;
391 }
392 let goal = self.goal.unwrap_or(col).max(col);
393 let to = if up { row - 1 } else { row + 1 };
394 at = view.at_col(to, goal);
395 self.goal = Some(goal);
396 keep_goal = true;
397 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago398 "left" | "ctrl+b" => at = at.saturating_sub(1),
399 "right" | "ctrl+f" => at = (at + 1).min(text.len()),
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago400 // Home and End are about the row the caret is on, which in a field
401 // of one row is the whole of the text.
402 "home" | "ctrl+a" => at = self.entry_view(node).caret_row(at).0,
403 "end" | "ctrl+e" => at = self.entry_view(node).caret_row(at).1,
404 "ctrl+home" => at = 0,
405 "ctrl+end" if multiline => at = text.len(),
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago406 "alt+b" => at = keys::word_left(&text, at),
407 "alt+f" => at = keys::word_right(&text, at),
408 "backspace" => {
409 if at > 0 {
410 text.remove(at - 1);
411 at -= 1;
412 changed = true;
413 }
414 }
415 "delete" | "ctrl+d" => {
416 if at < text.len() {
417 text.remove(at);
418 changed = true;
419 }
420 }
421 "ctrl+w" | "alt+backspace" => {
422 let from = keys::word_left(&text, at);
423 if from < at {
424 text.drain(from..at);
425 at = from;
426 changed = true;
427 }
428 }
429 "ctrl+u" => {
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago430 let from = self.entry_view(node).caret_row(at).0;
431 if from < at {
432 text.drain(from..at);
433 at = from;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago434 changed = true;
435 }
436 }
437 "ctrl+k" => {
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago438 let to = self.entry_view(node).caret_row(at).1;
439 if to > at {
440 text.drain(at..to);
441 changed = true;
442 } else if multiline && at < text.len() && text[at] == '\n' {
443 // At the end of a row already: the kill takes the break,
444 // which is how a line is joined to the one below it.
445 text.remove(at);
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago446 changed = true;
447 }
448 }
449 "space" => {
450 text.insert(at, ' ');
451 at += 1;
452 changed = true;
453 }
454 other => {
455 // A single character with no modifier on it is text.
456 let mut chars = other.chars();
457 match (chars.next(), chars.next()) {
458 (Some(ch), None) if !ch.is_control() => {
459 text.insert(at, ch);
460 at += 1;
461 changed = true;
462 }
463 _ => return false,
464 }
465 }
466 }
467 self.caret = at;
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago468 if !keep_goal {
469 self.goal = None;
470 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago471 if changed {
472 let now: String = text.iter().collect();
473 self.tree.set(node, "text", Value::Str(now.clone()));
474 self.tree.emit(node, "change", now, 0.0);
475 }
476 true
477 }
478
479 fn listbox_key(&mut self, node: u32, name: &str) -> bool {
480 let count = self.tree.child_count(node) as i64;
481 if count == 0 {
482 return false;
483 }
484 let page = self
485 .painted
486 .hits
487 .iter()
488 .find(|(id, _)| *id == node)
489 .map_or(1, |(_, rect)| rect.h.max(1) as i64);
490 let at = self.tree.props(node).num("selected", 0.0) as i64;
491 let to = match name {
492 "down" | "j" | "ctrl+n" => at + 1,
493 "up" | "k" | "ctrl+p" => at - 1,
494 "page-down" | "ctrl+d" => at + page,
495 "page-up" | "ctrl+u" => at - page,
496 "home" | "g" => 0,
497 "end" | "G" => count - 1,
498 "enter" | "space" => {
499 let index = at.clamp(0, count - 1);
500 let item = self.tree.child_at(node, index as usize);
501 let label = self.tree.props(item).label().to_owned();
502 self.tree.emit(node, "activate", label, index as f64);
503 return true;
504 }
505 _ => return false,
506 };
507 self.select(node, to.clamp(0, count - 1));
508 true
509 }
510
511 fn select(&mut self, node: u32, index: i64) {
512 if self.tree.props(node).num("selected", -1.0) as i64 == index {
513 return;
514 }
515 self.tree.set(node, "selected", Value::Num(index as f64));
516 let item = self.tree.child_at(node, index as usize);
517 let label = self.tree.props(item).label().to_owned();
518 self.tree.emit(node, "select", label, index as f64);
519 }
520
521 // ── mouse ───────────────────────────────────────────────────────────────
522
523 /// A click at a cell. Focuses whatever is under it and activates it, which
524 /// is the whole of button 1 in a terminal: there is no press and release to
525 /// tell apart at this level.
526 pub fn click(&mut self, x: u16, y: u16) -> bool {
527 let Some((node, rect)) = self
528 .painted
529 .hits
530 .iter()
531 .find(|(_, rect)| rect.contains(x, y))
532 .copied()
533 else {
534 return false;
535 };
536 self.set_focus(node);
537 match self.tree.tag(node) {
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago538 // A pill is pressed the way a button is: the caller's `:on-click`
539 // is what puts a reaction on or takes it off again.
540 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 ago541 Tag::CheckButton => self.toggle(node),
542 Tag::Listbox => {
543 let row = (y - rect.y) as i64;
544 let count = self.tree.child_count(node) as i64;
545 if count > 0 {
546 self.select(node, row.clamp(0, count - 1));
547 }
548 }
549 Tag::Entry => {
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago550 // Put the caret where it was clicked, on the row that was
551 // clicked: the field knows where its own characters were
552 // painted, so a click in the middle of the third wrapped row
553 // is the character in the middle of the third wrapped row.
554 let props = self.tree.props(node);
555 let pad = layout::inset(&Tag::Entry, &props);
556 let inner = rect.shrink(pad);
557 let view = self.entry_view(node);
558 self.caret = view.hit(x.saturating_sub(inner.x), y.saturating_sub(inner.y));
559 self.goal = None;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago560 }
561 _ => {}
562 }
563 true
564 }
565
566 /// The wheel, `by` rows — negative is up. It moves the innermost `:scroll`
567 /// under the pointer, which is the one a reader means.
568 pub fn wheel(&mut self, x: u16, y: u16, by: i32) -> bool {
569 let Some(node) = self.scroll_at(self.tree.root(), x, y) else {
570 return false;
571 };
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago572 self.scroll_by(node, by)
573 }
574
575 /// Page-up and page-down, for a reader with no pointer to point with.
576 ///
577 /// A terminal has no scrollbar to drag and the wheel is not on every desk,
578 /// so these are the way back through a backlog; a page is the viewport
579 /// less a row, which is the line that says where you were.
580 ///
581 /// The page belongs to the biggest thing on screen. There is no pointer to
582 /// aim with and the focus is rarely inside the list — in frq it is the
583 /// compose entry, under a backlog nobody would call the smaller half of
584 /// the screen — so area is the question, and the reading list wins it.
585 fn page(&mut self, up: bool) -> bool {
586 let Some((node, height)) = self
587 .painted
588 .scrolled
589 .iter()
590 .max_by_key(|(_, _, _, area)| (area.w as u32) * (area.h as u32))
591 .map(|(node, _, _, area)| (*node, area.h))
592 else {
593 return false;
594 };
595 let rows = height.saturating_sub(1).max(1) as i32;
596 self.scroll_by(node, if up { -rows } else { rows })
597 }
598
599 /// Move one `:scroll` by `by` rows, and remember where that put it.
600 ///
601 /// Where it is now comes from what was remembered under its key rather
602 /// than from the node, because the caller may have re-rendered it since
603 /// the last frame: a render clears a node's props and sets them again, and
604 /// a scroll caught between the two reads as an offset of zero. That is how
605 /// a page-down a moment after a message arrived answered with the top of
606 /// the buffer — it was a page down from a list that had forgotten where it
607 /// was. The prop is still written, for the paint that is about to read it.
608 fn scroll_by(&mut self, node: u32, by: i32) -> bool {
609 let key = self.scroll_key(node);
610 let now = self
611 .scrolls
612 .get(&key)
613 .map(|state| state.offset as i32)
614 .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 ago615 let to = (now + by).max(0) as f64;
616 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 ago617 // Unpin on the way up, and let the next frame decide whether this put
618 // the reader back at the bottom — painting is what knows how far down
619 // that is.
620 self.scrolls.insert(
621 key,
622 Scrolled {
623 offset: to as u16,
624 pinned: false,
625 },
626 );
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago627 self.tree.emit(node, "scroll", String::new(), to);
628 true
629 }
630
631 /// The innermost `:scroll` whose painted area holds this cell.
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago632 ///
633 /// Its own area, not the screen's. Asking whether the pointer was anywhere
634 /// on the terminal is a question every scroll answers yes to, so the first
635 /// one the walk reached took every wheel: on a wide screen that is the
636 /// chats list, and a reader wheeling over the conversation beside it moved
637 /// 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 ago638 fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> {
639 for child in self.tree.children(id) {
640 if let Some(inner) = self.scroll_at(child, x, y) {
641 return Some(inner);
642 }
643 }
644 // 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 ago645 // frame records the ones it painted, and where, which is enough for a
646 // wheel.
647 let painted = self
648 .painted
649 .scrolled
650 .iter()
651 .find(|(n, _, _, _)| *n == id)
652 .map(|(_, _, _, area)| *area);
653 if let Some(area) = painted {
654 if matches!(self.tree.tag(id), Tag::Scroll) && area.contains(x, y) {
655 return Some(id);
656 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago657 }
658 None
659 }
660}