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

Scroll the list under the pointer, and from where it actually is

Three things kept a backlog from scrolling in a terminal, and every one
of them read as the same thing: a conversation that will not move.

`scroll_at` asked whether the pointer was inside *the screen*, which is a
question every scroll answers yes to, so the first one the walk reached
took every wheel event. On a screen wide enough for frq's split layout
that is the chats list, and a reader wheeling over the conversation
beside it moved the sidebar. Painting now records each viewport's area
along with how far it was — translated into the parent's coordinates for
a scroll inside a scroll, the way the hit rects already are — and the
wheel is aimed at the one under the pointer.

`scroll_by` read where the list was from the node's own `offset`, which
belongs to the caller: a render clears a node's props and writes them
again, and a wheel that landed between the two read zero and answered
with the top of the buffer. In a client with messages arriving that gap
is open often. What was remembered under the scroll's key is the truth,
and that is what it reads now; the prop is still written, for the paint
about to read it.

And a notch of the wheel moved one row. A terminal reports one event per
detent and a row here is a whole line, so crossing a screen took forty of
them. Three, which is what a terminal reader expects.

Page-up and page-down are new, and are the way through a backlog on a
machine with no pointer at all. They go to the largest viewport on
screen: there is nothing 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. A page is the viewport less a row, which
is the line that says where you were.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-02T22:38:11-07:00 Browse files
a785201 parent: 258bbc5
modified crates/jolt-tui/src/paint.rs +25 -5
@@ -26,9 +26,10 @@ pub struct Painted {
2626 /// How far each scroll node's viewport actually was, after clamping to the
2727 /// content it had. Written back so a caller cannot scroll past the end.
2828 /// Each `:scroll` painted, as (node, the offset it was painted at, the
29- /// furthest it could have been). The second number is what tells a caller
30- /// whether it is at the bottom, which is what sticking to it means.
31- pub scrolled: Vec<(u32, u16, u16)>,
29+ /// furthest it could have been, and the area it was painted into). The
30+ /// second number is what tells a caller whether it is at the bottom, which
31+ /// is what sticking to it means; the rect is what a wheel is aimed at.
32+ pub scrolled: Vec<(u32, u16, u16, Rect)>,
3233 /// Where the cursor should sit — the focused entry's caret, if any.
3334 pub cursor: Option<(u16, u16)>,
3435 }
@@ -347,7 +348,7 @@ impl Painter<'_> {
347348 .max(1);
348349 let max_offset = content_h.saturating_sub(area.h);
349350 let offset = props.cells("offset", 0).min(max_offset);
350- self.out.scrolled.push((id, offset, max_offset));
351+ self.out.scrolled.push((id, offset, max_offset, area));
351352
352353 let mut buffer = Screen::new(area.w, content_h);
353354 let mut inner = Painter {
@@ -386,7 +387,26 @@ impl Painter<'_> {
386387 ));
387388 }
388389 }
389- self.out.scrolled.extend(learned.scrolled);
390+ // A scroll inside this one was painted into the buffer, so its area is
391+ // in the buffer's coordinates: move it the way the hits above moved,
392+ // and drop the ones the viewport is not showing. A wheel over a nested
393+ // list has to land on the list under the pointer, and a rect left in
394+ // the wrong space is a wheel aimed at whatever happens to be there.
395+ for (node, inner_offset, max, rect) in learned.scrolled {
396+ if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
397+ self.out.scrolled.push((
398+ node,
399+ inner_offset,
400+ max,
401+ Rect::new(
402+ area.x + rect.x,
403+ area.y + rect.y - offset,
404+ rect.w,
405+ rect.h.min(area.h),
406+ ),
407+ ));
408+ }
409+ }
390410 if let Some((cx, cy)) = learned.cursor {
391411 if cy >= offset && cy < offset.saturating_add(area.h) {
392412 self.out.cursor = Some((area.x + cx, area.y + cy - offset));
@@ -26,9 +26,10 @@ pub struct Painted {
26 /// How far each scroll node's viewport actually was, after clamping to the26 /// How far each scroll node's viewport actually was, after clamping to the
27 /// content it had. Written back so a caller cannot scroll past the end.27 /// content it had. Written back so a caller cannot scroll past the end.
28 /// Each `:scroll` painted, as (node, the offset it was painted at, the28 /// Each `:scroll` painted, as (node, the offset it was painted at, the
29- /// furthest it could have been). The second number is what tells a caller29+ /// furthest it could have been, and the area it was painted into). The
30- /// whether it is at the bottom, which is what sticking to it means.30+ /// second number is what tells a caller whether it is at the bottom, which
31- pub scrolled: Vec<(u32, u16, u16)>,31+ /// is what sticking to it means; the rect is what a wheel is aimed at.
32+ pub scrolled: Vec<(u32, u16, u16, Rect)>,
32 /// Where the cursor should sit — the focused entry's caret, if any.33 /// Where the cursor should sit — the focused entry's caret, if any.
33 pub cursor: Option<(u16, u16)>,34 pub cursor: Option<(u16, u16)>,
34 }35 }
@@ -347,7 +348,7 @@ impl Painter<'_> {
347 .max(1);348 .max(1);
348 let max_offset = content_h.saturating_sub(area.h);349 let max_offset = content_h.saturating_sub(area.h);
349 let offset = props.cells("offset", 0).min(max_offset);350 let offset = props.cells("offset", 0).min(max_offset);
350- self.out.scrolled.push((id, offset, max_offset));351+ self.out.scrolled.push((id, offset, max_offset, area));
351 352
352 let mut buffer = Screen::new(area.w, content_h);353 let mut buffer = Screen::new(area.w, content_h);
353 let mut inner = Painter {354 let mut inner = Painter {
@@ -386,7 +387,26 @@ impl Painter<'_> {
386 ));387 ));
387 }388 }
388 }389 }
389- self.out.scrolled.extend(learned.scrolled);390+ // A scroll inside this one was painted into the buffer, so its area is
391+ // in the buffer's coordinates: move it the way the hits above moved,
392+ // and drop the ones the viewport is not showing. A wheel over a nested
393+ // list has to land on the list under the pointer, and a rect left in
394+ // the wrong space is a wheel aimed at whatever happens to be there.
395+ for (node, inner_offset, max, rect) in learned.scrolled {
396+ if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
397+ self.out.scrolled.push((
398+ node,
399+ inner_offset,
400+ max,
401+ Rect::new(
402+ area.x + rect.x,
403+ area.y + rect.y - offset,
404+ rect.w,
405+ rect.h.min(area.h),
406+ ),
407+ ));
408+ }
409+ }
390 if let Some((cx, cy)) = learned.cursor {410 if let Some((cx, cy)) = learned.cursor {
391 if cy >= offset && cy < offset.saturating_add(area.h) {411 if cy >= offset && cy < offset.saturating_add(area.h) {
392 self.out.cursor = Some((area.x + cx, area.y + cy - offset));412 self.out.cursor = Some((area.x + cx, area.y + cy - offset));
modified crates/jolt-tui/src/term.rs +12 -2
@@ -19,6 +19,9 @@ use crossterm::{cursor, execute, queue, style};
1919 use crate::keys;
2020 use crate::screen::{attr, Color, Screen, Style};
2121
22+/// How far one notch of the wheel moves a list, in rows.
23+const WHEEL_ROWS: i32 = 3;
24+
2225 /// One thing that happened, in the vocabulary [`crate::ui::Ui`] takes.
2326 #[derive(Clone, Debug, PartialEq, Eq)]
2427 pub enum Input {
@@ -95,10 +98,17 @@ impl Term {
9598 MouseEventKind::Down(MouseButton::Left) => {
9699 out.push(Input::Click(mouse.column, mouse.row))
97100 }
101+ // Three rows a notch. A terminal reports one event per
102+ // detent and a row is the whole of a line here, so a row a
103+ // notch is a backlog that takes forty of them to cross a
104+ // screen — which reads as a list that will not move rather
105+ // than one moving slowly.
98106 MouseEventKind::ScrollDown => {
99- out.push(Input::Wheel(mouse.column, mouse.row, 1))
107+ out.push(Input::Wheel(mouse.column, mouse.row, WHEEL_ROWS))
108+ }
109+ MouseEventKind::ScrollUp => {
110+ out.push(Input::Wheel(mouse.column, mouse.row, -WHEEL_ROWS))
100111 }
101- MouseEventKind::ScrollUp => out.push(Input::Wheel(mouse.column, mouse.row, -1)),
102112 _ => {}
103113 },
104114 Ok(_) => {}
@@ -19,6 +19,9 @@ use crossterm::{cursor, execute, queue, style};
19 use crate::keys;19 use crate::keys;
20 use crate::screen::{attr, Color, Screen, Style};20 use crate::screen::{attr, Color, Screen, Style};
21 21
22+/// How far one notch of the wheel moves a list, in rows.
23+const WHEEL_ROWS: i32 = 3;
24+
22 /// One thing that happened, in the vocabulary [`crate::ui::Ui`] takes.25 /// One thing that happened, in the vocabulary [`crate::ui::Ui`] takes.
23 #[derive(Clone, Debug, PartialEq, Eq)]26 #[derive(Clone, Debug, PartialEq, Eq)]
24 pub enum Input {27 pub enum Input {
@@ -95,10 +98,17 @@ impl Term {
95 MouseEventKind::Down(MouseButton::Left) => {98 MouseEventKind::Down(MouseButton::Left) => {
96 out.push(Input::Click(mouse.column, mouse.row))99 out.push(Input::Click(mouse.column, mouse.row))
97 }100 }
101+ // Three rows a notch. A terminal reports one event per
102+ // detent and a row is the whole of a line here, so a row a
103+ // notch is a backlog that takes forty of them to cross a
104+ // screen — which reads as a list that will not move rather
105+ // than one moving slowly.
98 MouseEventKind::ScrollDown => {106 MouseEventKind::ScrollDown => {
99- out.push(Input::Wheel(mouse.column, mouse.row, 1))107+ out.push(Input::Wheel(mouse.column, mouse.row, WHEEL_ROWS))
108+ }
109+ MouseEventKind::ScrollUp => {
110+ out.push(Input::Wheel(mouse.column, mouse.row, -WHEEL_ROWS))
100 }111 }
101- MouseEventKind::ScrollUp => out.push(Input::Wheel(mouse.column, mouse.row, -1)),
102 _ => {}112 _ => {}
103 },113 },
104 Ok(_) => {}114 Ok(_) => {}
modified crates/jolt-tui/src/tests.rs +89 -0
@@ -304,6 +304,95 @@ fn a_scroll_shows_a_window_of_its_content_and_will_not_go_past_the_end() {
304304 assert_eq!(ui.tree.props(scroll).num("offset", -1.0), 3.0);
305305 }
306306
307+#[test]
308+fn the_wheel_scrolls_the_list_under_the_pointer_not_the_first_one_painted() {
309+ // frq's wide layout: the chats list down the left, the conversation beside
310+ // it, and both of them scrolls. A wheel over the conversation used to move
311+ // the sidebar — every scroll answered to a pointer anywhere on screen, so
312+ // the first one the walk reached took the lot.
313+ let mut ui = Ui::new(20, 3);
314+ let root = ui.tree.root();
315+ let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
316+ let sidebar = node(&mut ui, row, "scroll", &[]);
317+ ui.tree.set(sidebar, "width-request", Value::Num(10.0));
318+ for i in 0..6 {
319+ node(&mut ui, sidebar, "label", &[("label", &format!("chat {i}"))]);
320+ }
321+ let backlog = node(&mut ui, row, "scroll", &[]);
322+ ui.tree.set(backlog, "width-request", Value::Num(10.0));
323+ for i in 0..6 {
324+ node(&mut ui, backlog, "label", &[("label", &format!("line {i}"))]);
325+ }
326+ ui.frame();
327+ assert_eq!(ui.screen.line(0), "chat 0 line 0");
328+
329+ ui.wheel(15, 1, 2);
330+ ui.frame();
331+ assert_eq!(
332+ ui.screen.line(0),
333+ "chat 0 line 2",
334+ "the wheel was over the backlog, and only the backlog moved"
335+ );
336+
337+ ui.wheel(2, 1, 1);
338+ ui.frame();
339+ assert_eq!(
340+ ui.screen.line(0),
341+ "chat 1 line 2",
342+ "and over the sidebar it is the sidebar that moves"
343+ );
344+}
345+
346+#[test]
347+fn page_keys_move_the_backlog_while_the_entry_keeps_the_focus() {
348+ let mut ui = Ui::new(10, 4);
349+ let root = ui.tree.root();
350+ let scroll = node(&mut ui, root, "scroll", &[]);
351+ for i in 0..9 {
352+ node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]);
353+ }
354+ // What a reader is typing into, under the list they are reading.
355+ let entry = node(&mut ui, root, "entry", &[("text", "hi")]);
356+ ui.frame();
357+ assert_eq!(ui.screen.line(0), "row 0");
358+
359+ assert!(ui.key("page-down"), "the page was taken, not passed on");
360+ ui.frame();
361+ // A page is the viewport less the line that says where you were.
362+ assert_eq!(ui.screen.line(0), "row 2");
363+ assert_eq!(ui.focus(), entry, "and the entry still has the keyboard");
364+
365+ ui.key("page-up");
366+ ui.frame();
367+ assert_eq!(ui.screen.line(0), "row 0");
368+ assert!(events(&mut ui).iter().all(|(_, name, _, _)| name != "key"));
369+}
370+
371+#[test]
372+fn a_wheel_between_a_re_render_and_a_frame_moves_from_where_the_list_was() {
373+ // A render clears a node's props and writes them again; a wheel or a page
374+ // key that landed in that gap used to read an offset of zero and answer
375+ // with the top of the buffer.
376+ let mut ui = Ui::new(10, 3);
377+ let root = ui.tree.root();
378+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
379+ for i in 0..9 {
380+ node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]);
381+ }
382+ ui.frame();
383+ ui.wheel(1, 1, 4);
384+ ui.frame();
385+ assert_eq!(ui.screen.line(0), "row 4");
386+
387+ // The caller re-renders the list: props off, props on, no frame between.
388+ ui.tree.clear_props(scroll);
389+ ui.tree
390+ .set(scroll, "scroll-key", Value::Str("backlog".into()));
391+ ui.wheel(1, 1, 1);
392+ ui.frame();
393+ assert_eq!(ui.screen.line(0), "row 5");
394+}
395+
307396 #[test]
308397 fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() {
309398 let mut ui = Ui::new(14, 5);
@@ -304,6 +304,95 @@ fn a_scroll_shows_a_window_of_its_content_and_will_not_go_past_the_end() {
304 assert_eq!(ui.tree.props(scroll).num("offset", -1.0), 3.0);304 assert_eq!(ui.tree.props(scroll).num("offset", -1.0), 3.0);
305 }305 }
306 306
307+#[test]
308+fn the_wheel_scrolls_the_list_under_the_pointer_not_the_first_one_painted() {
309+ // frq's wide layout: the chats list down the left, the conversation beside
310+ // it, and both of them scrolls. A wheel over the conversation used to move
311+ // the sidebar — every scroll answered to a pointer anywhere on screen, so
312+ // the first one the walk reached took the lot.
313+ let mut ui = Ui::new(20, 3);
314+ let root = ui.tree.root();
315+ let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
316+ let sidebar = node(&mut ui, row, "scroll", &[]);
317+ ui.tree.set(sidebar, "width-request", Value::Num(10.0));
318+ for i in 0..6 {
319+ node(&mut ui, sidebar, "label", &[("label", &format!("chat {i}"))]);
320+ }
321+ let backlog = node(&mut ui, row, "scroll", &[]);
322+ ui.tree.set(backlog, "width-request", Value::Num(10.0));
323+ for i in 0..6 {
324+ node(&mut ui, backlog, "label", &[("label", &format!("line {i}"))]);
325+ }
326+ ui.frame();
327+ assert_eq!(ui.screen.line(0), "chat 0 line 0");
328+
329+ ui.wheel(15, 1, 2);
330+ ui.frame();
331+ assert_eq!(
332+ ui.screen.line(0),
333+ "chat 0 line 2",
334+ "the wheel was over the backlog, and only the backlog moved"
335+ );
336+
337+ ui.wheel(2, 1, 1);
338+ ui.frame();
339+ assert_eq!(
340+ ui.screen.line(0),
341+ "chat 1 line 2",
342+ "and over the sidebar it is the sidebar that moves"
343+ );
344+}
345+
346+#[test]
347+fn page_keys_move_the_backlog_while_the_entry_keeps_the_focus() {
348+ let mut ui = Ui::new(10, 4);
349+ let root = ui.tree.root();
350+ let scroll = node(&mut ui, root, "scroll", &[]);
351+ for i in 0..9 {
352+ node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]);
353+ }
354+ // What a reader is typing into, under the list they are reading.
355+ let entry = node(&mut ui, root, "entry", &[("text", "hi")]);
356+ ui.frame();
357+ assert_eq!(ui.screen.line(0), "row 0");
358+
359+ assert!(ui.key("page-down"), "the page was taken, not passed on");
360+ ui.frame();
361+ // A page is the viewport less the line that says where you were.
362+ assert_eq!(ui.screen.line(0), "row 2");
363+ assert_eq!(ui.focus(), entry, "and the entry still has the keyboard");
364+
365+ ui.key("page-up");
366+ ui.frame();
367+ assert_eq!(ui.screen.line(0), "row 0");
368+ assert!(events(&mut ui).iter().all(|(_, name, _, _)| name != "key"));
369+}
370+
371+#[test]
372+fn a_wheel_between_a_re_render_and_a_frame_moves_from_where_the_list_was() {
373+ // A render clears a node's props and writes them again; a wheel or a page
374+ // key that landed in that gap used to read an offset of zero and answer
375+ // with the top of the buffer.
376+ let mut ui = Ui::new(10, 3);
377+ let root = ui.tree.root();
378+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
379+ for i in 0..9 {
380+ node(&mut ui, scroll, "label", &[("label", &format!("row {i}"))]);
381+ }
382+ ui.frame();
383+ ui.wheel(1, 1, 4);
384+ ui.frame();
385+ assert_eq!(ui.screen.line(0), "row 4");
386+
387+ // The caller re-renders the list: props off, props on, no frame between.
388+ ui.tree.clear_props(scroll);
389+ ui.tree
390+ .set(scroll, "scroll-key", Value::Str("backlog".into()));
391+ ui.wheel(1, 1, 1);
392+ ui.frame();
393+ assert_eq!(ui.screen.line(0), "row 5");
394+}
395+
307 #[test]396 #[test]
308 fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() {397 fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() {
309 let mut ui = Ui::new(14, 5);398 let mut ui = Ui::new(14, 5);
modified crates/jolt-tui/src/ui.rs +71 -8
@@ -93,7 +93,7 @@ impl Ui {
9393 // of a screen shows nothing focused and the second one does.
9494 self.paint_once();
9595 }
96- for (node, offset, max) in self.painted.scrolled.clone() {
96+ for (node, offset, max, _area) in self.painted.scrolled.clone() {
9797 // Painting clamps the viewport to the content; write the clamped
9898 // value back so the caller's next `+1` starts from the truth.
9999 if self.tree.props(node).cells("offset", 0) != offset {
@@ -229,6 +229,15 @@ impl Ui {
229229 self.move_focus(false);
230230 return true;
231231 }
232+ // 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+ }
232241 "esc" => {
233242 // Esc belongs to the topmost overlay when there is one: that is
234243 // what closes a modal everywhere else.
@@ -459,13 +468,54 @@ impl Ui {
459468 let Some(node) = self.scroll_at(self.tree.root(), x, y) else {
460469 return false;
461470 };
462- let now = self.tree.props(node).cells("offset", 0) as i32;
471+ self.scroll_by(node, by)
472+ }
473+
474+ /// Page-up and page-down, for a reader with no pointer to point with.
475+ ///
476+ /// A terminal has no scrollbar to drag and the wheel is not on every desk,
477+ /// so these are the way back through a backlog; a page is the viewport
478+ /// less a row, which is the line that says where you were.
479+ ///
480+ /// The page belongs to the biggest thing on screen. There is no pointer to
481+ /// aim with and the focus is rarely inside the list — in frq it is the
482+ /// compose entry, under a backlog nobody would call the smaller half of
483+ /// the screen — so area is the question, and the reading list wins it.
484+ fn page(&mut self, up: bool) -> bool {
485+ let Some((node, height)) = self
486+ .painted
487+ .scrolled
488+ .iter()
489+ .max_by_key(|(_, _, _, area)| (area.w as u32) * (area.h as u32))
490+ .map(|(node, _, _, area)| (*node, area.h))
491+ else {
492+ return false;
493+ };
494+ let rows = height.saturating_sub(1).max(1) as i32;
495+ self.scroll_by(node, if up { -rows } else { rows })
496+ }
497+
498+ /// Move one `:scroll` by `by` rows, and remember where that put it.
499+ ///
500+ /// Where it is now comes from what was remembered under its key rather
501+ /// than from the node, because the caller may have re-rendered it since
502+ /// the last frame: a render clears a node's props and sets them again, and
503+ /// a scroll caught between the two reads as an offset of zero. That is how
504+ /// a page-down a moment after a message arrived answered with the top of
505+ /// the buffer — it was a page down from a list that had forgotten where it
506+ /// was. The prop is still written, for the paint that is about to read it.
507+ fn scroll_by(&mut self, node: u32, by: i32) -> bool {
508+ let key = self.scroll_key(node);
509+ let now = self
510+ .scrolls
511+ .get(&key)
512+ .map(|state| state.offset as i32)
513+ .unwrap_or_else(|| self.tree.props(node).cells("offset", 0) as i32);
463514 let to = (now + by).max(0) as f64;
464515 self.tree.set(node, "offset", Value::Num(to));
465516 // Unpin on the way up, and let the next frame decide whether this put
466517 // the reader back at the bottom — painting is what knows how far down
467518 // that is.
468- let key = self.scroll_key(node);
469519 self.scrolls.insert(
470520 key,
471521 Scrolled {
@@ -478,6 +528,12 @@ impl Ui {
478528 }
479529
480530 /// The innermost `:scroll` whose painted area holds this cell.
531+ ///
532+ /// Its own area, not the screen's. Asking whether the pointer was anywhere
533+ /// on the terminal is a question every scroll answers yes to, so the first
534+ /// one the walk reached took every wheel: on a wide screen that is the
535+ /// chats list, and a reader wheeling over the conversation beside it moved
536+ /// the sidebar instead — which reads as a backlog that will not scroll.
481537 fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> {
482538 for child in self.tree.children(id) {
483539 if let Some(inner) = self.scroll_at(child, x, y) {
@@ -485,11 +541,18 @@ impl Ui {
485541 }
486542 }
487543 // Scroll areas take no focus, so they are not in the hit list; the
488- // frame records the ones it painted, which is enough for a wheel.
489- let painted = self.painted.scrolled.iter().any(|(n, _, _)| *n == id);
490- if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y)
491- {
492- return Some(id);
544+ // frame records the ones it painted, and where, which is enough for a
545+ // wheel.
546+ let painted = self
547+ .painted
548+ .scrolled
549+ .iter()
550+ .find(|(n, _, _, _)| *n == id)
551+ .map(|(_, _, _, area)| *area);
552+ if let Some(area) = painted {
553+ if matches!(self.tree.tag(id), Tag::Scroll) && area.contains(x, y) {
554+ return Some(id);
555+ }
493556 }
494557 None
495558 }
@@ -93,7 +93,7 @@ impl Ui {
93 // of a screen shows nothing focused and the second one does.93 // of a screen shows nothing focused and the second one does.
94 self.paint_once();94 self.paint_once();
95 }95 }
96- for (node, offset, max) in self.painted.scrolled.clone() {96+ for (node, offset, max, _area) in self.painted.scrolled.clone() {
97 // Painting clamps the viewport to the content; write the clamped97 // Painting clamps the viewport to the content; write the clamped
98 // value back so the caller's next `+1` starts from the truth.98 // value back so the caller's next `+1` starts from the truth.
99 if self.tree.props(node).cells("offset", 0) != offset {99 if self.tree.props(node).cells("offset", 0) != offset {
@@ -229,6 +229,15 @@ impl Ui {
229 self.move_focus(false);229 self.move_focus(false);
230 return true;230 return true;
231 }231 }
232+ // 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+ }
232 "esc" => {241 "esc" => {
233 // Esc belongs to the topmost overlay when there is one: that is242 // Esc belongs to the topmost overlay when there is one: that is
234 // what closes a modal everywhere else.243 // what closes a modal everywhere else.
@@ -459,13 +468,54 @@ impl Ui {
459 let Some(node) = self.scroll_at(self.tree.root(), x, y) else {468 let Some(node) = self.scroll_at(self.tree.root(), x, y) else {
460 return false;469 return false;
461 };470 };
462- let now = self.tree.props(node).cells("offset", 0) as i32;471+ self.scroll_by(node, by)
472+ }
473+
474+ /// Page-up and page-down, for a reader with no pointer to point with.
475+ ///
476+ /// A terminal has no scrollbar to drag and the wheel is not on every desk,
477+ /// so these are the way back through a backlog; a page is the viewport
478+ /// less a row, which is the line that says where you were.
479+ ///
480+ /// The page belongs to the biggest thing on screen. There is no pointer to
481+ /// aim with and the focus is rarely inside the list — in frq it is the
482+ /// compose entry, under a backlog nobody would call the smaller half of
483+ /// the screen — so area is the question, and the reading list wins it.
484+ fn page(&mut self, up: bool) -> bool {
485+ let Some((node, height)) = self
486+ .painted
487+ .scrolled
488+ .iter()
489+ .max_by_key(|(_, _, _, area)| (area.w as u32) * (area.h as u32))
490+ .map(|(node, _, _, area)| (*node, area.h))
491+ else {
492+ return false;
493+ };
494+ let rows = height.saturating_sub(1).max(1) as i32;
495+ self.scroll_by(node, if up { -rows } else { rows })
496+ }
497+
498+ /// Move one `:scroll` by `by` rows, and remember where that put it.
499+ ///
500+ /// Where it is now comes from what was remembered under its key rather
501+ /// than from the node, because the caller may have re-rendered it since
502+ /// the last frame: a render clears a node's props and sets them again, and
503+ /// a scroll caught between the two reads as an offset of zero. That is how
504+ /// a page-down a moment after a message arrived answered with the top of
505+ /// the buffer — it was a page down from a list that had forgotten where it
506+ /// was. The prop is still written, for the paint that is about to read it.
507+ fn scroll_by(&mut self, node: u32, by: i32) -> bool {
508+ let key = self.scroll_key(node);
509+ let now = self
510+ .scrolls
511+ .get(&key)
512+ .map(|state| state.offset as i32)
513+ .unwrap_or_else(|| self.tree.props(node).cells("offset", 0) as i32);
463 let to = (now + by).max(0) as f64;514 let to = (now + by).max(0) as f64;
464 self.tree.set(node, "offset", Value::Num(to));515 self.tree.set(node, "offset", Value::Num(to));
465 // Unpin on the way up, and let the next frame decide whether this put516 // Unpin on the way up, and let the next frame decide whether this put
466 // the reader back at the bottom — painting is what knows how far down517 // the reader back at the bottom — painting is what knows how far down
467 // that is.518 // that is.
468- let key = self.scroll_key(node);
469 self.scrolls.insert(519 self.scrolls.insert(
470 key,520 key,
471 Scrolled {521 Scrolled {
@@ -478,6 +528,12 @@ impl Ui {
478 }528 }
479 529
480 /// The innermost `:scroll` whose painted area holds this cell.530 /// The innermost `:scroll` whose painted area holds this cell.
531+ ///
532+ /// Its own area, not the screen's. Asking whether the pointer was anywhere
533+ /// on the terminal is a question every scroll answers yes to, so the first
534+ /// one the walk reached took every wheel: on a wide screen that is the
535+ /// chats list, and a reader wheeling over the conversation beside it moved
536+ /// the sidebar instead — which reads as a backlog that will not scroll.
481 fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> {537 fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> {
482 for child in self.tree.children(id) {538 for child in self.tree.children(id) {
483 if let Some(inner) = self.scroll_at(child, x, y) {539 if let Some(inner) = self.scroll_at(child, x, y) {
@@ -485,11 +541,18 @@ impl Ui {
485 }541 }
486 }542 }
487 // Scroll areas take no focus, so they are not in the hit list; the543 // Scroll areas take no focus, so they are not in the hit list; the
488- // frame records the ones it painted, which is enough for a wheel.544+ // frame records the ones it painted, and where, which is enough for a
489- let painted = self.painted.scrolled.iter().any(|(n, _, _)| *n == id);545+ // wheel.
490- if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y)546+ let painted = self
491- {547+ .painted
492- return Some(id);548+ .scrolled
549+ .iter()
550+ .find(|(n, _, _, _)| *n == id)
551+ .map(|(_, _, _, area)| *area);
552+ if let Some(area) = painted {
553+ if matches!(self.tree.tag(id), Tag::Scroll) && area.contains(x, y) {
554+ return Some(id);
555+ }
493 }556 }
494 None557 None
495 }558 }