nandi/jolt-nativepublic Fork 0
0fb0175
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 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>
nandi committed 2026-09-11T21:47:13-07:00 Browse files
0fb0175 parent: 228672d
modified crates/jolt-cosmic/src/lib.rs +160 -24
@@ -36,6 +36,8 @@ use cosmic::iced::futures::{Stream, StreamExt};
3636 use cosmic::iced::widget::container::Style as ContainerStyle;
3737 use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport};
3838 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;
3941 use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription};
4042 use cosmic::widget::{self, Column, Row};
4143 use cosmic::{ApplicationExt, Element};
@@ -263,9 +265,8 @@ struct ScrollAsk {
263265 tick_before: Option<f64>,
264266 /// Not in the tree before this commit: mounted, or mounted again.
265267 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>,
269270 }
270271
271272 /// 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> {
283284 return;
284285 }
285286 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.
291301 let mut reveal = None;
292- for (index, row) in n.children.iter().enumerate() {
302+ for row in &n.children {
293303 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);
300306 });
301307 if asked {
302- reveal = Some(index as f32 / (count.saturating_sub(1).max(1)) as f32);
308+ reveal = Some(*row);
303309 break;
304310 }
305311 }
@@ -319,6 +325,88 @@ fn snap_to_end(name: &str) -> Task<Message> {
319325 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
320326 }
321327
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+
322410 // --- the app -----------------------------------------------------------------
323411
324412 struct App {
@@ -343,6 +431,9 @@ enum Message {
343431 Hover(i32),
344432 Unhover(i32),
345433 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),
346437 PickImage,
347438 Picked(Option<PathBuf>),
348439 }
@@ -404,12 +495,9 @@ impl App {
404495 offset_y: 0.0,
405496 });
406497 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 {
408499 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));
413501 } else if jumped || (ask.stick && memo.at_end) {
414502 memo.at_end = true;
415503 tasks.push(snap_to_end(&ask.name));
@@ -519,6 +607,17 @@ impl cosmic::Application for App {
519607 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
520608 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
521609 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+ }
522621 // The desktop's own chooser, through the portal, on libcosmic's
523622 // executor: it is a D-Bus round trip, and the window keeps
524623 // painting while it is open.
@@ -1085,6 +1184,23 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
10851184 .into(),
10861185 };
10871186
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+
10881204 // The containers and the entry size themselves above; anything else asked
10891205 // for a width gets it from a wrapper.
10901206 match (n.tag.as_str(), width_request(n)) {
@@ -1521,7 +1637,7 @@ mod tests {
15211637 }
15221638
15231639 #[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() {
15251641 let mut t = Tree::default();
15261642 let root = t.root();
15271643 let list = node(&mut t, root, "scroll");
@@ -1530,13 +1646,33 @@ mod tests {
15301646 let before = t.clone();
15311647 t.set(rows[3], "scroll-here", Prop::Bool(true));
15321648
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.
15331653 let asks = scroll_asks(&before, &t);
15341654 assert_eq!(asks.len(), 1);
15351655 assert!(!asks[0].fresh);
1536- assert_eq!(asks[0].reveal, Some(0.75));
1656+ assert_eq!(asks[0].reveal, Some(rows[3]));
15371657
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.
15381662 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]));
15401676 }
15411677
15421678 #[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 be268+ /// 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. The287+ // 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 a288+ // 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 there289+ // `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's621 // The desktop's own chooser, through the portal, on libcosmic's
523 // executor: it is a D-Bus round trip, and the window keeps622 // 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 asked1204 // 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]