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>
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 { | ||
| 158 | 158 | // An unknown tag with nothing under it paints its own text, so it has |
| 159 | 159 | // to be measured as the label it turns out to be — a widget given no |
| 160 | 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 | 167 | if minimum { |
| 163 | 168 | longest_word(props.label()) |
| 164 | 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 | 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 | 232 | pub fn width(tree: &Tree, id: u32, minimum: bool) -> u16 { |
| 213 | 233 | let props = tree.props(id); |
| 234 | + let requested = props.cells("width-request", 0); | |
| 235 | + if requested > 0 { | |
| 236 | + return requested; | |
| 237 | + } | |
| 214 | 238 | 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) | |
| 217 | 240 | } |
| 218 | 241 | |
| 219 | 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 | 250 | let inner = avail.saturating_sub(pad.saturating_mul(2)); |
| 228 | 251 | let content = match tag { |
| 229 | 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 | 254 | wrap(props.label(), inner).len() as u16 |
| 232 | 255 | } |
| 233 | 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 | 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 | 335 | /// Share `avail` out among the children of `id` along one axis. |
| 264 | 336 | /// |
| 265 | 337 | /// `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 | 359 | let measure = |child: u32, minimum: bool| -> i64 { |
| 288 | 360 | if across { |
| 289 | 361 | width(tree, child, minimum) as i64 |
| 362 | + } else if minimum { | |
| 363 | + min_height_for_width(tree, child, cross) as i64 | |
| 290 | 364 | } 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. | |
| 293 | 367 | height_for_width(tree, child, cross) as i64 |
| 294 | 368 | } |
| 295 | 369 | }; |
| @@ -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 has | 158 | // 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 no | 159 | // 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 u16 | 254 | 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, false | 337 | /// `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 i64 | 361 | 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 i64 | 367 | 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 { | ||
| 25 | 25 | pub hits: Vec<(u32, Rect)>, |
| 26 | 26 | /// How far each scroll node's viewport actually was, after clamping to the |
| 27 | 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 | 32 | /// Where the cursor should sit — the focused entry's caret, if any. |
| 30 | 33 | pub cursor: Option<(u16, u16)>, |
| 31 | 34 | } |
| @@ -133,7 +136,7 @@ impl Painter<'_> { | ||
| 133 | 136 | // out of the middle of a message is not a missing widget; it is a |
| 134 | 137 | // missing sentence. |
| 135 | 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 | 140 | self.wrapped(inner, props.label(), style); |
| 138 | 141 | } else { |
| 139 | 142 | self.children(id, inner, style, enabled); |
| @@ -344,7 +347,7 @@ impl Painter<'_> { | ||
| 344 | 347 | .max(1); |
| 345 | 348 | let max_offset = content_h.saturating_sub(area.h); |
| 346 | 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 | 352 | let mut buffer = Screen::new(area.w, content_h); |
| 350 | 353 | 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 the | 26 | /// 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 a | 136 | // 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() { | ||
| 400 | 400 | assert_eq!(ui.screen.line(0), "the first line"); |
| 401 | 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 | +} | |
| @@ -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; | ||
| 13 | 13 | use crate::paint::{self, Painted}; |
| 14 | 14 | use crate::screen::Screen; |
| 15 | 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 | 36 | pub struct Ui { |
| 18 | 37 | pub tree: Tree, |
| @@ -24,6 +43,8 @@ pub struct Ui { | ||
| 24 | 43 | painted: Painted, |
| 25 | 44 | tick: u64, |
| 26 | 45 | quit: bool, |
| 46 | + /// Scroll positions by `:scroll-key`, across re-renders. | |
| 47 | + scrolls: HashMap<String, Scrolled>, | |
| 27 | 48 | } |
| 28 | 49 | |
| 29 | 50 | impl Ui { |
| @@ -36,6 +57,7 @@ impl Ui { | ||
| 36 | 57 | painted: Painted::default(), |
| 37 | 58 | tick: 0, |
| 38 | 59 | quit: false, |
| 60 | + scrolls: HashMap::new(), | |
| 39 | 61 | } |
| 40 | 62 | } |
| 41 | 63 | |
| @@ -63,6 +85,7 @@ impl Ui { | ||
| 63 | 85 | /// focusable now, and how far each scroll area really is. |
| 64 | 86 | pub fn frame(&mut self) { |
| 65 | 87 | self.tick = self.tick.wrapping_add(1); |
| 88 | + self.restore_scrolls(self.tree.root()); | |
| 66 | 89 | self.paint_once(); |
| 67 | 90 | if self.settle_focus() { |
| 68 | 91 | // Focus is decided by what the paint found, so the frame that |
| @@ -70,12 +93,63 @@ impl Ui { | ||
| 70 | 93 | // of a screen shows nothing focused and the second one does. |
| 71 | 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 | 97 | // Painting clamps the viewport to the content; write the clamped |
| 75 | 98 | // value back so the caller's next `+1` starts from the truth. |
| 76 | 99 | if self.tree.props(node).cells("offset", 0) != offset { |
| 77 | 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 | 462 | let now = self.tree.props(node).cells("offset", 0) as i32; |
| 389 | 463 | let to = (now + by).max(0) as f64; |
| 390 | 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 | 476 | self.tree.emit(node, "scroll", String::new(), to); |
| 392 | 477 | true |
| 393 | 478 | } |
| @@ -401,7 +486,7 @@ impl Ui { | ||
| 401 | 486 | } |
| 402 | 487 | // Scroll areas take no focus, so they are not in the hit list; the |
| 403 | 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 | 490 | if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y) |
| 406 | 491 | { |
| 407 | 492 | 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 that | 91 | // 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 clamped | 97 | // 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 | true | 477 | 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; the | 487 | // 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); |