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

Let a list say where it put its rows, and jump there

iced can scroll a list to an offset and to nothing else — `snap_to`,
`scroll_to` and `scroll_by` take a number of points, and there is no "show me
this child" in the toolkit. So `scroll-here` was answered with the asking
row's index over the row count, applied as a fraction. That is a fraction of
the scroll RANGE rather than of the content — the two agree only when the
viewport is exactly one row tall — and it took every row for the same height
besides, in a backlog that puts a one-line message next to a picture. A jump
to a line three days back landed up to a viewport away, worst in the middle
of a list, which is where a reader is least able to tell a near miss from a
jump that did not happen.

The thing that knows where a row is is the layout, so `rows::Rows` reads the
layout it has already computed and writes down where each row landed. A jump
is then a lookup and the `scroll_to` that was always there, exact whatever
the rows are made of, and centred rather than pinned to the top edge: a line
answered three days ago is read with what was said around it.

One such widget per scroll area, always, whether or not anything is asking to
be scrolled to — and holding a state type of its own. That is the whole of
its safety, and it is the lesson of two panics: iced matches widgets by where
they sit and tells them apart by the type of their state, so a wrapper with
no state is indistinguishable from the column it wraps. An earlier try put an
id on the rows that were asking and only those; slots that had held a plain
column were handed a wrapper with exactly one child, the tree kept for the
old shape was reused for the new one, and a button somewhere underneath read
a child that was never there. Compiling and passing its tests the whole way.

