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>
a785201 parent: 258bbc5 modified
crates/jolt-tui/src/paint.rs +25 -5 | @@ -26,9 +26,10 @@ pub struct Painted { | ||
| 26 | 26 | /// How far each scroll node's viewport actually was, after clamping to the |
| 27 | 27 | /// content it had. Written back so a caller cannot scroll past the end. |
| 28 | 28 | /// 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)>, | |
| 32 | 33 | /// Where the cursor should sit — the focused entry's caret, if any. |
| 33 | 34 | pub cursor: Option<(u16, u16)>, |
| 34 | 35 | } |
| @@ -347,7 +348,7 @@ impl Painter<'_> { | ||
| 347 | 348 | .max(1); |
| 348 | 349 | let max_offset = content_h.saturating_sub(area.h); |
| 349 | 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 | 353 | let mut buffer = Screen::new(area.w, content_h); |
| 353 | 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 | 410 | if let Some((cx, cy)) = learned.cursor { |
| 391 | 411 | if cy >= offset && cy < offset.saturating_add(area.h) { |
| 392 | 412 | 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 the | 26 | /// 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, the | 28 | /// 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 | 29 | + /// 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}; | ||
| 19 | 19 | use crate::keys; |
| 20 | 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 | 25 | /// One thing that happened, in the vocabulary [`crate::ui::Ui`] takes. |
| 23 | 26 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 24 | 27 | pub enum Input { |
| @@ -95,10 +98,17 @@ impl Term { | ||
| 95 | 98 | MouseEventKind::Down(MouseButton::Left) => { |
| 96 | 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 | 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 | 114 | 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() { | ||
| 304 | 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 | 396 | #[test] |
| 308 | 397 | fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() { |
| 309 | 398 | 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 { | ||
| 93 | 93 | // of a screen shows nothing focused and the second one does. |
| 94 | 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 | 97 | // Painting clamps the viewport to the content; write the clamped |
| 98 | 98 | // value back so the caller's next `+1` starts from the truth. |
| 99 | 99 | if self.tree.props(node).cells("offset", 0) != offset { |
| @@ -229,6 +229,15 @@ impl Ui { | ||
| 229 | 229 | self.move_focus(false); |
| 230 | 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 | 241 | "esc" => { |
| 233 | 242 | // Esc belongs to the topmost overlay when there is one: that is |
| 234 | 243 | // what closes a modal everywhere else. |
| @@ -459,13 +468,54 @@ impl Ui { | ||
| 459 | 468 | let Some(node) = self.scroll_at(self.tree.root(), x, y) else { |
| 460 | 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 | 514 | let to = (now + by).max(0) as f64; |
| 464 | 515 | self.tree.set(node, "offset", Value::Num(to)); |
| 465 | 516 | // Unpin on the way up, and let the next frame decide whether this put |
| 466 | 517 | // the reader back at the bottom — painting is what knows how far down |
| 467 | 518 | // that is. |
| 468 | - let key = self.scroll_key(node); | |
| 469 | 519 | self.scrolls.insert( |
| 470 | 520 | key, |
| 471 | 521 | Scrolled { |
| @@ -478,6 +528,12 @@ impl Ui { | ||
| 478 | 528 | } |
| 479 | 529 | |
| 480 | 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 | 537 | fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> { |
| 482 | 538 | for child in self.tree.children(id) { |
| 483 | 539 | if let Some(inner) = self.scroll_at(child, x, y) { |
| @@ -485,11 +541,18 @@ impl Ui { | ||
| 485 | 541 | } |
| 486 | 542 | } |
| 487 | 543 | // 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 | + } | |
| 493 | 556 | } |
| 494 | 557 | None |
| 495 | 558 | } |
| @@ -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 clamped | 97 | // 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 is | 242 | // 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 put | 516 | // 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 down | 517 | // 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; the | 543 | // 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 | None | 557 | None |
| 495 | } | 558 | } |