Scroll to the row that asked, where it actually is
`scroll-here` was answered with the row's index over the row count, applied as a relative offset. A relative offset is a fraction of the scroll RANGE and not of the content, and the two agree only when the viewport is exactly one row tall — so even a list of identical rows landed up to a viewport early, worst in the middle and right at the two ends. frq's backlog is not identical rows either: a one-line message sits next to a picture at whatever height the window allows. A jump to a line three days back put it off the screen as often as on it. Where a row is is a question for the layout, so this asks the layout. Every row of a scroll area carries an id; an `Operation` reads the row's bounds, the viewport's, and how far it is scrolled already, and works out the offset that puts the row in the MIDDLE of the viewport — a line answered three days ago is read with what was said around it, and a jump that pins it to the ceiling shows only what came after. Every row carries one, not only the row that is asking. iced tells two widgets apart by the type of their state, and a wrapper with no state is indistinguishable from the column it wraps: a wrapper that came and went as a row became the target had the old tree reused for the new shape, and the state under it read as something it was not. That is a panic in the middle of a layout, not a wrong answer. Whether a node is a row of a scroll area is a question about where it sits, and where it sits does not change under it. And the ask stands while the row is asking, rather than firing once on the commit the prop arrives. A row that is not in the tree yet when the ask arrives — a conversation the client has only just switched to — was never scrolled to at all, and the ask is over in half a second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0fb0175 parent: 228672d modified
crates/jolt-cosmic/src/lib.rs +160 -24 | @@ -36,6 +36,8 @@ use cosmic::iced::futures::{Stream, StreamExt}; | ||
| 36 | 36 | use cosmic::iced::widget::container::Style as ContainerStyle; |
| 37 | 37 | use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport}; |
| 38 | 38 | use cosmic::iced::widget::text::Wrapping; |
| 39 | +use cosmic::iced::advanced::widget as advanced; | |
| 40 | +use cosmic::iced::advanced::widget::operation::scrollable as iced_operation; | |
| 39 | 41 | use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription}; |
| 40 | 42 | use cosmic::widget::{self, Column, Row}; |
| 41 | 43 | use cosmic::{ApplicationExt, Element}; |
| @@ -263,9 +265,8 @@ struct ScrollAsk { | ||
| 263 | 265 | tick_before: Option<f64>, |
| 264 | 266 | /// Not in the tree before this commit: mounted, or mounted again. |
| 265 | 267 | fresh: bool, |
| 266 | - /// Where, as a fraction of the list, a node that has just been asked to be | |
| 267 | - /// shown sits. | |
| 268 | - reveal: Option<f32>, | |
| 268 | + /// The node asking to be shown, if one is. | |
| 269 | + reveal: Option<i32>, | |
| 269 | 270 | } |
| 270 | 271 | |
| 271 | 272 | /// Every scroll area in `now`, and what changed about each since `before`. |
| @@ -283,23 +284,28 @@ fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> { | ||
| 283 | 284 | return; |
| 284 | 285 | } |
| 285 | 286 | let name = scroll_name(n, id); |
| 286 | - // A row asking to be shown, that was not asking last commit. The | |
| 287 | - // position is the index of the top-level row holding it: exact for a | |
| 288 | - // list of rows the same height and close for frq's backlog, and there | |
| 289 | - // is no layout to ask from here. | |
| 290 | - let count = n.children.len(); | |
| 287 | + // The ROW holding whatever asked to be shown. The row and not the | |
| 288 | + // asking node itself, because the row is what carries an id — see | |
| 289 | + // `element` — and the id is how the layout is asked where it is. | |
| 290 | + // | |
| 291 | + // This used to answer with the row's index over the row count, which | |
| 292 | + // is a fraction of the SCROLL RANGE rather than of the content — the | |
| 293 | + // two agree only when the viewport is exactly one row tall — and it | |
| 294 | + // assumed every row the same height besides, in a backlog that puts a | |
| 295 | + // one-line message next to a picture. The landing was out by up to a | |
| 296 | + // viewport, worst in the middle of a list. | |
| 297 | + // | |
| 298 | + // While it is asking, not only on the commit it starts: a row that is | |
| 299 | + // not in the tree yet when the ask arrives would otherwise never be | |
| 300 | + // scrolled to at all, and the ask is over in half a second. | |
| 291 | 301 | let mut reveal = None; |
| 292 | - for (index, row) in n.children.iter().enumerate() { | |
| 302 | + for row in &n.children { | |
| 293 | 303 | let mut asked = false; |
| 294 | - walk(now, *row, &mut |nid, node| { | |
| 295 | - let here = node.bool("scroll-here") == Some(true); | |
| 296 | - let was = before | |
| 297 | - .get(nid) | |
| 298 | - .is_some_and(|old| old.bool("scroll-here") == Some(true)); | |
| 299 | - asked |= here && !was; | |
| 304 | + walk(now, *row, &mut |_, node| { | |
| 305 | + asked |= node.bool("scroll-here") == Some(true); | |
| 300 | 306 | }); |
| 301 | 307 | if asked { |
| 302 | - reveal = Some(index as f32 / (count.saturating_sub(1).max(1)) as f32); | |
| 308 | + reveal = Some(*row); | |
| 303 | 309 | break; |
| 304 | 310 | } |
| 305 | 311 | } |
| @@ -319,6 +325,88 @@ fn snap_to_end(name: &str) -> Task<Message> { | ||
| 319 | 325 | iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) }) |
| 320 | 326 | } |
| 321 | 327 | |
| 328 | +/// What a row that can be scrolled to is known by. | |
| 329 | +fn here_id(node: i32) -> widget::Id { | |
| 330 | + widget::Id::new(format!("jolt-here-{node}")) | |
| 331 | +} | |
| 332 | + | |
| 333 | +/// Where `node` sits inside the scroll area called `name`, measured, and then | |
| 334 | +/// the scroll put there. | |
| 335 | +/// | |
| 336 | +/// Two steps because the answer is not in the tree. The tree says which row | |
| 337 | +/// asked; only the layout knows how far down the content it ended up, and the | |
| 338 | +/// layout is a thing you can ask questions of exactly once a frame, through an | |
| 339 | +/// `Operation`. So the operation reads the row's bounds and the scroll area's, | |
| 340 | +/// works the offset out, and hands it back as a message that scrolls there. | |
| 341 | +/// | |
| 342 | +/// The row is put in the MIDDLE of the viewport rather than against its top | |
| 343 | +/// edge. A line answered three days ago is read with what was said around it, | |
| 344 | +/// and a jump that pins it to the ceiling shows only what came after. | |
| 345 | +fn reveal(name: String, node: i32) -> Task<Message> { | |
| 346 | + let scroll = scroll_id(&name); | |
| 347 | + let target = here_id(node); | |
| 348 | + | |
| 349 | + struct Measure { | |
| 350 | + scroll: widget::Id, | |
| 351 | + target: widget::Id, | |
| 352 | + /// The scroll area: where its viewport starts, how tall it is, and | |
| 353 | + /// how far it is scrolled already. | |
| 354 | + view: Option<(f32, f32, f32)>, | |
| 355 | + /// The row: where it starts and how tall it is. | |
| 356 | + row: Option<(f32, f32)>, | |
| 357 | + } | |
| 358 | + | |
| 359 | + impl advanced::Operation<f32> for Measure { | |
| 360 | + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn advanced::Operation<f32>)) { | |
| 361 | + operate(self); | |
| 362 | + } | |
| 363 | + | |
| 364 | + fn container(&mut self, id: Option<&widget::Id>, bounds: cosmic::iced::Rectangle) { | |
| 365 | + if id == Some(&self.target) { | |
| 366 | + self.row = Some((bounds.y, bounds.height)); | |
| 367 | + } | |
| 368 | + } | |
| 369 | + | |
| 370 | + fn scrollable( | |
| 371 | + &mut self, | |
| 372 | + id: Option<&widget::Id>, | |
| 373 | + bounds: cosmic::iced::Rectangle, | |
| 374 | + _content: cosmic::iced::Rectangle, | |
| 375 | + translation: cosmic::iced::Vector, | |
| 376 | + _state: &mut dyn iced_operation::Scrollable, | |
| 377 | + ) { | |
| 378 | + if id == Some(&self.scroll) { | |
| 379 | + self.view = Some((bounds.y, bounds.height, translation.y)); | |
| 380 | + } | |
| 381 | + } | |
| 382 | + | |
| 383 | + fn finish(&self) -> advanced::operation::Outcome<f32> { | |
| 384 | + match (self.view, self.row) { | |
| 385 | + // Both are in window coordinates, so the row's place in the | |
| 386 | + // content is how far it is below the viewport's top edge plus | |
| 387 | + // how far the viewport has already been scrolled. | |
| 388 | + (Some((view_y, view_h, scrolled)), Some((row_y, row_h))) => { | |
| 389 | + let top = row_y - view_y + scrolled; | |
| 390 | + let centred = top - (view_h - row_h).max(0.0) / 2.0; | |
| 391 | + advanced::operation::Outcome::Some(centred.max(0.0)) | |
| 392 | + } | |
| 393 | + // The row is not laid out yet — it has only just been mounted, | |
| 394 | + // or it is not in this scroll area at all. Nothing to say, and | |
| 395 | + // the next commit asks again. | |
| 396 | + _ => advanced::operation::Outcome::None, | |
| 397 | + } | |
| 398 | + } | |
| 399 | + } | |
| 400 | + | |
| 401 | + advanced::operate(Measure { | |
| 402 | + scroll, | |
| 403 | + target, | |
| 404 | + view: None, | |
| 405 | + row: None, | |
| 406 | + }) | |
| 407 | + .map(move |y| cosmic::Action::App(Message::Revealed(name.clone(), y))) | |
| 408 | +} | |
| 409 | + | |
| 322 | 410 | // --- the app ----------------------------------------------------------------- |
| 323 | 411 | |
| 324 | 412 | struct App { |
| @@ -343,6 +431,9 @@ enum Message { | ||
| 343 | 431 | Hover(i32), |
| 344 | 432 | Unhover(i32), |
| 345 | 433 | Scrolled(i32, String, Viewport), |
| 434 | + /// A scroll area, and how far down its content the row that asked to be | |
| 435 | + /// shown was measured to be. | |
| 436 | + Revealed(String, f32), | |
| 346 | 437 | PickImage, |
| 347 | 438 | Picked(Option<PathBuf>), |
| 348 | 439 | } |
| @@ -404,12 +495,9 @@ impl App { | ||
| 404 | 495 | offset_y: 0.0, |
| 405 | 496 | }); |
| 406 | 497 | let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before; |
| 407 | - if let Some(fraction) = ask.reveal { | |
| 498 | + if let Some(node) = ask.reveal { | |
| 408 | 499 | memo.at_end = false; |
| 409 | - tasks.push(iced_scrollable::snap_to( | |
| 410 | - scroll_id(&ask.name), | |
| 411 | - RelativeOffset { x: None, y: Some(fraction) }, | |
| 412 | - )); | |
| 500 | + tasks.push(reveal(ask.name.clone(), node)); | |
| 413 | 501 | } else if jumped || (ask.stick && memo.at_end) { |
| 414 | 502 | memo.at_end = true; |
| 415 | 503 | tasks.push(snap_to_end(&ask.name)); |
| @@ -519,6 +607,17 @@ impl cosmic::Application for App { | ||
| 519 | 607 | Message::Hover(node) => post(node, "hover", String::new(), 0.0), |
| 520 | 608 | Message::Unhover(node) => post(node, "unhover", String::new(), 0.0), |
| 521 | 609 | Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport), |
| 610 | + // The measurement came back: put the scroll where it says. The | |
| 611 | + // memo is written too, so a list that is unmounted and comes back | |
| 612 | + // opens where the jump left it rather than where it was before. | |
| 613 | + Message::Revealed(name, y) => { | |
| 614 | + let id = scroll_id(&name); | |
| 615 | + if let Some(memo) = self.scrolls.get_mut(&name) { | |
| 616 | + memo.at_end = false; | |
| 617 | + memo.offset_y = y; | |
| 618 | + } | |
| 619 | + return iced_scrollable::scroll_to(id, AbsoluteOffset { x: None, y: Some(y) }); | |
| 620 | + } | |
| 522 | 621 | // The desktop's own chooser, through the portal, on libcosmic's |
| 523 | 622 | // executor: it is a D-Bus round trip, and the window keeps |
| 524 | 623 | // painting while it is open. |
| @@ -1085,6 +1184,23 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | ||
| 1085 | 1184 | .into(), |
| 1086 | 1185 | }; |
| 1087 | 1186 | |
| 1187 | + // Every row of a scroll area carries an id, so the operation in `reveal` | |
| 1188 | + // can find out where one of them actually is. | |
| 1189 | + // | |
| 1190 | + // Every row, and not only the row that is asking to be shown. iced tells | |
| 1191 | + // two widgets apart by their state's type, and a wrapper with no state of | |
| 1192 | + // its own is indistinguishable from the column it wraps — so a wrapper | |
| 1193 | + // that came and went as a row became the target would have the old tree | |
| 1194 | + // reused for the new shape, and the state under it would be read as | |
| 1195 | + // something it is not. That is not a wrong answer, it is a panic in the | |
| 1196 | + // middle of a layout. Whether a node is a row of a scroll area is a | |
| 1197 | + // question about where it sits, and where it sits does not change under | |
| 1198 | + // it. | |
| 1199 | + let el = match t.get(n.parent) { | |
| 1200 | + Some(parent) if parent.tag == "scroll" => widget::id_container(el, here_id(id)).into(), | |
| 1201 | + _ => el, | |
| 1202 | + }; | |
| 1203 | + | |
| 1088 | 1204 | // The containers and the entry size themselves above; anything else asked |
| 1089 | 1205 | // for a width gets it from a wrapper. |
| 1090 | 1206 | match (n.tag.as_str(), width_request(n)) { |
| @@ -1521,7 +1637,7 @@ mod tests { | ||
| 1521 | 1637 | } |
| 1522 | 1638 | |
| 1523 | 1639 | #[test] |
| 1524 | - fn a_new_scroll_here_asks_for_its_row_and_a_standing_one_does_not() { | |
| 1640 | + fn scroll_here_asks_with_the_row_itself_and_goes_on_asking() { | |
| 1525 | 1641 | let mut t = Tree::default(); |
| 1526 | 1642 | let root = t.root(); |
| 1527 | 1643 | let list = node(&mut t, root, "scroll"); |
| @@ -1530,13 +1646,33 @@ mod tests { | ||
| 1530 | 1646 | let before = t.clone(); |
| 1531 | 1647 | t.set(rows[3], "scroll-here", Prop::Bool(true)); |
| 1532 | 1648 | |
| 1649 | + // The row, not a guess at where it sits: only the layout knows that, | |
| 1650 | + // and `reveal` is what goes and asks it. Here the row and the node | |
| 1651 | + // asking are the same; a message row asks from inside itself, and the | |
| 1652 | + // answer is still the row, since the row is what carries an id. | |
| 1533 | 1653 | let asks = scroll_asks(&before, &t); |
| 1534 | 1654 | assert_eq!(asks.len(), 1); |
| 1535 | 1655 | assert!(!asks[0].fresh); |
| 1536 | - assert_eq!(asks[0].reveal, Some(0.75)); | |
| 1656 | + assert_eq!(asks[0].reveal, Some(rows[3])); | |
| 1537 | 1657 | |
| 1658 | + // And it asks again while the row is still asking. It used to ask only | |
| 1659 | + // on the commit the prop arrived, which meant a row not laid out yet — | |
| 1660 | + // a conversation the client has only just switched to — was never | |
| 1661 | + // scrolled to at all. | |
| 1538 | 1662 | let again = scroll_asks(&t, &t); |
| 1539 | - assert_eq!(again[0].reveal, None); | |
| 1663 | + assert_eq!(again[0].reveal, Some(rows[3])); | |
| 1664 | + | |
| 1665 | + // Nothing asking, nothing to reveal. | |
| 1666 | + t.set(rows[3], "scroll-here", Prop::Bool(false)); | |
| 1667 | + let quiet = scroll_asks(&t, &t); | |
| 1668 | + assert_eq!(quiet[0].reveal, None); | |
| 1669 | + | |
| 1670 | + // And a node asking from deeper inside a row still answers with the | |
| 1671 | + // row: that is the thing the layout can be asked about. | |
| 1672 | + let inner = node(&mut t, rows[1], "vbox"); | |
| 1673 | + t.set(inner, "scroll-here", Prop::Bool(true)); | |
| 1674 | + let deep = scroll_asks(&t, &t); | |
| 1675 | + assert_eq!(deep[0].reveal, Some(rows[1])); | |
| 1540 | 1676 | } |
| 1541 | 1677 | |
| 1542 | 1678 | #[test] |
| @@ -36,6 +36,8 @@ use cosmic::iced::futures::{Stream, StreamExt}; | |||
| 36 | use cosmic::iced::widget::container::Style as ContainerStyle; | 36 | use cosmic::iced::widget::container::Style as ContainerStyle; |
| 37 | use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport}; | 37 | use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport}; |
| 38 | use cosmic::iced::widget::text::Wrapping; | 38 | use cosmic::iced::widget::text::Wrapping; |
| 39 | +use cosmic::iced::advanced::widget as advanced; | ||
| 40 | +use cosmic::iced::advanced::widget::operation::scrollable as iced_operation; | ||
| 39 | use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription}; | 41 | use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription}; |
| 40 | use cosmic::widget::{self, Column, Row}; | 42 | use cosmic::widget::{self, Column, Row}; |
| 41 | use cosmic::{ApplicationExt, Element}; | 43 | use cosmic::{ApplicationExt, Element}; |
| @@ -263,9 +265,8 @@ struct ScrollAsk { | |||
| 263 | tick_before: Option<f64>, | 265 | tick_before: Option<f64>, |
| 264 | /// Not in the tree before this commit: mounted, or mounted again. | 266 | /// Not in the tree before this commit: mounted, or mounted again. |
| 265 | fresh: bool, | 267 | fresh: bool, |
| 266 | - /// Where, as a fraction of the list, a node that has just been asked to be | 268 | + /// The node asking to be shown, if one is. |
| 267 | - /// shown sits. | 269 | + reveal: Option<i32>, |
| 268 | - reveal: Option<f32>, | ||
| 269 | } | 270 | } |
| 270 | 271 | ||
| 271 | /// Every scroll area in `now`, and what changed about each since `before`. | 272 | /// Every scroll area in `now`, and what changed about each since `before`. |
| @@ -283,23 +284,28 @@ fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> { | |||
| 283 | return; | 284 | return; |
| 284 | } | 285 | } |
| 285 | let name = scroll_name(n, id); | 286 | let name = scroll_name(n, id); |
| 286 | - // A row asking to be shown, that was not asking last commit. The | 287 | + // The ROW holding whatever asked to be shown. The row and not the |
| 287 | - // position is the index of the top-level row holding it: exact for a | 288 | + // asking node itself, because the row is what carries an id — see |
| 288 | - // list of rows the same height and close for frq's backlog, and there | 289 | + // `element` — and the id is how the layout is asked where it is. |
| 289 | - // is no layout to ask from here. | 290 | + // |
| 290 | - let count = n.children.len(); | 291 | + // This used to answer with the row's index over the row count, which |
| 292 | + // is a fraction of the SCROLL RANGE rather than of the content — the | ||
| 293 | + // two agree only when the viewport is exactly one row tall — and it | ||
| 294 | + // assumed every row the same height besides, in a backlog that puts a | ||
| 295 | + // one-line message next to a picture. The landing was out by up to a | ||
| 296 | + // viewport, worst in the middle of a list. | ||
| 297 | + // | ||
| 298 | + // While it is asking, not only on the commit it starts: a row that is | ||
| 299 | + // not in the tree yet when the ask arrives would otherwise never be | ||
| 300 | + // scrolled to at all, and the ask is over in half a second. | ||
| 291 | let mut reveal = None; | 301 | let mut reveal = None; |
| 292 | - for (index, row) in n.children.iter().enumerate() { | 302 | + for row in &n.children { |
| 293 | let mut asked = false; | 303 | let mut asked = false; |
| 294 | - walk(now, *row, &mut |nid, node| { | 304 | + walk(now, *row, &mut |_, node| { |
| 295 | - let here = node.bool("scroll-here") == Some(true); | 305 | + asked |= node.bool("scroll-here") == Some(true); |
| 296 | - let was = before | ||
| 297 | - .get(nid) | ||
| 298 | - .is_some_and(|old| old.bool("scroll-here") == Some(true)); | ||
| 299 | - asked |= here && !was; | ||
| 300 | }); | 306 | }); |
| 301 | if asked { | 307 | if asked { |
| 302 | - reveal = Some(index as f32 / (count.saturating_sub(1).max(1)) as f32); | 308 | + reveal = Some(*row); |
| 303 | break; | 309 | break; |
| 304 | } | 310 | } |
| 305 | } | 311 | } |
| @@ -319,6 +325,88 @@ fn snap_to_end(name: &str) -> Task<Message> { | |||
| 319 | iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) }) | 325 | iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) }) |
| 320 | } | 326 | } |
| 321 | 327 | ||
| 328 | +/// What a row that can be scrolled to is known by. | ||
| 329 | +fn here_id(node: i32) -> widget::Id { | ||
| 330 | + widget::Id::new(format!("jolt-here-{node}")) | ||
| 331 | +} | ||
| 332 | + | ||
| 333 | +/// Where `node` sits inside the scroll area called `name`, measured, and then | ||
| 334 | +/// the scroll put there. | ||
| 335 | +/// | ||
| 336 | +/// Two steps because the answer is not in the tree. The tree says which row | ||
| 337 | +/// asked; only the layout knows how far down the content it ended up, and the | ||
| 338 | +/// layout is a thing you can ask questions of exactly once a frame, through an | ||
| 339 | +/// `Operation`. So the operation reads the row's bounds and the scroll area's, | ||
| 340 | +/// works the offset out, and hands it back as a message that scrolls there. | ||
| 341 | +/// | ||
| 342 | +/// The row is put in the MIDDLE of the viewport rather than against its top | ||
| 343 | +/// edge. A line answered three days ago is read with what was said around it, | ||
| 344 | +/// and a jump that pins it to the ceiling shows only what came after. | ||
| 345 | +fn reveal(name: String, node: i32) -> Task<Message> { | ||
| 346 | + let scroll = scroll_id(&name); | ||
| 347 | + let target = here_id(node); | ||
| 348 | + | ||
| 349 | + struct Measure { | ||
| 350 | + scroll: widget::Id, | ||
| 351 | + target: widget::Id, | ||
| 352 | + /// The scroll area: where its viewport starts, how tall it is, and | ||
| 353 | + /// how far it is scrolled already. | ||
| 354 | + view: Option<(f32, f32, f32)>, | ||
| 355 | + /// The row: where it starts and how tall it is. | ||
| 356 | + row: Option<(f32, f32)>, | ||
| 357 | + } | ||
| 358 | + | ||
| 359 | + impl advanced::Operation<f32> for Measure { | ||
| 360 | + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn advanced::Operation<f32>)) { | ||
| 361 | + operate(self); | ||
| 362 | + } | ||
| 363 | + | ||
| 364 | + fn container(&mut self, id: Option<&widget::Id>, bounds: cosmic::iced::Rectangle) { | ||
| 365 | + if id == Some(&self.target) { | ||
| 366 | + self.row = Some((bounds.y, bounds.height)); | ||
| 367 | + } | ||
| 368 | + } | ||
| 369 | + | ||
| 370 | + fn scrollable( | ||
| 371 | + &mut self, | ||
| 372 | + id: Option<&widget::Id>, | ||
| 373 | + bounds: cosmic::iced::Rectangle, | ||
| 374 | + _content: cosmic::iced::Rectangle, | ||
| 375 | + translation: cosmic::iced::Vector, | ||
| 376 | + _state: &mut dyn iced_operation::Scrollable, | ||
| 377 | + ) { | ||
| 378 | + if id == Some(&self.scroll) { | ||
| 379 | + self.view = Some((bounds.y, bounds.height, translation.y)); | ||
| 380 | + } | ||
| 381 | + } | ||
| 382 | + | ||
| 383 | + fn finish(&self) -> advanced::operation::Outcome<f32> { | ||
| 384 | + match (self.view, self.row) { | ||
| 385 | + // Both are in window coordinates, so the row's place in the | ||
| 386 | + // content is how far it is below the viewport's top edge plus | ||
| 387 | + // how far the viewport has already been scrolled. | ||
| 388 | + (Some((view_y, view_h, scrolled)), Some((row_y, row_h))) => { | ||
| 389 | + let top = row_y - view_y + scrolled; | ||
| 390 | + let centred = top - (view_h - row_h).max(0.0) / 2.0; | ||
| 391 | + advanced::operation::Outcome::Some(centred.max(0.0)) | ||
| 392 | + } | ||
| 393 | + // The row is not laid out yet — it has only just been mounted, | ||
| 394 | + // or it is not in this scroll area at all. Nothing to say, and | ||
| 395 | + // the next commit asks again. | ||
| 396 | + _ => advanced::operation::Outcome::None, | ||
| 397 | + } | ||
| 398 | + } | ||
| 399 | + } | ||
| 400 | + | ||
| 401 | + advanced::operate(Measure { | ||
| 402 | + scroll, | ||
| 403 | + target, | ||
| 404 | + view: None, | ||
| 405 | + row: None, | ||
| 406 | + }) | ||
| 407 | + .map(move |y| cosmic::Action::App(Message::Revealed(name.clone(), y))) | ||
| 408 | +} | ||
| 409 | + | ||
| 322 | // --- the app ----------------------------------------------------------------- | 410 | // --- the app ----------------------------------------------------------------- |
| 323 | 411 | ||
| 324 | struct App { | 412 | struct App { |
| @@ -343,6 +431,9 @@ enum Message { | |||
| 343 | Hover(i32), | 431 | Hover(i32), |
| 344 | Unhover(i32), | 432 | Unhover(i32), |
| 345 | Scrolled(i32, String, Viewport), | 433 | Scrolled(i32, String, Viewport), |
| 434 | + /// A scroll area, and how far down its content the row that asked to be | ||
| 435 | + /// shown was measured to be. | ||
| 436 | + Revealed(String, f32), | ||
| 346 | PickImage, | 437 | PickImage, |
| 347 | Picked(Option<PathBuf>), | 438 | Picked(Option<PathBuf>), |
| 348 | } | 439 | } |
| @@ -404,12 +495,9 @@ impl App { | |||
| 404 | offset_y: 0.0, | 495 | offset_y: 0.0, |
| 405 | }); | 496 | }); |
| 406 | let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before; | 497 | let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before; |
| 407 | - if let Some(fraction) = ask.reveal { | 498 | + if let Some(node) = ask.reveal { |
| 408 | memo.at_end = false; | 499 | memo.at_end = false; |
| 409 | - tasks.push(iced_scrollable::snap_to( | 500 | + tasks.push(reveal(ask.name.clone(), node)); |
| 410 | - scroll_id(&ask.name), | ||
| 411 | - RelativeOffset { x: None, y: Some(fraction) }, | ||
| 412 | - )); | ||
| 413 | } else if jumped || (ask.stick && memo.at_end) { | 501 | } else if jumped || (ask.stick && memo.at_end) { |
| 414 | memo.at_end = true; | 502 | memo.at_end = true; |
| 415 | tasks.push(snap_to_end(&ask.name)); | 503 | tasks.push(snap_to_end(&ask.name)); |
| @@ -519,6 +607,17 @@ impl cosmic::Application for App { | |||
| 519 | Message::Hover(node) => post(node, "hover", String::new(), 0.0), | 607 | Message::Hover(node) => post(node, "hover", String::new(), 0.0), |
| 520 | Message::Unhover(node) => post(node, "unhover", String::new(), 0.0), | 608 | Message::Unhover(node) => post(node, "unhover", String::new(), 0.0), |
| 521 | Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport), | 609 | Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport), |
| 610 | + // The measurement came back: put the scroll where it says. The | ||
| 611 | + // memo is written too, so a list that is unmounted and comes back | ||
| 612 | + // opens where the jump left it rather than where it was before. | ||
| 613 | + Message::Revealed(name, y) => { | ||
| 614 | + let id = scroll_id(&name); | ||
| 615 | + if let Some(memo) = self.scrolls.get_mut(&name) { | ||
| 616 | + memo.at_end = false; | ||
| 617 | + memo.offset_y = y; | ||
| 618 | + } | ||
| 619 | + return iced_scrollable::scroll_to(id, AbsoluteOffset { x: None, y: Some(y) }); | ||
| 620 | + } | ||
| 522 | // The desktop's own chooser, through the portal, on libcosmic's | 621 | // The desktop's own chooser, through the portal, on libcosmic's |
| 523 | // executor: it is a D-Bus round trip, and the window keeps | 622 | // executor: it is a D-Bus round trip, and the window keeps |
| 524 | // painting while it is open. | 623 | // painting while it is open. |
| @@ -1085,6 +1184,23 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | |||
| 1085 | .into(), | 1184 | .into(), |
| 1086 | }; | 1185 | }; |
| 1087 | 1186 | ||
| 1187 | + // Every row of a scroll area carries an id, so the operation in `reveal` | ||
| 1188 | + // can find out where one of them actually is. | ||
| 1189 | + // | ||
| 1190 | + // Every row, and not only the row that is asking to be shown. iced tells | ||
| 1191 | + // two widgets apart by their state's type, and a wrapper with no state of | ||
| 1192 | + // its own is indistinguishable from the column it wraps — so a wrapper | ||
| 1193 | + // that came and went as a row became the target would have the old tree | ||
| 1194 | + // reused for the new shape, and the state under it would be read as | ||
| 1195 | + // something it is not. That is not a wrong answer, it is a panic in the | ||
| 1196 | + // middle of a layout. Whether a node is a row of a scroll area is a | ||
| 1197 | + // question about where it sits, and where it sits does not change under | ||
| 1198 | + // it. | ||
| 1199 | + let el = match t.get(n.parent) { | ||
| 1200 | + Some(parent) if parent.tag == "scroll" => widget::id_container(el, here_id(id)).into(), | ||
| 1201 | + _ => el, | ||
| 1202 | + }; | ||
| 1203 | + | ||
| 1088 | // The containers and the entry size themselves above; anything else asked | 1204 | // The containers and the entry size themselves above; anything else asked |
| 1089 | // for a width gets it from a wrapper. | 1205 | // for a width gets it from a wrapper. |
| 1090 | match (n.tag.as_str(), width_request(n)) { | 1206 | match (n.tag.as_str(), width_request(n)) { |
| @@ -1521,7 +1637,7 @@ mod tests { | |||
| 1521 | } | 1637 | } |
| 1522 | 1638 | ||
| 1523 | #[test] | 1639 | #[test] |
| 1524 | - fn a_new_scroll_here_asks_for_its_row_and_a_standing_one_does_not() { | 1640 | + fn scroll_here_asks_with_the_row_itself_and_goes_on_asking() { |
| 1525 | let mut t = Tree::default(); | 1641 | let mut t = Tree::default(); |
| 1526 | let root = t.root(); | 1642 | let root = t.root(); |
| 1527 | let list = node(&mut t, root, "scroll"); | 1643 | let list = node(&mut t, root, "scroll"); |
| @@ -1530,13 +1646,33 @@ mod tests { | |||
| 1530 | let before = t.clone(); | 1646 | let before = t.clone(); |
| 1531 | t.set(rows[3], "scroll-here", Prop::Bool(true)); | 1647 | t.set(rows[3], "scroll-here", Prop::Bool(true)); |
| 1532 | 1648 | ||
| 1649 | + // The row, not a guess at where it sits: only the layout knows that, | ||
| 1650 | + // and `reveal` is what goes and asks it. Here the row and the node | ||
| 1651 | + // asking are the same; a message row asks from inside itself, and the | ||
| 1652 | + // answer is still the row, since the row is what carries an id. | ||
| 1533 | let asks = scroll_asks(&before, &t); | 1653 | let asks = scroll_asks(&before, &t); |
| 1534 | assert_eq!(asks.len(), 1); | 1654 | assert_eq!(asks.len(), 1); |
| 1535 | assert!(!asks[0].fresh); | 1655 | assert!(!asks[0].fresh); |
| 1536 | - assert_eq!(asks[0].reveal, Some(0.75)); | 1656 | + assert_eq!(asks[0].reveal, Some(rows[3])); |
| 1537 | 1657 | ||
| 1658 | + // And it asks again while the row is still asking. It used to ask only | ||
| 1659 | + // on the commit the prop arrived, which meant a row not laid out yet — | ||
| 1660 | + // a conversation the client has only just switched to — was never | ||
| 1661 | + // scrolled to at all. | ||
| 1538 | let again = scroll_asks(&t, &t); | 1662 | let again = scroll_asks(&t, &t); |
| 1539 | - assert_eq!(again[0].reveal, None); | 1663 | + assert_eq!(again[0].reveal, Some(rows[3])); |
| 1664 | + | ||
| 1665 | + // Nothing asking, nothing to reveal. | ||
| 1666 | + t.set(rows[3], "scroll-here", Prop::Bool(false)); | ||
| 1667 | + let quiet = scroll_asks(&t, &t); | ||
| 1668 | + assert_eq!(quiet[0].reveal, None); | ||
| 1669 | + | ||
| 1670 | + // And a node asking from deeper inside a row still answers with the | ||
| 1671 | + // row: that is the thing the layout can be asked about. | ||
| 1672 | + let inner = node(&mut t, rows[1], "vbox"); | ||
| 1673 | + t.set(inner, "scroll-here", Prop::Bool(true)); | ||
| 1674 | + let deep = scroll_asks(&t, &t); | ||
| 1675 | + assert_eq!(deep[0].reveal, Some(rows[1])); | ||
| 1540 | } | 1676 | } |
| 1541 | 1677 | ||
| 1542 | #[test] | 1678 | #[test] |