The ask also stands while the row is asking rather than firing once. A row
that is not laid out yet has no place written down for it, and the ask is
over in half a second — asking again each commit is what lets a jump into a
conversation the client has only just switched to land on the frame the rows
finally exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T22:20:12-07:00 Browse files
0ab003e parent: 98daca1
modified crates/jolt-cosmic/src/lib.rs +103 -22
@@ -19,6 +19,7 @@
1919 //!
2020 //! Every call except `cosmic_run` may come from any thread.
2121
22+mod rows;
2223 mod tree;
2324
2425 pub use tree::{Node, Prop, Tree};
@@ -228,6 +229,12 @@ struct ScrollMemo {
228229 /// follows what arrives only while this holds.
229230 at_end: bool,
230231 offset_y: f32,
232+ /// How tall the viewport was when the reader last moved it, which is what
233+ /// a jump centres a row in. Zero until they have: a list nobody has
234+ /// scrolled has no reported height, and a row put in the middle of a
235+ /// viewport of nothing is a row put at the top — which is the right answer
236+ /// to give when the height is not known.
237+ height: f32,
231238 }
232239
233240 /// Two points of slack: a viewport scrolled to its end by a fractional
@@ -263,9 +270,8 @@ struct ScrollAsk {
263270 tick_before: Option<f64>,
264271 /// Not in the tree before this commit: mounted, or mounted again.
265272 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>,
273+ /// The row asking to be shown, if one is.
274+ reveal: Option<i32>,
269275 }
270276
271277 /// Every scroll area in `now`, and what changed about each since `before`.
@@ -283,23 +289,30 @@ fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
283289 return;
284290 }
285291 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();
292+ // The row holding whatever asked to be shown.
293+ //
294+ // The row, because a row is what `rows::Rows` writes a place down for;
295+ // and the row rather than a guess at where it sits, because this used
296+ // to answer with its index over the row count. That is a fraction of
297+ // the scroll RANGE and not of the content — the two agree only when
298+ // the viewport is exactly one row tall — and it took every row for the
299+ // same height besides, in a backlog that puts a one-line message next
300+ // to a picture. The landing was out by up to a viewport, worst in the
301+ // middle of a list.
302+ //
303+ // While it is asking, not only on the commit the ask arrives. A row
304+ // that is not laid out yet has no place written down for it, and the
305+ // ask is over in half a second: asking again each commit is what lets
306+ // a jump into a conversation the client has only just switched to land
307+ // on the frame the rows finally exist.
291308 let mut reveal = None;
292- for (index, row) in n.children.iter().enumerate() {
309+ for row in &n.children {
293310 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;
311+ walk(now, *row, &mut |_, node| {
312+ asked |= node.bool("scroll-here") == Some(true);
300313 });
301314 if asked {
302- reveal = Some(index as f32 / (count.saturating_sub(1).max(1)) as f32);
315+ reveal = Some(*row);
303316 break;
304317 }
305318 }
@@ -315,6 +328,33 @@ fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
315328 asks
316329 }
317330
331+/// Where the rows of the scroll area called `name` were last laid out.
332+///
333+/// By name and not by node, because a scroll area outlives the node ids of a
334+/// tree that is rebuilt under it — the same list, and the reader's place in
335+/// it, is the thing `scroll-key` names.
336+fn placements(name: &str) -> rows::Placements {
337+ static BOOKS: LazyLock<Mutex<HashMap<String, rows::Placements>>> =
338+ LazyLock::new(|| Mutex::new(HashMap::new()));
339+ lock(&BOOKS).entry(name.to_owned()).or_default().clone()
340+}
341+
342+/// Where to scroll so that a row at `top`, `height` tall, sits in the middle
343+/// of a viewport `viewport` tall.
344+///
345+/// The middle rather than the top edge: a line answered three days ago is read
346+/// with what was said around it, and a jump that pins it to the ceiling shows
347+/// only what came after.
348+///
349+/// Never above the start of the content — a negative offset is not a place —
350+/// and the top edge is the answer while the viewport's height is unknown,
351+/// which it is until the reader has scrolled the list once. A row centred in a
352+/// viewport of nothing is a row at the top, which is the same answer said
353+/// twice, but it is worth being the one that is said on purpose.
354+fn centred_offset(top: f32, height: f32, viewport: f32) -> f32 {
355+ (top - (viewport - height).max(0.0) / 2.0).max(0.0)
356+}
357+
318358 fn snap_to_end(name: &str) -> Task<Message> {
319359 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
320360 }
@@ -402,13 +442,20 @@ impl App {
402442 .or_insert(ScrollMemo {
403443 at_end: ask.stick,
404444 offset_y: 0.0,
445+ height: 0.0,
405446 });
406447 let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;
407- if let Some(fraction) = ask.reveal {
448+ // A row asking to be shown, and a place written down for it by the
449+ // last layout. Both, or there is nothing to do yet: the row is
450+ // measured on the frame it appears, and the ask stands until it
451+ // has been.
452+ if let Some((top, height)) = ask.reveal.and_then(|row| placements(&ask.name).get(row)) {
453+ let y = centred_offset(top, height, memo.height);
408454 memo.at_end = false;
409- tasks.push(iced_scrollable::snap_to(
455+ memo.offset_y = y;
456+ tasks.push(iced_scrollable::scroll_to(
410457 scroll_id(&ask.name),
411- RelativeOffset { x: None, y: Some(fraction) },
458+ AbsoluteOffset { x: None, y: Some(y) },
412459 ));
413460 } else if jumped || (ask.stick && memo.at_end) {
414461 memo.at_end = true;
@@ -434,10 +481,12 @@ impl App {
434481 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
435482 at_end,
436483 offset_y: y,
484+ height: viewport.bounds().height,
437485 });
438486 let was = memo.at_end;
439487 memo.at_end = at_end;
440488 memo.offset_y = y;
489+ memo.height = viewport.bounds().height;
441490 // "end" or "away", the strings libvidya emits: frq's handler compares
442491 // against "end".
443492 if was != at_end {
@@ -823,6 +872,10 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
823872 .spacing(spacing)
824873 .width(Length::Fill)
825874 .height(Length::Shrink);
875+ // Wrapped in the thing that writes down where each row landed, so
876+ // that "take me to this line" has an answer in points — which is
877+ // the only thing a scroll area can be told. See `rows`.
878+ let content = rows::Rows::new(content, n.children.clone(), placements(&name));
826879 widget::scrollable(content)
827880 .id(scroll_id(&name))
828881 .width(Length::Fill)
@@ -1521,7 +1574,22 @@ mod tests {
15211574 }
15221575
15231576 #[test]
1524- fn a_new_scroll_here_asks_for_its_row_and_a_standing_one_does_not() {
1577+ fn a_row_is_centred_in_the_viewport_without_scrolling_past_the_top() {
1578+ // A row halfway down a long backlog, in a 600pt viewport: half the
1579+ // viewport above it, less half the row.
1580+ assert_eq!(centred_offset(1000.0, 40.0, 600.0), 1000.0 - 280.0);
1581+ // The same row with no viewport reported yet: its own top.
1582+ assert_eq!(centred_offset(1000.0, 40.0, 0.0), 1000.0);
1583+ // A row near the top cannot be centred without scrolling above the
1584+ // content, and nothing is above the content.
1585+ assert_eq!(centred_offset(20.0, 40.0, 600.0), 0.0);
1586+ // A row taller than the viewport is shown from its own top: there is
1587+ // no middle of it to put in the middle.
1588+ assert_eq!(centred_offset(500.0, 900.0, 600.0), 500.0);
1589+ }
1590+
1591+ #[test]
1592+ fn scroll_here_asks_with_its_row_and_goes_on_asking() {
15251593 let mut t = Tree::default();
15261594 let root = t.root();
15271595 let list = node(&mut t, root, "scroll");
@@ -1530,13 +1598,26 @@ mod tests {
15301598 let before = t.clone();
15311599 t.set(rows[3], "scroll-here", Prop::Bool(true));
15321600
1601+ // The row, since a row is what has a place written down for it.
15331602 let asks = scroll_asks(&before, &t);
15341603 assert_eq!(asks.len(), 1);
15351604 assert!(!asks[0].fresh);
1536- assert_eq!(asks[0].reveal, Some(0.75));
1605+ assert_eq!(asks[0].reveal, Some(rows[3]));
15371606
1607+ // And it goes on asking while the row is still asking: the row may not
1608+ // have been laid out on the commit the ask arrived.
15381609 let again = scroll_asks(&t, &t);
1539- assert_eq!(again[0].reveal, None);
1610+ assert_eq!(again[0].reveal, Some(rows[3]));
1611+
1612+ // A node asking from deeper inside a row answers with the row.
1613+ t.set(rows[3], "scroll-here", Prop::Bool(false));
1614+ let inner = node(&mut t, rows[1], "vbox");
1615+ t.set(inner, "scroll-here", Prop::Bool(true));
1616+ assert_eq!(scroll_asks(&t, &t)[0].reveal, Some(rows[1]));
1617+
1618+ // Nothing asking, nothing to reveal.
1619+ t.set(inner, "scroll-here", Prop::Bool(false));
1620+ assert_eq!(scroll_asks(&t, &t)[0].reveal, None);
15401621 }
15411622
15421623 #[test]
@@ -19,6 +19,7 @@
19 //!19 //!
20 //! Every call except `cosmic_run` may come from any thread.20 //! Every call except `cosmic_run` may come from any thread.
21 21
22+mod rows;
22 mod tree;23 mod tree;
23 24
24 pub use tree::{Node, Prop, Tree};25 pub use tree::{Node, Prop, Tree};
@@ -228,6 +229,12 @@ struct ScrollMemo {
228 /// follows what arrives only while this holds.229 /// follows what arrives only while this holds.
229 at_end: bool,230 at_end: bool,
230 offset_y: f32,231 offset_y: f32,
232+ /// How tall the viewport was when the reader last moved it, which is what
233+ /// a jump centres a row in. Zero until they have: a list nobody has
234+ /// scrolled has no reported height, and a row put in the middle of a
235+ /// viewport of nothing is a row put at the top — which is the right answer
236+ /// to give when the height is not known.
237+ height: f32,
231 }238 }
232 239
233 /// Two points of slack: a viewport scrolled to its end by a fractional240 /// Two points of slack: a viewport scrolled to its end by a fractional
@@ -263,9 +270,8 @@ struct ScrollAsk {
263 tick_before: Option<f64>,270 tick_before: Option<f64>,
264 /// Not in the tree before this commit: mounted, or mounted again.271 /// Not in the tree before this commit: mounted, or mounted again.
265 fresh: bool,272 fresh: bool,
266- /// Where, as a fraction of the list, a node that has just been asked to be273+ /// The row asking to be shown, if one is.
267- /// shown sits.274+ reveal: Option<i32>,
268- reveal: Option<f32>,
269 }275 }
270 276
271 /// Every scroll area in `now`, and what changed about each since `before`.277 /// Every scroll area in `now`, and what changed about each since `before`.
@@ -283,23 +289,30 @@ fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
283 return;289 return;
284 }290 }
285 let name = scroll_name(n, id);291 let name = scroll_name(n, id);
286- // A row asking to be shown, that was not asking last commit. The292+ // The row holding whatever asked to be shown.
287- // position is the index of the top-level row holding it: exact for a293+ //
288- // list of rows the same height and close for frq's backlog, and there294+ // The row, because a row is what `rows::Rows` writes a place down for;
289- // is no layout to ask from here.295+ // and the row rather than a guess at where it sits, because this used
290- let count = n.children.len();296+ // to answer with its index over the row count. That is a fraction of
297+ // the scroll RANGE and not of the content — the two agree only when
298+ // the viewport is exactly one row tall — and it took every row for the
299+ // same height besides, in a backlog that puts a one-line message next
300+ // to a picture. The landing was out by up to a viewport, worst in the
301+ // middle of a list.
302+ //
303+ // While it is asking, not only on the commit the ask arrives. A row
304+ // that is not laid out yet has no place written down for it, and the
305+ // ask is over in half a second: asking again each commit is what lets
306+ // a jump into a conversation the client has only just switched to land
307+ // on the frame the rows finally exist.
291 let mut reveal = None;308 let mut reveal = None;
292- for (index, row) in n.children.iter().enumerate() {309+ for row in &n.children {
293 let mut asked = false;310 let mut asked = false;
294- walk(now, *row, &mut |nid, node| {311+ walk(now, *row, &mut |_, node| {
295- let here = node.bool("scroll-here") == Some(true);312+ 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 });313 });
301 if asked {314 if asked {
302- reveal = Some(index as f32 / (count.saturating_sub(1).max(1)) as f32);315+ reveal = Some(*row);
303 break;316 break;
304 }317 }
305 }318 }
@@ -315,6 +328,33 @@ fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
315 asks328 asks
316 }329 }
317 330
331+/// Where the rows of the scroll area called `name` were last laid out.
332+///
333+/// By name and not by node, because a scroll area outlives the node ids of a
334+/// tree that is rebuilt under it — the same list, and the reader's place in
335+/// it, is the thing `scroll-key` names.
336+fn placements(name: &str) -> rows::Placements {
337+ static BOOKS: LazyLock<Mutex<HashMap<String, rows::Placements>>> =
338+ LazyLock::new(|| Mutex::new(HashMap::new()));
339+ lock(&BOOKS).entry(name.to_owned()).or_default().clone()
340+}
341+
342+/// Where to scroll so that a row at `top`, `height` tall, sits in the middle
343+/// of a viewport `viewport` tall.
344+///
345+/// The middle rather than the top edge: a line answered three days ago is read
346+/// with what was said around it, and a jump that pins it to the ceiling shows
347+/// only what came after.
348+///
349+/// Never above the start of the content — a negative offset is not a place —
350+/// and the top edge is the answer while the viewport's height is unknown,
351+/// which it is until the reader has scrolled the list once. A row centred in a
352+/// viewport of nothing is a row at the top, which is the same answer said
353+/// twice, but it is worth being the one that is said on purpose.
354+fn centred_offset(top: f32, height: f32, viewport: f32) -> f32 {
355+ (top - (viewport - height).max(0.0) / 2.0).max(0.0)
356+}
357+
318 fn snap_to_end(name: &str) -> Task<Message> {358 fn snap_to_end(name: &str) -> Task<Message> {
319 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })359 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
320 }360 }
@@ -402,13 +442,20 @@ impl App {
402 .or_insert(ScrollMemo {442 .or_insert(ScrollMemo {
403 at_end: ask.stick,443 at_end: ask.stick,
404 offset_y: 0.0,444 offset_y: 0.0,
445+ height: 0.0,
405 });446 });
406 let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;447 let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;
407- if let Some(fraction) = ask.reveal {448+ // A row asking to be shown, and a place written down for it by the
449+ // last layout. Both, or there is nothing to do yet: the row is
450+ // measured on the frame it appears, and the ask stands until it
451+ // has been.
452+ if let Some((top, height)) = ask.reveal.and_then(|row| placements(&ask.name).get(row)) {
453+ let y = centred_offset(top, height, memo.height);
408 memo.at_end = false;454 memo.at_end = false;
409- tasks.push(iced_scrollable::snap_to(455+ memo.offset_y = y;
456+ tasks.push(iced_scrollable::scroll_to(
410 scroll_id(&ask.name),457 scroll_id(&ask.name),
411- RelativeOffset { x: None, y: Some(fraction) },458+ AbsoluteOffset { x: None, y: Some(y) },
412 ));459 ));
413 } else if jumped || (ask.stick && memo.at_end) {460 } else if jumped || (ask.stick && memo.at_end) {
414 memo.at_end = true;461 memo.at_end = true;
@@ -434,10 +481,12 @@ impl App {
434 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {481 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
435 at_end,482 at_end,
436 offset_y: y,483 offset_y: y,
484+ height: viewport.bounds().height,
437 });485 });
438 let was = memo.at_end;486 let was = memo.at_end;
439 memo.at_end = at_end;487 memo.at_end = at_end;
440 memo.offset_y = y;488 memo.offset_y = y;
489+ memo.height = viewport.bounds().height;
441 // "end" or "away", the strings libvidya emits: frq's handler compares490 // "end" or "away", the strings libvidya emits: frq's handler compares
442 // against "end".491 // against "end".
443 if was != at_end {492 if was != at_end {
@@ -823,6 +872,10 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag
823 .spacing(spacing)872 .spacing(spacing)
824 .width(Length::Fill)873 .width(Length::Fill)
825 .height(Length::Shrink);874 .height(Length::Shrink);
875+ // Wrapped in the thing that writes down where each row landed, so
876+ // that "take me to this line" has an answer in points — which is
877+ // the only thing a scroll area can be told. See `rows`.
878+ let content = rows::Rows::new(content, n.children.clone(), placements(&name));
826 widget::scrollable(content)879 widget::scrollable(content)
827 .id(scroll_id(&name))880 .id(scroll_id(&name))
828 .width(Length::Fill)881 .width(Length::Fill)
@@ -1521,7 +1574,22 @@ mod tests {
1521 }1574 }
1522 1575
1523 #[test]1576 #[test]
1524- fn a_new_scroll_here_asks_for_its_row_and_a_standing_one_does_not() {1577+ fn a_row_is_centred_in_the_viewport_without_scrolling_past_the_top() {
1578+ // A row halfway down a long backlog, in a 600pt viewport: half the
1579+ // viewport above it, less half the row.
1580+ assert_eq!(centred_offset(1000.0, 40.0, 600.0), 1000.0 - 280.0);
1581+ // The same row with no viewport reported yet: its own top.
1582+ assert_eq!(centred_offset(1000.0, 40.0, 0.0), 1000.0);
1583+ // A row near the top cannot be centred without scrolling above the
1584+ // content, and nothing is above the content.
1585+ assert_eq!(centred_offset(20.0, 40.0, 600.0), 0.0);
1586+ // A row taller than the viewport is shown from its own top: there is
1587+ // no middle of it to put in the middle.
1588+ assert_eq!(centred_offset(500.0, 900.0, 600.0), 500.0);
1589+ }
1590+
1591+ #[test]
1592+ fn scroll_here_asks_with_its_row_and_goes_on_asking() {
1525 let mut t = Tree::default();1593 let mut t = Tree::default();
1526 let root = t.root();1594 let root = t.root();
1527 let list = node(&mut t, root, "scroll");1595 let list = node(&mut t, root, "scroll");
@@ -1530,13 +1598,26 @@ mod tests {
1530 let before = t.clone();1598 let before = t.clone();
1531 t.set(rows[3], "scroll-here", Prop::Bool(true));1599 t.set(rows[3], "scroll-here", Prop::Bool(true));
1532 1600
1601+ // The row, since a row is what has a place written down for it.
1533 let asks = scroll_asks(&before, &t);1602 let asks = scroll_asks(&before, &t);
1534 assert_eq!(asks.len(), 1);1603 assert_eq!(asks.len(), 1);
1535 assert!(!asks[0].fresh);1604 assert!(!asks[0].fresh);
1536- assert_eq!(asks[0].reveal, Some(0.75));1605+ assert_eq!(asks[0].reveal, Some(rows[3]));
1537 1606
1607+ // And it goes on asking while the row is still asking: the row may not
1608+ // have been laid out on the commit the ask arrived.
1538 let again = scroll_asks(&t, &t);1609 let again = scroll_asks(&t, &t);
1539- assert_eq!(again[0].reveal, None);1610+ assert_eq!(again[0].reveal, Some(rows[3]));
1611+
1612+ // A node asking from deeper inside a row answers with the row.
1613+ t.set(rows[3], "scroll-here", Prop::Bool(false));
1614+ let inner = node(&mut t, rows[1], "vbox");
1615+ t.set(inner, "scroll-here", Prop::Bool(true));
1616+ assert_eq!(scroll_asks(&t, &t)[0].reveal, Some(rows[1]));
1617+
1618+ // Nothing asking, nothing to reveal.
1619+ t.set(inner, "scroll-here", Prop::Bool(false));
1620+ assert_eq!(scroll_asks(&t, &t)[0].reveal, None);
1540 }1621 }
1541 1622
1542 #[test]1623 #[test]
added crates/jolt-cosmic/src/rows.rs +248 -0
new file mode 100644
@@ -0,0 +1,248 @@
1+//! A column that remembers where it put its rows.
2+//!
3+//! iced can scroll a list to an offset and to nothing else: `snap_to`,
4+//! `scroll_to` and `scroll_by` all take a number of points, and there is no
5+//! "show me this child" anywhere in the toolkit. So a client that wants to be
6+//! taken to one row of a hundred has to answer the question itself — how far
7+//! down the content is that row? — and the only thing that knows is the layout.
8+//!
9+//! This is that, in the one place the answer is free. `Rows` wraps the column
10+//! inside a scroll area and does nothing to it but read the layout it already
11+//! computed, writing down where each row landed. A jump is then a lookup and
12+//! the `scroll_to` that already exists.
13+//!
14+//! It is one widget per scroll area, always, whether or not anything is asking
15+//! to be scrolled to. That is the whole of its safety: iced matches widgets by
16+//! where they sit, so a wrapper that came and went as rows became interesting
17+//! would leave the tree a different shape than the state kept for it. The
18+//! `State` below is why it can be told apart from the column it wraps at all —
19+//! iced tells two widgets apart by the type of their state, and two stateless
20+//! ones are the same widget as far as it knows.
21+
22+use std::collections::HashMap;
23+use std::sync::{Arc, Mutex};
24+
25+use cosmic::iced::advanced::widget::{Operation, Tree, tree};
26+use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, overlay, renderer};
27+// `cosmic::Element`, not iced's: the two differ in their theme, and this
28+// widget lives in a cosmic tree.
29+use cosmic::Element;
30+use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
31+
32+/// Where each row of one scroll area was last laid out, in points from the top
33+/// of the content, beside how tall it is.
34+///
35+/// Shared with whoever wants to scroll: the widget writes during layout, the
36+/// app reads when a jump asks for a row. Late answers are the point — a row
37+/// only just mounted is measured on the frame it appears, and the jump that
38+/// wanted it can ask again on the next one rather than having missed its one
39+/// chance.
40+#[derive(Clone, Default)]
41+pub struct Placements(Arc<Mutex<HashMap<i32, (f32, f32)>>>);
42+
43+impl Placements {
44+ pub fn new() -> Self {
45+ Self::default()
46+ }
47+
48+ /// Where row `key` sits, if it has been laid out since the last time the
49+ /// list it is in was.
50+ pub fn get(&self, key: i32) -> Option<(f32, f32)> {
51+ self.0.lock().ok()?.get(&key).copied()
52+ }
53+
54+ fn write(&self, rows: HashMap<i32, (f32, f32)>) {
55+ if let Ok(mut held) = self.0.lock() {
56+ *held = rows;
57+ }
58+ }
59+}
60+
61+/// The trivial state that gives this widget a type of its own.
62+///
63+/// It holds nothing. What it is for is the tag: a wrapper with no state is
64+/// indistinguishable from a plain column to iced's diffing, which reuses the
65+/// tree it kept for one as the tree for the other — and the mismatch is not
66+/// noticed until something deep inside reads a child that was never there.
67+struct State;
68+
69+pub struct Rows<'a, Message> {
70+ inner: Element<'a, Message>,
71+ keys: Vec<i32>,
72+ placements: Placements,
73+}
74+
75+impl<'a, Message> Rows<'a, Message> {
76+ /// `keys` are the rows of `inner`, in the order they were given to it.
77+ pub fn new(
78+ inner: impl Into<Element<'a, Message>>,
79+ keys: Vec<i32>,
80+ placements: Placements,
81+ ) -> Self {
82+ Self {
83+ inner: inner.into(),
84+ keys,
85+ placements,
86+ }
87+ }
88+}
89+
90+impl<Message> Widget<Message, cosmic::Theme, cosmic::Renderer> for Rows<'_, Message> {
91+ fn tag(&self) -> tree::Tag {
92+ tree::Tag::of::<State>()
93+ }
94+
95+ fn state(&self) -> tree::State {
96+ tree::State::new(State)
97+ }
98+
99+ fn children(&self) -> Vec<Tree> {
100+ vec![Tree::new(&self.inner)]
101+ }
102+
103+ fn diff(&mut self, tree: &mut Tree) {
104+ tree.diff_children(std::slice::from_mut(&mut self.inner));
105+ }
106+
107+ fn size(&self) -> Size<Length> {
108+ self.inner.as_widget().size()
109+ }
110+
111+ fn layout(
112+ &mut self,
113+ tree: &mut Tree,
114+ renderer: &cosmic::Renderer,
115+ limits: &layout::Limits,
116+ ) -> layout::Node {
117+ let node = self
118+ .inner
119+ .as_widget_mut()
120+ .layout(&mut tree.children[0], renderer, limits);
121+
122+ // The column's own children, in the order its keys were given. Their
123+ // bounds are already relative to the content's top, which is the
124+ // coordinate `scroll_to` is asking for.
125+ //
126+ // Written whole rather than merged: a row that has left the list has
127+ // no place any more, and an old answer for it would send a jump to
128+ // wherever it used to be.
129+ let mut rows = HashMap::with_capacity(self.keys.len());
130+ for (key, child) in self.keys.iter().zip(node.children()) {
131+ let bounds = child.bounds();
132+ rows.insert(*key, (bounds.y, bounds.height));
133+ }
134+ self.placements.write(rows);
135+
136+ let size = node.size();
137+ layout::Node::with_children(size, vec![node])
138+ }
139+
140+ fn operate(
141+ &mut self,
142+ tree: &mut Tree,
143+ layout: Layout<'_>,
144+ renderer: &cosmic::Renderer,
145+ operation: &mut dyn Operation,
146+ ) {
147+ operation.traverse(&mut |operation| {
148+ self.inner.as_widget_mut().operate(
149+ &mut tree.children[0],
150+ inner_layout(layout),
151+ renderer,
152+ operation,
153+ );
154+ });
155+ }
156+
157+ fn update(
158+ &mut self,
159+ tree: &mut Tree,
160+ event: &Event,
161+ layout: Layout<'_>,
162+ cursor: mouse::Cursor,
163+ renderer: &cosmic::Renderer,
164+ clipboard: &mut dyn Clipboard,
165+ shell: &mut Shell<'_, Message>,
166+ viewport: &Rectangle,
167+ ) {
168+ self.inner.as_widget_mut().update(
169+ &mut tree.children[0],
170+ event,
171+ inner_layout(layout),
172+ cursor,
173+ renderer,
174+ clipboard,
175+ shell,
176+ viewport,
177+ );
178+ }
179+
180+ fn mouse_interaction(
181+ &self,
182+ tree: &Tree,
183+ layout: Layout<'_>,
184+ cursor: mouse::Cursor,
185+ viewport: &Rectangle,
186+ renderer: &cosmic::Renderer,
187+ ) -> mouse::Interaction {
188+ self.inner.as_widget().mouse_interaction(
189+ &tree.children[0],
190+ inner_layout(layout),
191+ cursor,
192+ viewport,
193+ renderer,
194+ )
195+ }
196+
197+ fn draw(
198+ &self,
199+ tree: &Tree,
200+ renderer: &mut cosmic::Renderer,
201+ theme: &cosmic::Theme,
202+ style: &renderer::Style,
203+ layout: Layout<'_>,
204+ cursor: mouse::Cursor,
205+ viewport: &Rectangle,
206+ ) {
207+ self.inner.as_widget().draw(
208+ &tree.children[0],
209+ renderer,
210+ theme,
211+ style,
212+ inner_layout(layout),
213+ cursor,
214+ viewport,
215+ );
216+ }
217+
218+ fn overlay<'b>(
219+ &'b mut self,
220+ tree: &'b mut Tree,
221+ layout: Layout<'b>,
222+ renderer: &cosmic::Renderer,
223+ viewport: &Rectangle,
224+ translation: Vector,
225+ ) -> Option<overlay::Element<'b, Message, cosmic::Theme, cosmic::Renderer>> {
226+ self.inner.as_widget_mut().overlay(
227+ &mut tree.children[0],
228+ inner_layout(layout),
229+ renderer,
230+ viewport,
231+ translation,
232+ )
233+ }
234+}
235+
236+/// The one child this widget's layout node has.
237+fn inner_layout(layout: Layout<'_>) -> Layout<'_> {
238+ layout
239+ .children()
240+ .next()
241+ .expect("a Rows layout holds exactly one child")
242+}
243+
244+impl<'a, Message: 'a> From<Rows<'a, Message>> for Element<'a, Message> {
245+ fn from(rows: Rows<'a, Message>) -> Self {
246+ Element::new(rows)
247+ }
248+}
new file mode 100644
@@ -0,0 +1,248 @@
1+//! A column that remembers where it put its rows.
2+//!
3+//! iced can scroll a list to an offset and to nothing else: `snap_to`,
4+//! `scroll_to` and `scroll_by` all take a number of points, and there is no
5+//! "show me this child" anywhere in the toolkit. So a client that wants to be
6+//! taken to one row of a hundred has to answer the question itself — how far
7+//! down the content is that row? — and the only thing that knows is the layout.
8+//!
9+//! This is that, in the one place the answer is free. `Rows` wraps the column
10+//! inside a scroll area and does nothing to it but read the layout it already
11+//! computed, writing down where each row landed. A jump is then a lookup and
12+//! the `scroll_to` that already exists.
13+//!
14+//! It is one widget per scroll area, always, whether or not anything is asking
15+//! to be scrolled to. That is the whole of its safety: iced matches widgets by
16+//! where they sit, so a wrapper that came and went as rows became interesting
17+//! would leave the tree a different shape than the state kept for it. The
18+//! `State` below is why it can be told apart from the column it wraps at all —
19+//! iced tells two widgets apart by the type of their state, and two stateless
20+//! ones are the same widget as far as it knows.
21+
22+use std::collections::HashMap;
23+use std::sync::{Arc, Mutex};
24+
25+use cosmic::iced::advanced::widget::{Operation, Tree, tree};
26+use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, overlay, renderer};
27+// `cosmic::Element`, not iced's: the two differ in their theme, and this
28+// widget lives in a cosmic tree.
29+use cosmic::Element;
30+use cosmic::iced::{Event, Length, Rectangle, Size, Vector};
31+
32+/// Where each row of one scroll area was last laid out, in points from the top
33+/// of the content, beside how tall it is.
34+///
35+/// Shared with whoever wants to scroll: the widget writes during layout, the
36+/// app reads when a jump asks for a row. Late answers are the point — a row
37+/// only just mounted is measured on the frame it appears, and the jump that
38+/// wanted it can ask again on the next one rather than having missed its one
39+/// chance.
40+#[derive(Clone, Default)]
41+pub struct Placements(Arc<Mutex<HashMap<i32, (f32, f32)>>>);
42+
43+impl Placements {
44+ pub fn new() -> Self {
45+ Self::default()
46+ }
47+
48+ /// Where row `key` sits, if it has been laid out since the last time the
49+ /// list it is in was.
50+ pub fn get(&self, key: i32) -> Option<(f32, f32)> {
51+ self.0.lock().ok()?.get(&key).copied()
52+ }
53+
54+ fn write(&self, rows: HashMap<i32, (f32, f32)>) {
55+ if let Ok(mut held) = self.0.lock() {
56+ *held = rows;
57+ }
58+ }
59+}
60+
61+/// The trivial state that gives this widget a type of its own.
62+///
63+/// It holds nothing. What it is for is the tag: a wrapper with no state is
64+/// indistinguishable from a plain column to iced's diffing, which reuses the
65+/// tree it kept for one as the tree for the other — and the mismatch is not
66+/// noticed until something deep inside reads a child that was never there.
67+struct State;
68+
69+pub struct Rows<'a, Message> {
70+ inner: Element<'a, Message>,
71+ keys: Vec<i32>,
72+ placements: Placements,
73+}
74+
75+impl<'a, Message> Rows<'a, Message> {
76+ /// `keys` are the rows of `inner`, in the order they were given to it.
77+ pub fn new(
78+ inner: impl Into<Element<'a, Message>>,
79+ keys: Vec<i32>,
80+ placements: Placements,
81+ ) -> Self {
82+ Self {
83+ inner: inner.into(),
84+ keys,
85+ placements,
86+ }
87+ }
88+}
89+
90+impl<Message> Widget<Message, cosmic::Theme, cosmic::Renderer> for Rows<'_, Message> {
91+ fn tag(&self) -> tree::Tag {
92+ tree::Tag::of::<State>()
93+ }
94+
95+ fn state(&self) -> tree::State {
96+ tree::State::new(State)
97+ }
98+
99+ fn children(&self) -> Vec<Tree> {
100+ vec![Tree::new(&self.inner)]
101+ }
102+
103+ fn diff(&mut self, tree: &mut Tree) {
104+ tree.diff_children(std::slice::from_mut(&mut self.inner));
105+ }
106+
107+ fn size(&self) -> Size<Length> {
108+ self.inner.as_widget().size()
109+ }
110+
111+ fn layout(
112+ &mut self,
113+ tree: &mut Tree,
114+ renderer: &cosmic::Renderer,
115+ limits: &layout::Limits,
116+ ) -> layout::Node {
117+ let node = self
118+ .inner
119+ .as_widget_mut()
120+ .layout(&mut tree.children[0], renderer, limits);
121+
122+ // The column's own children, in the order its keys were given. Their
123+ // bounds are already relative to the content's top, which is the
124+ // coordinate `scroll_to` is asking for.
125+ //
126+ // Written whole rather than merged: a row that has left the list has
127+ // no place any more, and an old answer for it would send a jump to
128+ // wherever it used to be.
129+ let mut rows = HashMap::with_capacity(self.keys.len());
130+ for (key, child) in self.keys.iter().zip(node.children()) {
131+ let bounds = child.bounds();
132+ rows.insert(*key, (bounds.y, bounds.height));
133+ }
134+ self.placements.write(rows);
135+
136+ let size = node.size();
137+ layout::Node::with_children(size, vec![node])
138+ }
139+
140+ fn operate(
141+ &mut self,
142+ tree: &mut Tree,
143+ layout: Layout<'_>,
144+ renderer: &cosmic::Renderer,
145+ operation: &mut dyn Operation,
146+ ) {
147+ operation.traverse(&mut |operation| {
148+ self.inner.as_widget_mut().operate(
149+ &mut tree.children[0],
150+ inner_layout(layout),
151+ renderer,
152+ operation,
153+ );
154+ });
155+ }
156+
157+ fn update(
158+ &mut self,
159+ tree: &mut Tree,
160+ event: &Event,
161+ layout: Layout<'_>,
162+ cursor: mouse::Cursor,
163+ renderer: &cosmic::Renderer,
164+ clipboard: &mut dyn Clipboard,
165+ shell: &mut Shell<'_, Message>,
166+ viewport: &Rectangle,
167+ ) {
168+ self.inner.as_widget_mut().update(
169+ &mut tree.children[0],
170+ event,
171+ inner_layout(layout),
172+ cursor,
173+ renderer,
174+ clipboard,
175+ shell,
176+ viewport,
177+ );
178+ }
179+
180+ fn mouse_interaction(
181+ &self,
182+ tree: &Tree,
183+ layout: Layout<'_>,
184+ cursor: mouse::Cursor,
185+ viewport: &Rectangle,
186+ renderer: &cosmic::Renderer,
187+ ) -> mouse::Interaction {
188+ self.inner.as_widget().mouse_interaction(
189+ &tree.children[0],
190+ inner_layout(layout),
191+ cursor,
192+ viewport,
193+ renderer,
194+ )
195+ }
196+
197+ fn draw(
198+ &self,
199+ tree: &Tree,
200+ renderer: &mut cosmic::Renderer,
201+ theme: &cosmic::Theme,
202+ style: &renderer::Style,
203+ layout: Layout<'_>,
204+ cursor: mouse::Cursor,
205+ viewport: &Rectangle,
206+ ) {
207+ self.inner.as_widget().draw(
208+ &tree.children[0],
209+ renderer,
210+ theme,
211+ style,
212+ inner_layout(layout),
213+ cursor,
214+ viewport,
215+ );
216+ }
217+
218+ fn overlay<'b>(
219+ &'b mut self,
220+ tree: &'b mut Tree,
221+ layout: Layout<'b>,
222+ renderer: &cosmic::Renderer,
223+ viewport: &Rectangle,
224+ translation: Vector,
225+ ) -> Option<overlay::Element<'b, Message, cosmic::Theme, cosmic::Renderer>> {
226+ self.inner.as_widget_mut().overlay(
227+ &mut tree.children[0],
228+ inner_layout(layout),
229+ renderer,
230+ viewport,
231+ translation,
232+ )
233+ }
234+}
235+
236+/// The one child this widget's layout node has.
237+fn inner_layout(layout: Layout<'_>) -> Layout<'_> {
238+ layout
239+ .children()
240+ .next()
241+ .expect("a Rows layout holds exactly one child")
242+}
243+
244+impl<'a, Message: 'a> From<Rows<'a, Message>> for Element<'a, Message> {
245+ fn from(rows: Rows<'a, Message>) -> Self {
246+ Element::new(rows)
247+ }
248+}