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

Keep asking until the row is there, and say so when it is not

A jump asks on the commit the reader made it, which is a frame before the
room it lands in has been built: no row is laid out, so no row has a place
written down, so there is nothing to scroll to yet. The ask is answered by
trying again on the next frame, and the next — but it used to stop the moment
nothing was asking, and a room part-way through being built looks exactly
like a jump that is over. It is not over, it is early, so the budget is spent
in full.

Which row is asking is asked again each time rather than carried. Rows are
rebuilt under the reconciler as a buffer takes lines, so the row that was
node 412 a frame ago is node 587 now, and a retry holding the old number
looks up a place for a row nobody has. Which room a reader jumped into
decided whether the jump worked, which is as strange as it sounds until you
see what it hung on.

And `JOLT_SCROLL_LOG` says what each jump decided: the row, where the layout
put it, the viewport it is centred in, and the offset asked for — or that it
has no place yet and how many tries are left. A jump is three numbers and a
lookup, and which of them is wrong is not a thing anyone can tell from a
window that scrolled to the wrong place. Two bugs were found with it that
were not going to be found by reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T23:32:54-07:00 Browse files
b49f82a parent: 0ab003e
modified crates/jolt-cosmic/src/lib.rs +149 -7
@@ -355,6 +355,104 @@ fn centred_offset(top: f32, height: f32, viewport: f32) -> f32 {
355355 (top - (viewport - height).max(0.0) / 2.0).max(0.0)
356356 }
357357
358+/// Whether to say out loud what every jump decided, on stderr.
359+///
360+/// Set `JOLT_SCROLL_LOG` to anything. A jump is three numbers and a lookup,
361+/// and which of them is wrong is not a thing anyone can tell from a window
362+/// that scrolled to the wrong place.
363+fn scroll_log() -> bool {
364+ static ON: LazyLock<bool> = LazyLock::new(|| std::env::var_os("JOLT_SCROLL_LOG").is_some());
365+ *ON
366+}
367+
368+/// How many frames a reveal keeps trying for.
369+///
370+/// A row is measured by the layout that draws it, so the frame a jump is asked
371+/// on is a frame too early: the places written down are the ones from before
372+/// the room changed. Twenty frames is a third of a second at sixty, which is
373+/// longer than a screen takes to build and shorter than a reader waits before
374+/// deciding nothing happened.
375+const REVEAL_TRIES: u8 = 20;
376+
377+/// The row of the scroll area called `name` that is asking to be shown, as the
378+/// tree has it now.
379+///
380+/// Asked again on every attempt rather than carried, because a row is not the
381+/// same node for long. A buffer that takes a line while a jump is landing is
382+/// rebuilt under the reconciler, and the row that was node 412 a frame ago is
383+/// node 587 now — so a retry holding the old number would look up a place for
384+/// a row nobody has, and go on failing until it gave up. Which room a reader
385+/// jumped into decided whether it worked, and that is exactly as strange as
386+/// it sounds until you see what it depends on.
387+fn asking_row(t: &Tree, name: &str) -> Option<i32> {
388+ let mut found = None;
389+ walk(t, t.root_id(), &mut |id, n| {
390+ if found.is_some() || n.tag != "scroll" || scroll_name(n, id) != name {
391+ return;
392+ }
393+ for row in &n.children {
394+ let mut asked = false;
395+ walk(t, *row, &mut |_, node| {
396+ asked |= node.bool("scroll-here") == Some(true);
397+ });
398+ if asked {
399+ found = Some(*row);
400+ break;
401+ }
402+ }
403+ });
404+ found
405+}
406+
407+/// Ask to be taken to the row of `name` that wants showing — now if its place
408+/// is known, and on the next frame if it is not.
409+///
410+/// A task that is already finished is not a wasted frame: iced takes its
411+/// message on the next pass of the loop, which is after this frame has been
412+/// laid out — and being laid out is exactly what the row has to have done for
413+/// there to be an answer.
414+fn reveal(name: String, row: i32, viewport: f32) -> Task<Message> {
415+ match placements(&name).get(row) {
416+ Some((top, height)) => {
417+ let y = centred_offset(top, height, viewport);
418+ if scroll_log() {
419+ eprintln!(
420+ "jolt-scroll: {name} row {row} at {top} (h {height}), viewport {viewport} -> {y}"
421+ );
422+ }
423+ iced_scrollable::scroll_to(scroll_id(&name), AbsoluteOffset { x: None, y: Some(y) })
424+ }
425+ None => {
426+ if scroll_log() {
427+ eprintln!("jolt-scroll: {name} row {row} has no place yet, trying again");
428+ }
429+ Task::future(async move { cosmic::Action::App(Message::Reveal(name, REVEAL_TRIES)) })
430+ }
431+ }
432+}
433+
434+/// How many rows the scroll area called `name` has, and how many of them are
435+/// asking to be shown. For the log alone.
436+fn scroll_shape(t: &Tree, name: &str) -> (usize, usize) {
437+ let mut shape = (0, 0);
438+ walk(t, t.root_id(), &mut |id, n| {
439+ if shape.0 > 0 || n.tag != "scroll" || scroll_name(n, id) != name {
440+ return;
441+ }
442+ shape.0 = n.children.len();
443+ for row in &n.children {
444+ let mut asked = false;
445+ walk(t, *row, &mut |_, node| {
446+ asked |= node.bool("scroll-here") == Some(true);
447+ });
448+ if asked {
449+ shape.1 += 1;
450+ }
451+ }
452+ });
453+ shape
454+}
455+
358456 fn snap_to_end(name: &str) -> Task<Message> {
359457 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
360458 }
@@ -383,6 +481,9 @@ enum Message {
383481 Hover(i32),
384482 Unhover(i32),
385483 Scrolled(i32, String, Viewport),
484+ /// Show whichever row of this scroll area is asking to be shown, and how
485+ /// many more frames to keep trying for. See `reveal`.
486+ Reveal(String, u8),
386487 PickImage,
387488 Picked(Option<PathBuf>),
388489 }
@@ -449,14 +550,15 @@ impl App {
449550 // last layout. Both, or there is nothing to do yet: the row is
450551 // measured on the frame it appears, and the ask stands until it
451552 // 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);
553+ // A row is asking to be shown. Whether or not it can be shown yet,
554+ // nothing else may move this list while it is asking: the branch
555+ // below would otherwise take a reader who was at the newest line —
556+ // which is most readers, most of the time — straight back to it,
557+ // and a jump that ends at the bottom of the room reads as a jump
558+ // that did nothing.
559+ if let Some(row) = ask.reveal {
454560 memo.at_end = false;
455- memo.offset_y = y;
456- tasks.push(iced_scrollable::scroll_to(
457- scroll_id(&ask.name),
458- AbsoluteOffset { x: None, y: Some(y) },
459- ));
561+ tasks.push(reveal(ask.name.clone(), row, memo.height));
460562 } else if jumped || (ask.stick && memo.at_end) {
461563 memo.at_end = true;
462564 tasks.push(snap_to_end(&ask.name));
@@ -568,6 +670,46 @@ impl cosmic::Application for App {
568670 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
569671 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
570672 Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport),
673+ // The row was not laid out when the jump was asked for. Look
674+ // again, and keep looking for a few frames: a room the reader has
675+ // only just been taken to has to be built before its lines have
676+ // anywhere to be.
677+ Message::Reveal(name, tries) => {
678+ let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height);
679+ let place = asking_row(&self.tree, &name)
680+ .and_then(|row| placements(&name).get(row));
681+ if let Some((top, height)) = place {
682+ let y = centred_offset(top, height, viewport);
683+ if let Some(memo) = self.scrolls.get_mut(&name) {
684+ memo.at_end = false;
685+ memo.offset_y = y;
686+ }
687+ return iced_scrollable::scroll_to(
688+ scroll_id(&name),
689+ AbsoluteOffset { x: None, y: Some(y) },
690+ );
691+ }
692+ if scroll_log() {
693+ let asking = asking_row(&self.tree, &name);
694+ let (rows, here) = scroll_shape(&self.tree, &name);
695+ eprintln!(
696+ "jolt-scroll: {name} retry {tries}, asking {asking:?}, \
697+ {rows} rows, {here} asking to be shown, \
698+ {} placed, viewport {viewport}",
699+ placements(&name).len()
700+ );
701+ }
702+ // Not landed yet. Keep trying for the whole budget rather
703+ // than stopping the moment nothing is asking: a room the
704+ // reader has just been taken to is built over several frames,
705+ // and one where the rows are not in the tree yet looks exactly
706+ // like a jump that is over. It is not over, it is early.
707+ if tries > 0 {
708+ return Task::future(async move {
709+ cosmic::Action::App(Message::Reveal(name, tries - 1))
710+ });
711+ }
712+ }
571713 // The desktop's own chooser, through the portal, on libcosmic's
572714 // executor: it is a D-Bus round trip, and the window keeps
573715 // painting while it is open.
@@ -355,6 +355,104 @@ fn centred_offset(top: f32, height: f32, viewport: f32) -> f32 {
355 (top - (viewport - height).max(0.0) / 2.0).max(0.0)355 (top - (viewport - height).max(0.0) / 2.0).max(0.0)
356 }356 }
357 357
358+/// Whether to say out loud what every jump decided, on stderr.
359+///
360+/// Set `JOLT_SCROLL_LOG` to anything. A jump is three numbers and a lookup,
361+/// and which of them is wrong is not a thing anyone can tell from a window
362+/// that scrolled to the wrong place.
363+fn scroll_log() -> bool {
364+ static ON: LazyLock<bool> = LazyLock::new(|| std::env::var_os("JOLT_SCROLL_LOG").is_some());
365+ *ON
366+}
367+
368+/// How many frames a reveal keeps trying for.
369+///
370+/// A row is measured by the layout that draws it, so the frame a jump is asked
371+/// on is a frame too early: the places written down are the ones from before
372+/// the room changed. Twenty frames is a third of a second at sixty, which is
373+/// longer than a screen takes to build and shorter than a reader waits before
374+/// deciding nothing happened.
375+const REVEAL_TRIES: u8 = 20;
376+
377+/// The row of the scroll area called `name` that is asking to be shown, as the
378+/// tree has it now.
379+///
380+/// Asked again on every attempt rather than carried, because a row is not the
381+/// same node for long. A buffer that takes a line while a jump is landing is
382+/// rebuilt under the reconciler, and the row that was node 412 a frame ago is
383+/// node 587 now — so a retry holding the old number would look up a place for
384+/// a row nobody has, and go on failing until it gave up. Which room a reader
385+/// jumped into decided whether it worked, and that is exactly as strange as
386+/// it sounds until you see what it depends on.
387+fn asking_row(t: &Tree, name: &str) -> Option<i32> {
388+ let mut found = None;
389+ walk(t, t.root_id(), &mut |id, n| {
390+ if found.is_some() || n.tag != "scroll" || scroll_name(n, id) != name {
391+ return;
392+ }
393+ for row in &n.children {
394+ let mut asked = false;
395+ walk(t, *row, &mut |_, node| {
396+ asked |= node.bool("scroll-here") == Some(true);
397+ });
398+ if asked {
399+ found = Some(*row);
400+ break;
401+ }
402+ }
403+ });
404+ found
405+}
406+
407+/// Ask to be taken to the row of `name` that wants showing — now if its place
408+/// is known, and on the next frame if it is not.
409+///
410+/// A task that is already finished is not a wasted frame: iced takes its
411+/// message on the next pass of the loop, which is after this frame has been
412+/// laid out — and being laid out is exactly what the row has to have done for
413+/// there to be an answer.
414+fn reveal(name: String, row: i32, viewport: f32) -> Task<Message> {
415+ match placements(&name).get(row) {
416+ Some((top, height)) => {
417+ let y = centred_offset(top, height, viewport);
418+ if scroll_log() {
419+ eprintln!(
420+ "jolt-scroll: {name} row {row} at {top} (h {height}), viewport {viewport} -> {y}"
421+ );
422+ }
423+ iced_scrollable::scroll_to(scroll_id(&name), AbsoluteOffset { x: None, y: Some(y) })
424+ }
425+ None => {
426+ if scroll_log() {
427+ eprintln!("jolt-scroll: {name} row {row} has no place yet, trying again");
428+ }
429+ Task::future(async move { cosmic::Action::App(Message::Reveal(name, REVEAL_TRIES)) })
430+ }
431+ }
432+}
433+
434+/// How many rows the scroll area called `name` has, and how many of them are
435+/// asking to be shown. For the log alone.
436+fn scroll_shape(t: &Tree, name: &str) -> (usize, usize) {
437+ let mut shape = (0, 0);
438+ walk(t, t.root_id(), &mut |id, n| {
439+ if shape.0 > 0 || n.tag != "scroll" || scroll_name(n, id) != name {
440+ return;
441+ }
442+ shape.0 = n.children.len();
443+ for row in &n.children {
444+ let mut asked = false;
445+ walk(t, *row, &mut |_, node| {
446+ asked |= node.bool("scroll-here") == Some(true);
447+ });
448+ if asked {
449+ shape.1 += 1;
450+ }
451+ }
452+ });
453+ shape
454+}
455+
358 fn snap_to_end(name: &str) -> Task<Message> {456 fn snap_to_end(name: &str) -> Task<Message> {
359 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })457 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
360 }458 }
@@ -383,6 +481,9 @@ enum Message {
383 Hover(i32),481 Hover(i32),
384 Unhover(i32),482 Unhover(i32),
385 Scrolled(i32, String, Viewport),483 Scrolled(i32, String, Viewport),
484+ /// Show whichever row of this scroll area is asking to be shown, and how
485+ /// many more frames to keep trying for. See `reveal`.
486+ Reveal(String, u8),
386 PickImage,487 PickImage,
387 Picked(Option<PathBuf>),488 Picked(Option<PathBuf>),
388 }489 }
@@ -449,14 +550,15 @@ impl App {
449 // last layout. Both, or there is nothing to do yet: the row is550 // 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 it551 // measured on the frame it appears, and the ask stands until it
451 // has been.552 // has been.
452- if let Some((top, height)) = ask.reveal.and_then(|row| placements(&ask.name).get(row)) {553+ // A row is asking to be shown. Whether or not it can be shown yet,
453- let y = centred_offset(top, height, memo.height);554+ // nothing else may move this list while it is asking: the branch
555+ // below would otherwise take a reader who was at the newest line —
556+ // which is most readers, most of the time — straight back to it,
557+ // and a jump that ends at the bottom of the room reads as a jump
558+ // that did nothing.
559+ if let Some(row) = ask.reveal {
454 memo.at_end = false;560 memo.at_end = false;
455- memo.offset_y = y;561+ tasks.push(reveal(ask.name.clone(), row, memo.height));
456- tasks.push(iced_scrollable::scroll_to(
457- scroll_id(&ask.name),
458- AbsoluteOffset { x: None, y: Some(y) },
459- ));
460 } else if jumped || (ask.stick && memo.at_end) {562 } else if jumped || (ask.stick && memo.at_end) {
461 memo.at_end = true;563 memo.at_end = true;
462 tasks.push(snap_to_end(&ask.name));564 tasks.push(snap_to_end(&ask.name));
@@ -568,6 +670,46 @@ impl cosmic::Application for App {
568 Message::Hover(node) => post(node, "hover", String::new(), 0.0),670 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
569 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),671 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
570 Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport),672 Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport),
673+ // The row was not laid out when the jump was asked for. Look
674+ // again, and keep looking for a few frames: a room the reader has
675+ // only just been taken to has to be built before its lines have
676+ // anywhere to be.
677+ Message::Reveal(name, tries) => {
678+ let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height);
679+ let place = asking_row(&self.tree, &name)
680+ .and_then(|row| placements(&name).get(row));
681+ if let Some((top, height)) = place {
682+ let y = centred_offset(top, height, viewport);
683+ if let Some(memo) = self.scrolls.get_mut(&name) {
684+ memo.at_end = false;
685+ memo.offset_y = y;
686+ }
687+ return iced_scrollable::scroll_to(
688+ scroll_id(&name),
689+ AbsoluteOffset { x: None, y: Some(y) },
690+ );
691+ }
692+ if scroll_log() {
693+ let asking = asking_row(&self.tree, &name);
694+ let (rows, here) = scroll_shape(&self.tree, &name);
695+ eprintln!(
696+ "jolt-scroll: {name} retry {tries}, asking {asking:?}, \
697+ {rows} rows, {here} asking to be shown, \
698+ {} placed, viewport {viewport}",
699+ placements(&name).len()
700+ );
701+ }
702+ // Not landed yet. Keep trying for the whole budget rather
703+ // than stopping the moment nothing is asking: a room the
704+ // reader has just been taken to is built over several frames,
705+ // and one where the rows are not in the tree yet looks exactly
706+ // like a jump that is over. It is not over, it is early.
707+ if tries > 0 {
708+ return Task::future(async move {
709+ cosmic::Action::App(Message::Reveal(name, tries - 1))
710+ });
711+ }
712+ }
571 // The desktop's own chooser, through the portal, on libcosmic's713 // The desktop's own chooser, through the portal, on libcosmic's
572 // executor: it is a D-Bus round trip, and the window keeps714 // executor: it is a D-Bus round trip, and the window keeps
573 // painting while it is open.715 // painting while it is open.
modified crates/jolt-cosmic/src/rows.rs +5 -0
@@ -51,6 +51,11 @@ impl Placements {
5151 self.0.lock().ok()?.get(&key).copied()
5252 }
5353
54+ /// How many rows have a place written down. For the log alone.
55+ pub fn len(&self) -> usize {
56+ self.0.lock().map(|held| held.len()).unwrap_or(0)
57+ }
58+
5459 fn write(&self, rows: HashMap<i32, (f32, f32)>) {
5560 if let Ok(mut held) = self.0.lock() {
5661 *held = rows;
@@ -51,6 +51,11 @@ impl Placements {
51 self.0.lock().ok()?.get(&key).copied()51 self.0.lock().ok()?.get(&key).copied()
52 }52 }
53 53
54+ /// How many rows have a place written down. For the log alone.
55+ pub fn len(&self) -> usize {
56+ self.0.lock().map(|held| held.len()).unwrap_or(0)
57+ }
58+
54 fn write(&self, rows: HashMap<i32, (f32, f32)>) {59 fn write(&self, rows: HashMap<i32, (f32, f32)>) {
55 if let Ok(mut held) = self.0.lock() {60 if let Ok(mut held) = self.0.lock() {
56 *held = rows;61 *held = rows;