Keep asking for the place a list opens at
A conversation opened at its newest line most of the time, and at the top of its backlog the rest of the time, and nothing about the room decided which. iced runs a widget operation against the tree the last `view` built. The commit a scroll area ARRIVES on is the one that builds it, so the ask that goes out for where it should open — its end, or the offset the reader left it at — can name a scrollable that does not exist yet, and is dropped where it falls. Whether it lands is a question of which of the two happens first. This is `reveal`'s problem at the other end: a row is measured by the layout that draws it, so the frame a jump is asked on is a frame too early. `reveal` answers it by asking again, and so does this. A list that has just mounted re-asserts where it opened for a few frames, and stops the moment it reports that it arrived — a mount settles in one or two, and what is left of the budget after that would only be spent fighting a reader who opened a room and scrolled straight away. Both asks name an absolute place, so one that already landed lands on the same place again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8e8cd51 parent: 4f920af modified
crates/jolt-cosmic/src/lib.rs +101 -1 | @@ -454,6 +454,37 @@ fn scroll_log() -> bool { | ||
| 454 | 454 | /// deciding nothing happened. |
| 455 | 455 | const REVEAL_TRIES: u8 = 20; |
| 456 | 456 | |
| 457 | +/// How many frames a list that has just mounted keeps asking for the place it | |
| 458 | +/// opens at. | |
| 459 | +/// | |
| 460 | +/// `REVEAL_TRIES`' reasoning at the other end of the same problem. iced runs a | |
| 461 | +/// widget operation against the tree the last `view` built, so the ask that | |
| 462 | +/// goes out on the commit a scroll area ARRIVES on can find no such widget to | |
| 463 | +/// act on — the scrollable it names is one this frame is only now building. | |
| 464 | +/// Whether it lands is then a question of which happens first, which is not a | |
| 465 | +/// question a room should open on: most of the time the conversation opened at | |
| 466 | +/// its newest line and sometimes it opened at the top of the backlog. | |
| 467 | +/// | |
| 468 | +/// Four frames rather than `reveal`'s twenty, and it stops the moment the list | |
| 469 | +/// reports that it arrived. A mount settles in one or two; what is left of the | |
| 470 | +/// budget after that is time spent fighting a reader who opened a room and | |
| 471 | +/// immediately scrolled, and there is no reason to spend more of it than the | |
| 472 | +/// mount actually needs. | |
| 473 | +const SETTLE_TRIES: u8 = 4; | |
| 474 | + | |
| 475 | +/// Whether the list called `name` is already where a mount sent it — its end | |
| 476 | +/// for `None`, that offset for `Some`. | |
| 477 | +/// | |
| 478 | +/// Read off the memo, which is to say off what the toolkit last reported, so | |
| 479 | +/// a list nobody has heard from yet has not arrived and is asked again. | |
| 480 | +fn settled(memo: Option<&ScrollMemo>, want: Option<f32>) -> bool { | |
| 481 | + match (memo, want) { | |
| 482 | + (None, _) => false, | |
| 483 | + (Some(m), None) => m.at_end, | |
| 484 | + (Some(m), Some(y)) => (m.offset_y - y).abs() <= AT_END_SLACK, | |
| 485 | + } | |
| 486 | +} | |
| 487 | + | |
| 457 | 488 | /// The row of the scroll area called `name` that is asking to be shown, as the |
| 458 | 489 | /// tree has it now. |
| 459 | 490 | /// |
| @@ -564,6 +595,10 @@ enum Message { | ||
| 564 | 595 | /// Show whichever row of this scroll area is asking to be shown, and how |
| 565 | 596 | /// many more frames to keep trying for. See `reveal`. |
| 566 | 597 | Reveal(String, u8), |
| 598 | + /// Put this scroll area where it opened at — its end for `None`, that | |
| 599 | + /// offset for `Some` — again, and how many more frames to keep at it. | |
| 600 | + /// See `SETTLE_TRIES`. | |
| 601 | + Settle(String, Option<f32>, u8), | |
| 567 | 602 | PickImage, |
| 568 | 603 | Picked(Option<PathBuf>), |
| 569 | 604 | } |
| @@ -629,7 +664,8 @@ impl App { | ||
| 629 | 664 | // A row asking to be shown is measured on the frame it appears, |
| 630 | 665 | // so the ask stands until the layout has a place for it — see |
| 631 | 666 | // `reveal`, which asks again rather than giving up. |
| 632 | - match scroll_move(&ask, memo) { | |
| 667 | + let moved = scroll_move(&ask, memo); | |
| 668 | + match moved { | |
| 633 | 669 | ScrollMove::Reveal(row) => tasks.push(reveal(ask.name.clone(), row, memo.height)), |
| 634 | 670 | ScrollMove::End => tasks.push(snap_to_end(&ask.name)), |
| 635 | 671 | ScrollMove::Restore(y) => tasks.push(iced_scrollable::scroll_to( |
| @@ -638,6 +674,22 @@ impl App { | ||
| 638 | 674 | )), |
| 639 | 675 | ScrollMove::Stay => {} |
| 640 | 676 | } |
| 677 | + // And keeps asking, while this is the commit the list arrived on: | |
| 678 | + // there may be no widget to have heard the ask above. `reveal` | |
| 679 | + // does its own asking again; the other two are asked for here. | |
| 680 | + if ask.fresh { | |
| 681 | + let want = match moved { | |
| 682 | + ScrollMove::End => Some(None), | |
| 683 | + ScrollMove::Restore(y) => Some(Some(y)), | |
| 684 | + _ => None, | |
| 685 | + }; | |
| 686 | + if let Some(want) = want { | |
| 687 | + let name = ask.name.clone(); | |
| 688 | + tasks.push(Task::future(async move { | |
| 689 | + cosmic::Action::App(Message::Settle(name, want, SETTLE_TRIES)) | |
| 690 | + })); | |
| 691 | + } | |
| 692 | + } | |
| 641 | 693 | } |
| 642 | 694 | // A list that was never scrolled keeps no memo worth the space; one |
| 643 | 695 | // that was keeps its place for when it comes back. |
| @@ -741,6 +793,28 @@ impl cosmic::Application for App { | ||
| 741 | 793 | // again, and keep looking for a few frames: a room the reader has |
| 742 | 794 | // only just been taken to has to be built before its lines have |
| 743 | 795 | // anywhere to be. |
| 796 | + // Asked again until the list says it arrived, or the budget is | |
| 797 | + // out. Idempotent either way: both asks name an absolute place, | |
| 798 | + // so one that already landed lands on the same place again. | |
| 799 | + Message::Settle(name, want, tries) => { | |
| 800 | + if settled(self.scrolls.get(&name), want) || tries == 0 { | |
| 801 | + return Task::none(); | |
| 802 | + } | |
| 803 | + let again = { | |
| 804 | + let name = name.clone(); | |
| 805 | + Task::future(async move { | |
| 806 | + cosmic::Action::App(Message::Settle(name, want, tries - 1)) | |
| 807 | + }) | |
| 808 | + }; | |
| 809 | + let now = match want { | |
| 810 | + None => snap_to_end(&name), | |
| 811 | + Some(y) => iced_scrollable::scroll_to( | |
| 812 | + scroll_id(&name), | |
| 813 | + AbsoluteOffset { x: None, y: Some(y) }, | |
| 814 | + ), | |
| 815 | + }; | |
| 816 | + return Task::batch([now, again]); | |
| 817 | + } | |
| 744 | 818 | Message::Reveal(name, tries) => { |
| 745 | 819 | let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height); |
| 746 | 820 | let place = asking_row(&self.tree, &name) |
| @@ -1938,6 +2012,32 @@ mod tests { | ||
| 1938 | 2012 | assert_eq!(report(&mut m, false), Some("away")); |
| 1939 | 2013 | } |
| 1940 | 2014 | |
| 2015 | + #[test] | |
| 2016 | + fn a_mount_keeps_asking_until_the_list_says_it_arrived() { | |
| 2017 | + // Nothing heard from yet: the ask above may have found no widget to | |
| 2018 | + // act on, so it stands. | |
| 2019 | + assert!(!settled(None, None)); | |
| 2020 | + assert!(!settled(None, Some(512.0))); | |
| 2021 | + | |
| 2022 | + // Reported somewhere else: still asking. | |
| 2023 | + let mut m = memo(); | |
| 2024 | + m.at_end = false; | |
| 2025 | + m.offset_y = 0.0; | |
| 2026 | + assert!(!settled(Some(&m), None)); | |
| 2027 | + assert!(!settled(Some(&m), Some(512.0))); | |
| 2028 | + | |
| 2029 | + // Arrived, and the asking stops. | |
| 2030 | + m.at_end = true; | |
| 2031 | + assert!(settled(Some(&m), None)); | |
| 2032 | + m.offset_y = 512.0; | |
| 2033 | + assert!(settled(Some(&m), Some(512.0))); | |
| 2034 | + // Within the slack a fractional offset leaves. | |
| 2035 | + m.offset_y = 511.0; | |
| 2036 | + assert!(settled(Some(&m), Some(512.0))); | |
| 2037 | + m.offset_y = 480.0; | |
| 2038 | + assert!(!settled(Some(&m), Some(512.0))); | |
| 2039 | + } | |
| 2040 | + | |
| 1941 | 2041 | #[test] |
| 1942 | 2042 | fn a_jump_is_the_counter_moving() { |
| 1943 | 2043 | let mut t = Tree::default(); |
| @@ -454,6 +454,37 @@ fn scroll_log() -> bool { | |||
| 454 | /// deciding nothing happened. | 454 | /// deciding nothing happened. |
| 455 | const REVEAL_TRIES: u8 = 20; | 455 | const REVEAL_TRIES: u8 = 20; |
| 456 | 456 | ||
| 457 | +/// How many frames a list that has just mounted keeps asking for the place it | ||
| 458 | +/// opens at. | ||
| 459 | +/// | ||
| 460 | +/// `REVEAL_TRIES`' reasoning at the other end of the same problem. iced runs a | ||
| 461 | +/// widget operation against the tree the last `view` built, so the ask that | ||
| 462 | +/// goes out on the commit a scroll area ARRIVES on can find no such widget to | ||
| 463 | +/// act on — the scrollable it names is one this frame is only now building. | ||
| 464 | +/// Whether it lands is then a question of which happens first, which is not a | ||
| 465 | +/// question a room should open on: most of the time the conversation opened at | ||
| 466 | +/// its newest line and sometimes it opened at the top of the backlog. | ||
| 467 | +/// | ||
| 468 | +/// Four frames rather than `reveal`'s twenty, and it stops the moment the list | ||
| 469 | +/// reports that it arrived. A mount settles in one or two; what is left of the | ||
| 470 | +/// budget after that is time spent fighting a reader who opened a room and | ||
| 471 | +/// immediately scrolled, and there is no reason to spend more of it than the | ||
| 472 | +/// mount actually needs. | ||
| 473 | +const SETTLE_TRIES: u8 = 4; | ||
| 474 | + | ||
| 475 | +/// Whether the list called `name` is already where a mount sent it — its end | ||
| 476 | +/// for `None`, that offset for `Some`. | ||
| 477 | +/// | ||
| 478 | +/// Read off the memo, which is to say off what the toolkit last reported, so | ||
| 479 | +/// a list nobody has heard from yet has not arrived and is asked again. | ||
| 480 | +fn settled(memo: Option<&ScrollMemo>, want: Option<f32>) -> bool { | ||
| 481 | + match (memo, want) { | ||
| 482 | + (None, _) => false, | ||
| 483 | + (Some(m), None) => m.at_end, | ||
| 484 | + (Some(m), Some(y)) => (m.offset_y - y).abs() <= AT_END_SLACK, | ||
| 485 | + } | ||
| 486 | +} | ||
| 487 | + | ||
| 457 | /// The row of the scroll area called `name` that is asking to be shown, as the | 488 | /// The row of the scroll area called `name` that is asking to be shown, as the |
| 458 | /// tree has it now. | 489 | /// tree has it now. |
| 459 | /// | 490 | /// |
| @@ -564,6 +595,10 @@ enum Message { | |||
| 564 | /// Show whichever row of this scroll area is asking to be shown, and how | 595 | /// Show whichever row of this scroll area is asking to be shown, and how |
| 565 | /// many more frames to keep trying for. See `reveal`. | 596 | /// many more frames to keep trying for. See `reveal`. |
| 566 | Reveal(String, u8), | 597 | Reveal(String, u8), |
| 598 | + /// Put this scroll area where it opened at — its end for `None`, that | ||
| 599 | + /// offset for `Some` — again, and how many more frames to keep at it. | ||
| 600 | + /// See `SETTLE_TRIES`. | ||
| 601 | + Settle(String, Option<f32>, u8), | ||
| 567 | PickImage, | 602 | PickImage, |
| 568 | Picked(Option<PathBuf>), | 603 | Picked(Option<PathBuf>), |
| 569 | } | 604 | } |
| @@ -629,7 +664,8 @@ impl App { | |||
| 629 | // A row asking to be shown is measured on the frame it appears, | 664 | // A row asking to be shown is measured on the frame it appears, |
| 630 | // so the ask stands until the layout has a place for it — see | 665 | // so the ask stands until the layout has a place for it — see |
| 631 | // `reveal`, which asks again rather than giving up. | 666 | // `reveal`, which asks again rather than giving up. |
| 632 | - match scroll_move(&ask, memo) { | 667 | + let moved = scroll_move(&ask, memo); |
| 668 | + match moved { | ||
| 633 | ScrollMove::Reveal(row) => tasks.push(reveal(ask.name.clone(), row, memo.height)), | 669 | ScrollMove::Reveal(row) => tasks.push(reveal(ask.name.clone(), row, memo.height)), |
| 634 | ScrollMove::End => tasks.push(snap_to_end(&ask.name)), | 670 | ScrollMove::End => tasks.push(snap_to_end(&ask.name)), |
| 635 | ScrollMove::Restore(y) => tasks.push(iced_scrollable::scroll_to( | 671 | ScrollMove::Restore(y) => tasks.push(iced_scrollable::scroll_to( |
| @@ -638,6 +674,22 @@ impl App { | |||
| 638 | )), | 674 | )), |
| 639 | ScrollMove::Stay => {} | 675 | ScrollMove::Stay => {} |
| 640 | } | 676 | } |
| 677 | + // And keeps asking, while this is the commit the list arrived on: | ||
| 678 | + // there may be no widget to have heard the ask above. `reveal` | ||
| 679 | + // does its own asking again; the other two are asked for here. | ||
| 680 | + if ask.fresh { | ||
| 681 | + let want = match moved { | ||
| 682 | + ScrollMove::End => Some(None), | ||
| 683 | + ScrollMove::Restore(y) => Some(Some(y)), | ||
| 684 | + _ => None, | ||
| 685 | + }; | ||
| 686 | + if let Some(want) = want { | ||
| 687 | + let name = ask.name.clone(); | ||
| 688 | + tasks.push(Task::future(async move { | ||
| 689 | + cosmic::Action::App(Message::Settle(name, want, SETTLE_TRIES)) | ||
| 690 | + })); | ||
| 691 | + } | ||
| 692 | + } | ||
| 641 | } | 693 | } |
| 642 | // A list that was never scrolled keeps no memo worth the space; one | 694 | // A list that was never scrolled keeps no memo worth the space; one |
| 643 | // that was keeps its place for when it comes back. | 695 | // that was keeps its place for when it comes back. |
| @@ -741,6 +793,28 @@ impl cosmic::Application for App { | |||
| 741 | // again, and keep looking for a few frames: a room the reader has | 793 | // again, and keep looking for a few frames: a room the reader has |
| 742 | // only just been taken to has to be built before its lines have | 794 | // only just been taken to has to be built before its lines have |
| 743 | // anywhere to be. | 795 | // anywhere to be. |
| 796 | + // Asked again until the list says it arrived, or the budget is | ||
| 797 | + // out. Idempotent either way: both asks name an absolute place, | ||
| 798 | + // so one that already landed lands on the same place again. | ||
| 799 | + Message::Settle(name, want, tries) => { | ||
| 800 | + if settled(self.scrolls.get(&name), want) || tries == 0 { | ||
| 801 | + return Task::none(); | ||
| 802 | + } | ||
| 803 | + let again = { | ||
| 804 | + let name = name.clone(); | ||
| 805 | + Task::future(async move { | ||
| 806 | + cosmic::Action::App(Message::Settle(name, want, tries - 1)) | ||
| 807 | + }) | ||
| 808 | + }; | ||
| 809 | + let now = match want { | ||
| 810 | + None => snap_to_end(&name), | ||
| 811 | + Some(y) => iced_scrollable::scroll_to( | ||
| 812 | + scroll_id(&name), | ||
| 813 | + AbsoluteOffset { x: None, y: Some(y) }, | ||
| 814 | + ), | ||
| 815 | + }; | ||
| 816 | + return Task::batch([now, again]); | ||
| 817 | + } | ||
| 744 | Message::Reveal(name, tries) => { | 818 | Message::Reveal(name, tries) => { |
| 745 | let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height); | 819 | let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height); |
| 746 | let place = asking_row(&self.tree, &name) | 820 | let place = asking_row(&self.tree, &name) |
| @@ -1938,6 +2012,32 @@ mod tests { | |||
| 1938 | assert_eq!(report(&mut m, false), Some("away")); | 2012 | assert_eq!(report(&mut m, false), Some("away")); |
| 1939 | } | 2013 | } |
| 1940 | 2014 | ||
| 2015 | + #[test] | ||
| 2016 | + fn a_mount_keeps_asking_until_the_list_says_it_arrived() { | ||
| 2017 | + // Nothing heard from yet: the ask above may have found no widget to | ||
| 2018 | + // act on, so it stands. | ||
| 2019 | + assert!(!settled(None, None)); | ||
| 2020 | + assert!(!settled(None, Some(512.0))); | ||
| 2021 | + | ||
| 2022 | + // Reported somewhere else: still asking. | ||
| 2023 | + let mut m = memo(); | ||
| 2024 | + m.at_end = false; | ||
| 2025 | + m.offset_y = 0.0; | ||
| 2026 | + assert!(!settled(Some(&m), None)); | ||
| 2027 | + assert!(!settled(Some(&m), Some(512.0))); | ||
| 2028 | + | ||
| 2029 | + // Arrived, and the asking stops. | ||
| 2030 | + m.at_end = true; | ||
| 2031 | + assert!(settled(Some(&m), None)); | ||
| 2032 | + m.offset_y = 512.0; | ||
| 2033 | + assert!(settled(Some(&m), Some(512.0))); | ||
| 2034 | + // Within the slack a fractional offset leaves. | ||
| 2035 | + m.offset_y = 511.0; | ||
| 2036 | + assert!(settled(Some(&m), Some(512.0))); | ||
| 2037 | + m.offset_y = 480.0; | ||
| 2038 | + assert!(!settled(Some(&m), Some(512.0))); | ||
| 2039 | + } | ||
| 2040 | + | ||
| 1941 | #[test] | 2041 | #[test] |
| 1942 | fn a_jump_is_the_counter_moving() { | 2042 | fn a_jump_is_the_counter_moving() { |
| 1943 | let mut t = Tree::default(); | 2043 | let mut t = Tree::default(); |