nandi/jolt-nativepublic Fork 0
68910bd
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 backlog keep its place, and leave room for what is under it

Three things a chat screen asks of a layout, and none of them worked.

A viewport had no memory. Its offset lived in a prop, and a re-render clears
props — which is right, the component's state is the truth — so a backlog lost
where it was every time a message arrived, and opened on the oldest line rather
than the newest. It is remembered here now, under `:scroll-key`, along with
whether it is following its own bottom: sticky viewports open there, stop
following when the reader scrolls up, and follow again when they come back
down, so a new line never drags the screen out from under someone reading
history.

A column could not shrink. Down the page a minimum was the natural height, so
there was no give anywhere, and a backlog taller than the screen kept every row
it asked for and painted the separator and the compose bar past the bottom
edge. A scroll is the one thing that can be shorter than its content — it is a
viewport — and the minimum recurses, because the viewport is three levels below
the column that has to do the giving.

And a width-request was a floor rather than a width. In a window it can be a
floor: a label wraps to what it is given, so a column's natural width is
whatever the layout decides. Here a label is as wide as its line, so the
sidebar was as wide as the longest thing anyone had said in it, and the
conversation beside it got ten cells to wrap in.

Also: an unknown leaf that names a picture paints nothing rather than its
label. An `:avatar`'s label is the nick behind the face, which frq already
paints on the row beside it — so every sender came out named twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-01T23:39:50-07:00 Browse files
68910bd parent: 5acc801
modified crates/jolt-tui/src/layout.rs +80 -6
@@ -158,7 +158,12 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
158158 // An unknown tag with nothing under it paints its own text, so it has
159159 // to be measured as the label it turns out to be — a widget given no
160160 // room is as invisible as one that was never painted.
161- Tag::Unknown(_) if tree.child_count(id) == 0 => {
161+ //
162+ // Unless it names a picture. An `:avatar`'s label is the nick behind
163+ // the face and an `:image`'s is its alt text: words for something that
164+ // cannot be drawn here, and in frq's case words already on the row
165+ // beside it, which is how every sender came out named twice.
166+ Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {
162167 if minimum {
163168 longest_word(props.label())
164169 } else {
@@ -208,12 +213,30 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
208213 }
209214 }
210215
216+/// True for a node whose label describes a picture rather than being text to
217+/// paint — an `:avatar`, an `:image`, a live `:feed`.
218+fn has_picture(props: &Props) -> bool {
219+ props.has("src") || props.has("feed")
220+}
221+
211222 /// A node's natural or minimum width, requests and insets included.
223+///
224+/// A `width-request` is the width, not a floor under it. In a window it can be
225+/// a floor, because a label wraps to whatever it is given and a column's
226+/// natural width is therefore whatever the layout decides. Here a label's
227+/// natural width is its whole line, so a column that holds one is as wide as
228+/// the longest thing anybody ever said in it — and `max` then hands the
229+/// sidebar the screen and leaves the conversation beside it ten cells to wrap
230+/// in. Asking for a width is the caller saying how wide the column is; nothing
231+/// else in a terminal can say it for them.
212232 pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {
213233 let props = tree.props(id);
234+ let requested = props.cells("width-request", 0);
235+ if requested > 0 {
236+ return requested;
237+ }
214238 let pad = inset(&tree.tag(id), &props).saturating_mul(2);
215- let content = intrinsic_width(tree, id, minimum).saturating_add(pad);
216- content.max(props.cells("width-request", 0))
239+ intrinsic_width(tree, id, minimum).saturating_add(pad)
217240 }
218241
219242 /// How tall `id` is when laid out `avail` columns wide.
@@ -227,7 +250,7 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
227250 let inner = avail.saturating_sub(pad.saturating_mul(2));
228251 let content = match tag {
229252 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,
230- Tag::Unknown(_) if tree.child_count(id) == 0 => {
253+ Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {
231254 wrap(props.label(), inner).len() as u16
232255 }
233256 Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,
@@ -260,6 +283,55 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
260283 .max(props.cells("height-request", 0))
261284 }
262285
286+/// The least `id` can be squeezed to at `avail` columns wide.
287+///
288+/// Down the page almost nothing can be shorter than it is: a label wrapped to
289+/// four lines needs four. A `:scroll` is the exception, and the reason there is
290+/// one — it is a viewport, so its height is whatever it is given and its
291+/// content moves inside it.
292+///
293+/// It has to recurse, because the viewport is rarely the child being measured.
294+/// In frq's chat screen the backlog is a scroll inside a column inside a row
295+/// inside the screen, and a column that reported its natural height all the
296+/// way up gave the layout nothing to take: the backlog kept every row it asked
297+/// for and the separator and compose bar under it were painted past the bottom
298+/// edge — a conversation you cannot type into.
299+pub fn min_height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
300+ let tag = tree.tag(id);
301+ let props = tree.props(id);
302+ let pad = inset(&tag, &props);
303+ let inner = avail.saturating_sub(pad.saturating_mul(2));
304+ let content = match tag {
305+ Tag::Scroll => 1,
306+ Tag::Box | Tag::Window | Tag::Frame | Tag::Overlay | Tag::Unknown(_)
307+ if tree.child_count(id) > 0 =>
308+ {
309+ let children = tree.children(id);
310+ let spacing = props.cells("spacing", 0);
311+ if horizontal(&props) && matches!(tag, Tag::Box) {
312+ let shares = share(tree, id, inner, true, 0);
313+ children
314+ .iter()
315+ .zip(shares)
316+ .map(|(c, w)| min_height_for_width(tree, *c, w))
317+ .max()
318+ .unwrap_or(0)
319+ } else {
320+ let gaps = spacing.saturating_mul(children.len().saturating_sub(1) as u16);
321+ children
322+ .iter()
323+ .map(|c| min_height_for_width(tree, *c, inner))
324+ .fold(gaps, |a, b| a.saturating_add(b))
325+ }
326+ }
327+ // Everything else is as short as it is tall.
328+ _ => return height_for_width(tree, id, avail),
329+ };
330+ content
331+ .saturating_add(pad.saturating_mul(2))
332+ .max(props.cells("height-request", 0))
333+}
334+
263335 /// Share `avail` out among the children of `id` along one axis.
264336 ///
265337 /// `across` picks the axis: true for a horizontal box sharing columns, false
@@ -287,9 +359,11 @@ pub fn share(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<
287359 let measure = |child: u32, minimum: bool| -> i64 {
288360 if across {
289361 width(tree, child, minimum) as i64
362+ } else if minimum {
363+ min_height_for_width(tree, child, cross) as i64
290364 } else {
291- // Down the page a child's height depends on the width it gets,
292- // which the caller has already fixed by the time it asks.
365+ // A child's height depends on the width it gets, which the caller
366+ // has already fixed by the time it asks.
293367 height_for_width(tree, child, cross) as i64
294368 }
295369 };
@@ -158,7 +158,12 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
158 // An unknown tag with nothing under it paints its own text, so it has158 // An unknown tag with nothing under it paints its own text, so it has
159 // to be measured as the label it turns out to be — a widget given no159 // to be measured as the label it turns out to be — a widget given no
160 // room is as invisible as one that was never painted.160 // room is as invisible as one that was never painted.
161- Tag::Unknown(_) if tree.child_count(id) == 0 => {161+ //
162+ // Unless it names a picture. An `:avatar`'s label is the nick behind
163+ // the face and an `:image`'s is its alt text: words for something that
164+ // cannot be drawn here, and in frq's case words already on the row
165+ // beside it, which is how every sender came out named twice.
166+ Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {
162 if minimum {167 if minimum {
163 longest_word(props.label())168 longest_word(props.label())
164 } else {169 } else {
@@ -208,12 +213,30 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
208 }213 }
209 }214 }
210 215
216+/// True for a node whose label describes a picture rather than being text to
217+/// paint — an `:avatar`, an `:image`, a live `:feed`.
218+fn has_picture(props: &Props) -> bool {
219+ props.has("src") || props.has("feed")
220+}
221+
211 /// A node's natural or minimum width, requests and insets included.222 /// A node's natural or minimum width, requests and insets included.
223+///
224+/// A `width-request` is the width, not a floor under it. In a window it can be
225+/// a floor, because a label wraps to whatever it is given and a column's
226+/// natural width is therefore whatever the layout decides. Here a label's
227+/// natural width is its whole line, so a column that holds one is as wide as
228+/// the longest thing anybody ever said in it — and `max` then hands the
229+/// sidebar the screen and leaves the conversation beside it ten cells to wrap
230+/// in. Asking for a width is the caller saying how wide the column is; nothing
231+/// else in a terminal can say it for them.
212 pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {232 pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 {
213 let props = tree.props(id);233 let props = tree.props(id);
234+ let requested = props.cells("width-request", 0);
235+ if requested > 0 {
236+ return requested;
237+ }
214 let pad = inset(&tree.tag(id), &props).saturating_mul(2);238 let pad = inset(&tree.tag(id), &props).saturating_mul(2);
215- let content = intrinsic_width(tree, id, minimum).saturating_add(pad);239+ intrinsic_width(tree, id, minimum).saturating_add(pad)
216- content.max(props.cells("width-request", 0))
217 }240 }
218 241
219 /// How tall `id` is when laid out `avail` columns wide.242 /// How tall `id` is when laid out `avail` columns wide.
@@ -227,7 +250,7 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
227 let inner = avail.saturating_sub(pad.saturating_mul(2));250 let inner = avail.saturating_sub(pad.saturating_mul(2));
228 let content = match tag {251 let content = match tag {
229 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,252 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,
230- Tag::Unknown(_) if tree.child_count(id) == 0 => {253+ Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {
231 wrap(props.label(), inner).len() as u16254 wrap(props.label(), inner).len() as u16
232 }255 }
233 Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,256 Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,
@@ -260,6 +283,55 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
260 .max(props.cells("height-request", 0))283 .max(props.cells("height-request", 0))
261 }284 }
262 285
286+/// The least `id` can be squeezed to at `avail` columns wide.
287+///
288+/// Down the page almost nothing can be shorter than it is: a label wrapped to
289+/// four lines needs four. A `:scroll` is the exception, and the reason there is
290+/// one — it is a viewport, so its height is whatever it is given and its
291+/// content moves inside it.
292+///
293+/// It has to recurse, because the viewport is rarely the child being measured.
294+/// In frq's chat screen the backlog is a scroll inside a column inside a row
295+/// inside the screen, and a column that reported its natural height all the
296+/// way up gave the layout nothing to take: the backlog kept every row it asked
297+/// for and the separator and compose bar under it were painted past the bottom
298+/// edge — a conversation you cannot type into.
299+pub fn min_height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
300+ let tag = tree.tag(id);
301+ let props = tree.props(id);
302+ let pad = inset(&tag, &props);
303+ let inner = avail.saturating_sub(pad.saturating_mul(2));
304+ let content = match tag {
305+ Tag::Scroll => 1,
306+ Tag::Box | Tag::Window | Tag::Frame | Tag::Overlay | Tag::Unknown(_)
307+ if tree.child_count(id) > 0 =>
308+ {
309+ let children = tree.children(id);
310+ let spacing = props.cells("spacing", 0);
311+ if horizontal(&props) && matches!(tag, Tag::Box) {
312+ let shares = share(tree, id, inner, true, 0);
313+ children
314+ .iter()
315+ .zip(shares)
316+ .map(|(c, w)| min_height_for_width(tree, *c, w))
317+ .max()
318+ .unwrap_or(0)
319+ } else {
320+ let gaps = spacing.saturating_mul(children.len().saturating_sub(1) as u16);
321+ children
322+ .iter()
323+ .map(|c| min_height_for_width(tree, *c, inner))
324+ .fold(gaps, |a, b| a.saturating_add(b))
325+ }
326+ }
327+ // Everything else is as short as it is tall.
328+ _ => return height_for_width(tree, id, avail),
329+ };
330+ content
331+ .saturating_add(pad.saturating_mul(2))
332+ .max(props.cells("height-request", 0))
333+}
334+
263 /// Share `avail` out among the children of `id` along one axis.335 /// Share `avail` out among the children of `id` along one axis.
264 ///336 ///
265 /// `across` picks the axis: true for a horizontal box sharing columns, false337 /// `across` picks the axis: true for a horizontal box sharing columns, false
@@ -287,9 +359,11 @@ pub fn share(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<
287 let measure = |child: u32, minimum: bool| -> i64 {359 let measure = |child: u32, minimum: bool| -> i64 {
288 if across {360 if across {
289 width(tree, child, minimum) as i64361 width(tree, child, minimum) as i64
362+ } else if minimum {
363+ min_height_for_width(tree, child, cross) as i64
290 } else {364 } else {
291- // Down the page a child's height depends on the width it gets,365+ // A child's height depends on the width it gets, which the caller
292- // which the caller has already fixed by the time it asks.366+ // has already fixed by the time it asks.
293 height_for_width(tree, child, cross) as i64367 height_for_width(tree, child, cross) as i64
294 }368 }
295 };369 };
modified crates/jolt-tui/src/paint.rs +6 -3
@@ -25,7 +25,10 @@ pub struct Painted {
2525 pub hits: Vec<(u32, Rect)>,
2626 /// How far each scroll node's viewport actually was, after clamping to the
2727 /// content it had. Written back so a caller cannot scroll past the end.
28- pub scrolled: Vec<(u32, u16)>,
28+ /// Each `:scroll` painted, as (node, the offset it was painted at, the
29+ /// furthest it could have been). The second number is what tells a caller
30+ /// whether it is at the bottom, which is what sticking to it means.
31+ pub scrolled: Vec<(u32, u16, u16)>,
2932 /// Where the cursor should sit — the focused entry's caret, if any.
3033 pub cursor: Option<(u16, u16)>,
3134 }
@@ -133,7 +136,7 @@ impl Painter<'_> {
133136 // out of the middle of a message is not a missing widget; it is a
134137 // missing sentence.
135138 Tag::Unknown(_) => {
136- if self.tree.child_count(id) == 0 {
139+ if self.tree.child_count(id) == 0 && !props.has("src") && !props.has("feed") {
137140 self.wrapped(inner, props.label(), style);
138141 } else {
139142 self.children(id, inner, style, enabled);
@@ -344,7 +347,7 @@ impl Painter<'_> {
344347 .max(1);
345348 let max_offset = content_h.saturating_sub(area.h);
346349 let offset = props.cells("offset", 0).min(max_offset);
347- self.out.scrolled.push((id, offset));
350+ self.out.scrolled.push((id, offset, max_offset));
348351
349352 let mut buffer = Screen::new(area.w, content_h);
350353 let mut inner = Painter {
@@ -25,7 +25,10 @@ pub struct Painted {
25 pub hits: Vec<(u32, Rect)>,25 pub hits: Vec<(u32, Rect)>,
26 /// How far each scroll node's viewport actually was, after clamping to the26 /// How far each scroll node's viewport actually was, after clamping to the
27 /// content it had. Written back so a caller cannot scroll past the end.27 /// content it had. Written back so a caller cannot scroll past the end.
28- pub scrolled: Vec<(u32, u16)>,28+ /// Each `:scroll` painted, as (node, the offset it was painted at, the
29+ /// furthest it could have been). The second number is what tells a caller
30+ /// whether it is at the bottom, which is what sticking to it means.
31+ pub scrolled: Vec<(u32, u16, u16)>,
29 /// Where the cursor should sit — the focused entry's caret, if any.32 /// Where the cursor should sit — the focused entry's caret, if any.
30 pub cursor: Option<(u16, u16)>,33 pub cursor: Option<(u16, u16)>,
31 }34 }
@@ -133,7 +136,7 @@ impl Painter<'_> {
133 // out of the middle of a message is not a missing widget; it is a136 // out of the middle of a message is not a missing widget; it is a
134 // missing sentence.137 // missing sentence.
135 Tag::Unknown(_) => {138 Tag::Unknown(_) => {
136- if self.tree.child_count(id) == 0 {139+ if self.tree.child_count(id) == 0 && !props.has("src") && !props.has("feed") {
137 self.wrapped(inner, props.label(), style);140 self.wrapped(inner, props.label(), style);
138 } else {141 } else {
139 self.children(id, inner, style, enabled);142 self.children(id, inner, style, enabled);
@@ -344,7 +347,7 @@ impl Painter<'_> {
344 .max(1);347 .max(1);
345 let max_offset = content_h.saturating_sub(area.h);348 let max_offset = content_h.saturating_sub(area.h);
346 let offset = props.cells("offset", 0).min(max_offset);349 let offset = props.cells("offset", 0).min(max_offset);
347- self.out.scrolled.push((id, offset));350+ self.out.scrolled.push((id, offset, max_offset));
348 351
349 let mut buffer = Screen::new(area.w, content_h);352 let mut buffer = Screen::new(area.w, content_h);
350 let mut inner = Painter {353 let mut inner = Painter {
modified crates/jolt-tui/src/tests.rs +99 -0
@@ -400,3 +400,102 @@ fn a_nested_column_paints_every_child_and_not_only_the_first() {
400400 assert_eq!(ui.screen.line(0), "the first line");
401401 assert_eq!(ui.screen.line(1), "[ Open ]");
402402 }
403+
404+#[test]
405+fn an_unknown_leaf_that_names_a_picture_paints_nothing() {
406+ // An `:avatar`'s label is the nick behind the face — words for something
407+ // that cannot be drawn here, and in frq already on the row beside it.
408+ let mut ui = ui();
409+ let root = ui.tree.root();
410+ node(
411+ &mut ui,
412+ root,
413+ "avatar",
414+ &[("label", "nandi.uk"), ("src", "/tmp/a.png")],
415+ );
416+ node(&mut ui, root, "label", &[("label", "nandi.uk")]);
417+ ui.frame();
418+ assert_eq!(ui.screen.line(0), "nandi.uk");
419+ assert_eq!(ui.screen.line(1), "");
420+}
421+
422+#[test]
423+fn a_sticky_viewport_opens_at_the_bottom_and_stays_there() {
424+ // A backlog taller than its viewport, in a scroll that follows its own
425+ // bottom: the newest line is what a chat client opens on, and a line
426+ // arriving must not drag the screen out from under a reader who scrolled
427+ // up to read history.
428+ let mut ui = Ui::new(20, 3);
429+ let root = ui.tree.root();
430+ let scroll = node(
431+ &mut ui,
432+ root,
433+ "scroll",
434+ &[("scroll-key", "backlog"), ("stick-to-bottom", "true")],
435+ );
436+ for n in 1..=6 {
437+ node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
438+ }
439+ ui.frame();
440+ assert_eq!(ui.screen.line(2), "line 6");
441+
442+ // Scrolled up, and it stays where it was put across a re-render — the
443+ // position lives under the key, not in a prop the next render clears.
444+ ui.wheel(0, 0, -3);
445+ ui.frame();
446+ assert_eq!(ui.screen.line(0), "line 1");
447+ ui.tree.clear_props(scroll);
448+ ui.tree.set(scroll, "scroll-key", Value::Str("backlog".into()));
449+ ui.tree
450+ .set(scroll, "stick-to-bottom", Value::Bool(true));
451+ ui.frame();
452+ assert_eq!(ui.screen.line(0), "line 1");
453+
454+ // Back down to the bottom, and it follows again.
455+ ui.wheel(0, 0, 9);
456+ ui.frame();
457+ node(&mut ui, scroll, "label", &[("label", "line 7")]);
458+ ui.frame();
459+ assert_eq!(ui.screen.line(2), "line 7");
460+}
461+
462+#[test]
463+fn a_column_that_asks_for_a_width_gets_it_and_no_more() {
464+ // Two panes in a row, the first with a width of its own. Its content is
465+ // one long line, so measured naturally it is wider than the screen and the
466+ // pane beside it is left nothing — which is the split view painting a
467+ // sidebar and a ten-cell column of wrapped fragments.
468+ let mut ui = Ui::new(40, 2);
469+ let root = ui.tree.root();
470+ let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
471+ let side = node(&mut ui, row, "vbox", &[]);
472+ // A number, as the ABI sends one: a width read as a string is no width.
473+ ui.tree.set(side, "width-request", Value::Num(10.0));
474+ node(
475+ &mut ui,
476+ side,
477+ "label",
478+ &[("label", "a preview far longer than ten cells")],
479+ );
480+ let main = node(&mut ui, row, "vbox", &[]);
481+ node(&mut ui, main, "label", &[("label", "the conversation")]);
482+ ui.frame();
483+ assert_eq!(ui.screen.line(0), "a preview the conversation");
484+}
485+
486+#[test]
487+fn a_backlog_taller_than_the_screen_leaves_the_compose_bar_its_row() {
488+ // The shape of frq's chat screen: a viewport holding more than fits, and
489+ // under it the things you act with. A column that cannot shrink hands the
490+ // scroll every row it asks for and paints the entry off the bottom edge.
491+ let mut ui = Ui::new(20, 4);
492+ let root = ui.tree.root();
493+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
494+ for n in 1..=10 {
495+ node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
496+ }
497+ node(&mut ui, root, "separator", &[]);
498+ node(&mut ui, root, "entry", &[("placeholder", "Message")]);
499+ ui.frame();
500+ assert_eq!(ui.screen.line(3), "Message");
501+}
@@ -400,3 +400,102 @@ fn a_nested_column_paints_every_child_and_not_only_the_first() {
400 assert_eq!(ui.screen.line(0), "the first line");400 assert_eq!(ui.screen.line(0), "the first line");
401 assert_eq!(ui.screen.line(1), "[ Open ]");401 assert_eq!(ui.screen.line(1), "[ Open ]");
402 }402 }
403+
404+#[test]
405+fn an_unknown_leaf_that_names_a_picture_paints_nothing() {
406+ // An `:avatar`'s label is the nick behind the face — words for something
407+ // that cannot be drawn here, and in frq already on the row beside it.
408+ let mut ui = ui();
409+ let root = ui.tree.root();
410+ node(
411+ &mut ui,
412+ root,
413+ "avatar",
414+ &[("label", "nandi.uk"), ("src", "/tmp/a.png")],
415+ );
416+ node(&mut ui, root, "label", &[("label", "nandi.uk")]);
417+ ui.frame();
418+ assert_eq!(ui.screen.line(0), "nandi.uk");
419+ assert_eq!(ui.screen.line(1), "");
420+}
421+
422+#[test]
423+fn a_sticky_viewport_opens_at_the_bottom_and_stays_there() {
424+ // A backlog taller than its viewport, in a scroll that follows its own
425+ // bottom: the newest line is what a chat client opens on, and a line
426+ // arriving must not drag the screen out from under a reader who scrolled
427+ // up to read history.
428+ let mut ui = Ui::new(20, 3);
429+ let root = ui.tree.root();
430+ let scroll = node(
431+ &mut ui,
432+ root,
433+ "scroll",
434+ &[("scroll-key", "backlog"), ("stick-to-bottom", "true")],
435+ );
436+ for n in 1..=6 {
437+ node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
438+ }
439+ ui.frame();
440+ assert_eq!(ui.screen.line(2), "line 6");
441+
442+ // Scrolled up, and it stays where it was put across a re-render — the
443+ // position lives under the key, not in a prop the next render clears.
444+ ui.wheel(0, 0, -3);
445+ ui.frame();
446+ assert_eq!(ui.screen.line(0), "line 1");
447+ ui.tree.clear_props(scroll);
448+ ui.tree.set(scroll, "scroll-key", Value::Str("backlog".into()));
449+ ui.tree
450+ .set(scroll, "stick-to-bottom", Value::Bool(true));
451+ ui.frame();
452+ assert_eq!(ui.screen.line(0), "line 1");
453+
454+ // Back down to the bottom, and it follows again.
455+ ui.wheel(0, 0, 9);
456+ ui.frame();
457+ node(&mut ui, scroll, "label", &[("label", "line 7")]);
458+ ui.frame();
459+ assert_eq!(ui.screen.line(2), "line 7");
460+}
461+
462+#[test]
463+fn a_column_that_asks_for_a_width_gets_it_and_no_more() {
464+ // Two panes in a row, the first with a width of its own. Its content is
465+ // one long line, so measured naturally it is wider than the screen and the
466+ // pane beside it is left nothing — which is the split view painting a
467+ // sidebar and a ten-cell column of wrapped fragments.
468+ let mut ui = Ui::new(40, 2);
469+ let root = ui.tree.root();
470+ let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
471+ let side = node(&mut ui, row, "vbox", &[]);
472+ // A number, as the ABI sends one: a width read as a string is no width.
473+ ui.tree.set(side, "width-request", Value::Num(10.0));
474+ node(
475+ &mut ui,
476+ side,
477+ "label",
478+ &[("label", "a preview far longer than ten cells")],
479+ );
480+ let main = node(&mut ui, row, "vbox", &[]);
481+ node(&mut ui, main, "label", &[("label", "the conversation")]);
482+ ui.frame();
483+ assert_eq!(ui.screen.line(0), "a preview the conversation");
484+}
485+
486+#[test]
487+fn a_backlog_taller_than_the_screen_leaves_the_compose_bar_its_row() {
488+ // The shape of frq's chat screen: a viewport holding more than fits, and
489+ // under it the things you act with. A column that cannot shrink hands the
490+ // scroll every row it asks for and paints the entry off the bottom edge.
491+ let mut ui = Ui::new(20, 4);
492+ let root = ui.tree.root();
493+ let scroll = node(&mut ui, root, "scroll", &[("scroll-key", "backlog")]);
494+ for n in 1..=10 {
495+ node(&mut ui, scroll, "label", &[("label", &format!("line {n}"))]);
496+ }
497+ node(&mut ui, root, "separator", &[]);
498+ node(&mut ui, root, "entry", &[("placeholder", "Message")]);
499+ ui.frame();
500+ assert_eq!(ui.screen.line(3), "Message");
501+}
modified crates/jolt-tui/src/ui.rs +87 -2
@@ -13,6 +13,25 @@ use crate::keys;
1313 use crate::paint::{self, Painted};
1414 use crate::screen::Screen;
1515 use crate::tree::{Tag, Tree, Value};
16+use std::collections::HashMap;
17+
18+/// Where one scroll area is, and whether it is following its own bottom.
19+///
20+/// It lives here rather than in the tree because a re-render clears a node's
21+/// props: glimmer writes what the component said and nothing else, which is
22+/// right — the component's state is the truth — and it means a viewport that
23+/// kept its position in a prop loses it the moment anything above it changes.
24+/// A chat backlog changes on every message, which is exactly when a reader
25+/// cares where they were.
26+#[derive(Clone, Copy)]
27+struct Scrolled {
28+ offset: u16,
29+ /// Following the bottom. A `:stick-to-bottom` viewport starts this way,
30+ /// stops when the reader scrolls up, and starts again when they come back
31+ /// down — which is the behaviour that lets a new message arrive without
32+ /// dragging the screen out from under someone reading history.
33+ pinned: bool,
34+}
1635
1736 pub struct Ui {
1837 pub tree: Tree,
@@ -24,6 +43,8 @@ pub struct Ui {
2443 painted: Painted,
2544 tick: u64,
2645 quit: bool,
46+ /// Scroll positions by `:scroll-key`, across re-renders.
47+ scrolls: HashMap<String, Scrolled>,
2748 }
2849
2950 impl Ui {
@@ -36,6 +57,7 @@ impl Ui {
3657 painted: Painted::default(),
3758 tick: 0,
3859 quit: false,
60+ scrolls: HashMap::new(),
3961 }
4062 }
4163
@@ -63,6 +85,7 @@ impl Ui {
6385 /// focusable now, and how far each scroll area really is.
6486 pub fn frame(&mut self) {
6587 self.tick = self.tick.wrapping_add(1);
88+ self.restore_scrolls(self.tree.root());
6689 self.paint_once();
6790 if self.settle_focus() {
6891 // Focus is decided by what the paint found, so the frame that
@@ -70,12 +93,63 @@ impl Ui {
7093 // of a screen shows nothing focused and the second one does.
7194 self.paint_once();
7295 }
73- for (node, offset) in self.painted.scrolled.clone() {
96+ for (node, offset, max) in self.painted.scrolled.clone() {
7497 // Painting clamps the viewport to the content; write the clamped
7598 // value back so the caller's next `+1` starts from the truth.
7699 if self.tree.props(node).cells("offset", 0) != offset {
77100 self.tree.set(node, "offset", Value::Num(offset as f64));
78101 }
102+ // And remember it under its key, which is what survives the
103+ // re-render that is about to clear the prop. Being at the bottom
104+ // is what pins it there: a reader who scrolls back down has said
105+ // they want to follow again, and never has to say so twice.
106+ let key = self.scroll_key(node);
107+ let sticky = self.tree.props(node).bool("stick-to-bottom", false);
108+ self.scrolls.insert(
109+ key,
110+ Scrolled {
111+ offset,
112+ pinned: sticky && offset >= max,
113+ },
114+ );
115+ }
116+ }
117+
118+ /// What a scroll area is remembered by. Its `:scroll-key` when it has one,
119+ /// because that is a name the caller chose and means the same viewport
120+ /// after a re-mount; its handle otherwise, which at least survives a
121+ /// re-render that leaves the node where it was.
122+ fn scroll_key(&self, node: u32) -> String {
123+ let props = self.tree.props(node);
124+ let key = props.str("scroll-key");
125+ if key.is_empty() {
126+ format!("#{node}")
127+ } else {
128+ key.to_owned()
129+ }
130+ }
131+
132+ /// Put every scroll area back where it was before the tree is painted.
133+ ///
134+ /// A pinned one is asked for an offset past the end and painting clamps it
135+ /// to the bottom, which is how it follows content that grew since the last
136+ /// frame without this having to measure anything.
137+ fn restore_scrolls(&mut self, id: u32) {
138+ if matches!(self.tree.tag(id), Tag::Scroll) {
139+ let key = self.scroll_key(id);
140+ let sticky = self.tree.props(id).bool("stick-to-bottom", false);
141+ let to = match self.scrolls.get(&key) {
142+ Some(state) if sticky && state.pinned => u16::MAX,
143+ Some(state) => state.offset,
144+ // Never seen: a sticky viewport opens at the bottom, which for
145+ // a backlog is the message that just arrived.
146+ None if sticky => u16::MAX,
147+ None => return,
148+ };
149+ self.tree.set(id, "offset", Value::Num(to as f64));
150+ }
151+ for child in self.tree.children(id) {
152+ self.restore_scrolls(child);
79153 }
80154 }
81155
@@ -388,6 +462,17 @@ impl Ui {
388462 let now = self.tree.props(node).cells("offset", 0) as i32;
389463 let to = (now + by).max(0) as f64;
390464 self.tree.set(node, "offset", Value::Num(to));
465+ // Unpin on the way up, and let the next frame decide whether this put
466+ // the reader back at the bottom — painting is what knows how far down
467+ // that is.
468+ let key = self.scroll_key(node);
469+ self.scrolls.insert(
470+ key,
471+ Scrolled {
472+ offset: to as u16,
473+ pinned: false,
474+ },
475+ );
391476 self.tree.emit(node, "scroll", String::new(), to);
392477 true
393478 }
@@ -401,7 +486,7 @@ impl Ui {
401486 }
402487 // Scroll areas take no focus, so they are not in the hit list; the
403488 // frame records the ones it painted, which is enough for a wheel.
404- let painted = self.painted.scrolled.iter().any(|(n, _)| *n == id);
489+ let painted = self.painted.scrolled.iter().any(|(n, _, _)| *n == id);
405490 if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y)
406491 {
407492 return Some(id);
@@ -13,6 +13,25 @@ use crate::keys;
13 use crate::paint::{self, Painted};13 use crate::paint::{self, Painted};
14 use crate::screen::Screen;14 use crate::screen::Screen;
15 use crate::tree::{Tag, Tree, Value};15 use crate::tree::{Tag, Tree, Value};
16+use std::collections::HashMap;
17+
18+/// Where one scroll area is, and whether it is following its own bottom.
19+///
20+/// It lives here rather than in the tree because a re-render clears a node's
21+/// props: glimmer writes what the component said and nothing else, which is
22+/// right — the component's state is the truth — and it means a viewport that
23+/// kept its position in a prop loses it the moment anything above it changes.
24+/// A chat backlog changes on every message, which is exactly when a reader
25+/// cares where they were.
26+#[derive(Clone, Copy)]
27+struct Scrolled {
28+ offset: u16,
29+ /// Following the bottom. A `:stick-to-bottom` viewport starts this way,
30+ /// stops when the reader scrolls up, and starts again when they come back
31+ /// down — which is the behaviour that lets a new message arrive without
32+ /// dragging the screen out from under someone reading history.
33+ pinned: bool,
34+}
16 35
17 pub struct Ui {36 pub struct Ui {
18 pub tree: Tree,37 pub tree: Tree,
@@ -24,6 +43,8 @@ pub struct Ui {
24 painted: Painted,43 painted: Painted,
25 tick: u64,44 tick: u64,
26 quit: bool,45 quit: bool,
46+ /// Scroll positions by `:scroll-key`, across re-renders.
47+ scrolls: HashMap<String, Scrolled>,
27 }48 }
28 49
29 impl Ui {50 impl Ui {
@@ -36,6 +57,7 @@ impl Ui {
36 painted: Painted::default(),57 painted: Painted::default(),
37 tick: 0,58 tick: 0,
38 quit: false,59 quit: false,
60+ scrolls: HashMap::new(),
39 }61 }
40 }62 }
41 63
@@ -63,6 +85,7 @@ impl Ui {
63 /// focusable now, and how far each scroll area really is.85 /// focusable now, and how far each scroll area really is.
64 pub fn frame(&mut self) {86 pub fn frame(&mut self) {
65 self.tick = self.tick.wrapping_add(1);87 self.tick = self.tick.wrapping_add(1);
88+ self.restore_scrolls(self.tree.root());
66 self.paint_once();89 self.paint_once();
67 if self.settle_focus() {90 if self.settle_focus() {
68 // Focus is decided by what the paint found, so the frame that91 // Focus is decided by what the paint found, so the frame that
@@ -70,12 +93,63 @@ impl Ui {
70 // of a screen shows nothing focused and the second one does.93 // of a screen shows nothing focused and the second one does.
71 self.paint_once();94 self.paint_once();
72 }95 }
73- for (node, offset) in self.painted.scrolled.clone() {96+ for (node, offset, max) in self.painted.scrolled.clone() {
74 // Painting clamps the viewport to the content; write the clamped97 // Painting clamps the viewport to the content; write the clamped
75 // value back so the caller's next `+1` starts from the truth.98 // value back so the caller's next `+1` starts from the truth.
76 if self.tree.props(node).cells("offset", 0) != offset {99 if self.tree.props(node).cells("offset", 0) != offset {
77 self.tree.set(node, "offset", Value::Num(offset as f64));100 self.tree.set(node, "offset", Value::Num(offset as f64));
78 }101 }
102+ // And remember it under its key, which is what survives the
103+ // re-render that is about to clear the prop. Being at the bottom
104+ // is what pins it there: a reader who scrolls back down has said
105+ // they want to follow again, and never has to say so twice.
106+ let key = self.scroll_key(node);
107+ let sticky = self.tree.props(node).bool("stick-to-bottom", false);
108+ self.scrolls.insert(
109+ key,
110+ Scrolled {
111+ offset,
112+ pinned: sticky && offset >= max,
113+ },
114+ );
115+ }
116+ }
117+
118+ /// What a scroll area is remembered by. Its `:scroll-key` when it has one,
119+ /// because that is a name the caller chose and means the same viewport
120+ /// after a re-mount; its handle otherwise, which at least survives a
121+ /// re-render that leaves the node where it was.
122+ fn scroll_key(&self, node: u32) -> String {
123+ let props = self.tree.props(node);
124+ let key = props.str("scroll-key");
125+ if key.is_empty() {
126+ format!("#{node}")
127+ } else {
128+ key.to_owned()
129+ }
130+ }
131+
132+ /// Put every scroll area back where it was before the tree is painted.
133+ ///
134+ /// A pinned one is asked for an offset past the end and painting clamps it
135+ /// to the bottom, which is how it follows content that grew since the last
136+ /// frame without this having to measure anything.
137+ fn restore_scrolls(&mut self, id: u32) {
138+ if matches!(self.tree.tag(id), Tag::Scroll) {
139+ let key = self.scroll_key(id);
140+ let sticky = self.tree.props(id).bool("stick-to-bottom", false);
141+ let to = match self.scrolls.get(&key) {
142+ Some(state) if sticky && state.pinned => u16::MAX,
143+ Some(state) => state.offset,
144+ // Never seen: a sticky viewport opens at the bottom, which for
145+ // a backlog is the message that just arrived.
146+ None if sticky => u16::MAX,
147+ None => return,
148+ };
149+ self.tree.set(id, "offset", Value::Num(to as f64));
150+ }
151+ for child in self.tree.children(id) {
152+ self.restore_scrolls(child);
79 }153 }
80 }154 }
81 155
@@ -388,6 +462,17 @@ impl Ui {
388 let now = self.tree.props(node).cells("offset", 0) as i32;462 let now = self.tree.props(node).cells("offset", 0) as i32;
389 let to = (now + by).max(0) as f64;463 let to = (now + by).max(0) as f64;
390 self.tree.set(node, "offset", Value::Num(to));464 self.tree.set(node, "offset", Value::Num(to));
465+ // Unpin on the way up, and let the next frame decide whether this put
466+ // the reader back at the bottom — painting is what knows how far down
467+ // that is.
468+ let key = self.scroll_key(node);
469+ self.scrolls.insert(
470+ key,
471+ Scrolled {
472+ offset: to as u16,
473+ pinned: false,
474+ },
475+ );
391 self.tree.emit(node, "scroll", String::new(), to);476 self.tree.emit(node, "scroll", String::new(), to);
392 true477 true
393 }478 }
@@ -401,7 +486,7 @@ impl Ui {
401 }486 }
402 // Scroll areas take no focus, so they are not in the hit list; the487 // Scroll areas take no focus, so they are not in the hit list; the
403 // frame records the ones it painted, which is enough for a wheel.488 // frame records the ones it painted, which is enough for a wheel.
404- let painted = self.painted.scrolled.iter().any(|(n, _)| *n == id);489+ let painted = self.painted.scrolled.iter().any(|(n, _, _)| *n == id);
405 if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y)490 if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y)
406 {491 {
407 return Some(id);492 return Some(id);