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

Share a column's rows out at the width it has, not at its own height

A box asked how tall each child was by measuring it at `avail` — which down
the page is the rows it has to give away, not the columns it has to give them
in. So a child was measured two or three cells wide, every label wrapped to a
paragraph, the total ran far past the room, and the overrun came off the end:
the first child painted and every sibling after it was handed nothing.

It hid at the top of a tree, where the window is as wide as it is tall and the
two numbers are close enough to pass. Two levels down it is the whole screen —
frq's chats list painted a channel's name and dropped the Open button under it,
which reads as a list you cannot click rather than a list that was never drawn.

And an unknown tag paints as a box so its children still show, which loses the
text of one that has no children: `:status` and `:link` are widgets the caller
has and this has not, and painting the box without the label took a link out
of the middle of a message. A leaf paints its own text, and is measured as the
label it turns out to be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-01T23:16:23-07:00 Browse files
5acc801 parent: 3cfee15
modified crates/jolt-tui/src/layout.rs +52 -7
@@ -155,6 +155,16 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
155155 columns(text)
156156 }
157157 }
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
160+ // room is as invisible as one that was never painted.
161+ Tag::Unknown(_) if tree.child_count(id) == 0 => {
162+ if minimum {
163+ longest_word(props.label())
164+ } else {
165+ columns(props.label())
166+ }
167+ }
158168 Tag::Separator => 1,
159169 Tag::Spacer => props.cells("size", 1),
160170 Tag::Progress => {
@@ -217,6 +227,9 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
217227 let inner = avail.saturating_sub(pad.saturating_mul(2));
218228 let content = match tag {
219229 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,
230+ Tag::Unknown(_) if tree.child_count(id) == 0 => {
231+ wrap(props.label(), inner).len() as u16
232+ }
220233 Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,
221234 Tag::Entry => props.cells("rows", 1).max(1),
222235 Tag::Spacer => props.cells("size", 1),
@@ -226,7 +239,7 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
226239 let spacing = props.cells("spacing", 0);
227240 if horizontal(&props) && matches!(tag, Tag::Box) {
228241 // Across: each child is measured at the width it will get.
229- let shares = share(tree, id, inner, true);
242+ let shares = share(tree, id, inner, true, 0);
230243 children
231244 .iter()
232245 .zip(shares)
@@ -253,7 +266,15 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
253266 /// for a vertical one sharing rows. The rule is the same either way — natural
254267 /// sizes first, shrink proportionally toward the minimums when short, and the
255268 /// surplus to whoever set `:hexpand` / `:vexpand`.
256-pub fn share(tree: &Tree, id: u32, avail: u16, across: bool) -> Vec<u16> {
269+///
270+/// `cross` is the extent on the *other* axis, and sharing rows out cannot be
271+/// done without it: how tall a child is depends on how wide it is, because
272+/// that is what wrapping means. Passing the rows in its place measures every
273+/// label at a column count of two or three, wraps it to a paragraph, and the
274+/// overrun is then taken off the end — which paints a box's first child and
275+/// drops every sibling after it. Unused when `across`, where a width does not
276+/// depend on a height.
277+pub fn share(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<u16> {
257278 let children = tree.children(id);
258279 if children.is_empty() {
259280 return Vec::new();
@@ -269,7 +290,7 @@ pub fn share(tree: &Tree, id: u32, avail: u16, across: bool) -> Vec<u16> {
269290 } else {
270291 // Down the page a child's height depends on the width it gets,
271292 // which the caller has already fixed by the time it asks.
272- height_for_width(tree, child, avail) as i64
293+ height_for_width(tree, child, cross) as i64
273294 }
274295 };
275296
@@ -343,7 +364,13 @@ pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {
343364 let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box);
344365 let spacing = props.cells("spacing", 0);
345366 let children = tree.children(id);
346- let shares = share(tree, id, if across { area.w } else { area.h }, across);
367+ let shares = share(
368+ tree,
369+ id,
370+ if across { area.w } else { area.h },
371+ across,
372+ if across { area.h } else { area.w },
373+ );
347374
348375 let mut out = Vec::with_capacity(children.len());
349376 let mut at = 0u16;
@@ -435,7 +462,7 @@ mod tests {
435462 let a = label(&mut tree, row, "aa");
436463 let b = label(&mut tree, row, "bb");
437464 tree.set(b, "hexpand", Value::Bool(true));
438- assert_eq!(share(&tree, row, 20, true), vec![2, 18]);
465+ assert_eq!(share(&tree, row, 20, true, 1), vec![2, 18]);
439466 let _ = a;
440467 }
441468
@@ -450,7 +477,7 @@ mod tests {
450477 label(&mut tree, row, "three four");
451478 // 17 natural, 10 offered: both give up some, neither goes under its
452479 // longest word.
453- let shares = share(&tree, row, 10, true);
480+ let shares = share(&tree, row, 10, true, 1);
454481 assert_eq!(shares.iter().sum::<u16>(), 10);
455482 assert!(shares[0] >= 3 && shares[1] >= 5, "{shares:?}");
456483 }
@@ -467,7 +494,25 @@ mod tests {
467494 let b = label(&mut tree, row, "bb");
468495 tree.set(a, "hexpand", Value::Bool(true));
469496 tree.set(b, "hexpand", Value::Bool(true));
470- assert_eq!(share(&tree, row, 12, true), vec![5, 5]);
497+ assert_eq!(share(&tree, row, 12, true, 1), vec![5, 5]);
498+ }
499+
500+ #[test]
501+ fn a_column_shares_its_rows_out_at_the_width_it_has() {
502+ // A column two rows tall and thirty columns wide holds two labels, and
503+ // each is one row at that width. Measured against the rows instead —
504+ // as this did — "the second line" wraps to five, the overrun comes off
505+ // the end, and the second child is handed nothing: a box that paints
506+ // its first child and drops the rest, which is what the chats list did
507+ // to every Open button in it.
508+ let mut tree = Tree::new();
509+ let col = tree.new_node("vbox");
510+ tree.set(col, "orientation", Value::Str("vertical".into()));
511+ let root = tree.root();
512+ tree.append(root, col);
513+ label(&mut tree, col, "the first line");
514+ label(&mut tree, col, "the second line");
515+ assert_eq!(share(&tree, col, 2, false, 30), vec![1, 1]);
471516 }
472517
473518 #[test]
@@ -155,6 +155,16 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
155 columns(text)155 columns(text)
156 }156 }
157 }157 }
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
160+ // room is as invisible as one that was never painted.
161+ Tag::Unknown(_) if tree.child_count(id) == 0 => {
162+ if minimum {
163+ longest_word(props.label())
164+ } else {
165+ columns(props.label())
166+ }
167+ }
158 Tag::Separator => 1,168 Tag::Separator => 1,
159 Tag::Spacer => props.cells("size", 1),169 Tag::Spacer => props.cells("size", 1),
160 Tag::Progress => {170 Tag::Progress => {
@@ -217,6 +227,9 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
217 let inner = avail.saturating_sub(pad.saturating_mul(2));227 let inner = avail.saturating_sub(pad.saturating_mul(2));
218 let content = match tag {228 let content = match tag {
219 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,229 Tag::Label | Tag::Title | Tag::DimLabel => wrap(props.label(), inner).len() as u16,
230+ Tag::Unknown(_) if tree.child_count(id) == 0 => {
231+ wrap(props.label(), inner).len() as u16
232+ }
220 Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,233 Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,
221 Tag::Entry => props.cells("rows", 1).max(1),234 Tag::Entry => props.cells("rows", 1).max(1),
222 Tag::Spacer => props.cells("size", 1),235 Tag::Spacer => props.cells("size", 1),
@@ -226,7 +239,7 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
226 let spacing = props.cells("spacing", 0);239 let spacing = props.cells("spacing", 0);
227 if horizontal(&props) && matches!(tag, Tag::Box) {240 if horizontal(&props) && matches!(tag, Tag::Box) {
228 // Across: each child is measured at the width it will get.241 // Across: each child is measured at the width it will get.
229- let shares = share(tree, id, inner, true);242+ let shares = share(tree, id, inner, true, 0);
230 children243 children
231 .iter()244 .iter()
232 .zip(shares)245 .zip(shares)
@@ -253,7 +266,15 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
253 /// for a vertical one sharing rows. The rule is the same either way — natural266 /// for a vertical one sharing rows. The rule is the same either way — natural
254 /// sizes first, shrink proportionally toward the minimums when short, and the267 /// sizes first, shrink proportionally toward the minimums when short, and the
255 /// surplus to whoever set `:hexpand` / `:vexpand`.268 /// surplus to whoever set `:hexpand` / `:vexpand`.
256-pub fn share(tree: &Tree, id: u32, avail: u16, across: bool) -> Vec<u16> {269+///
270+/// `cross` is the extent on the *other* axis, and sharing rows out cannot be
271+/// done without it: how tall a child is depends on how wide it is, because
272+/// that is what wrapping means. Passing the rows in its place measures every
273+/// label at a column count of two or three, wraps it to a paragraph, and the
274+/// overrun is then taken off the end — which paints a box's first child and
275+/// drops every sibling after it. Unused when `across`, where a width does not
276+/// depend on a height.
277+pub fn share(tree: &Tree, id: u32, avail: u16, across: bool, cross: u16) -> Vec<u16> {
257 let children = tree.children(id);278 let children = tree.children(id);
258 if children.is_empty() {279 if children.is_empty() {
259 return Vec::new();280 return Vec::new();
@@ -269,7 +290,7 @@ pub fn share(tree: &Tree, id: u32, avail: u16, across: bool) -> Vec<u16> {
269 } else {290 } else {
270 // Down the page a child's height depends on the width it gets,291 // Down the page a child's height depends on the width it gets,
271 // which the caller has already fixed by the time it asks.292 // which the caller has already fixed by the time it asks.
272- height_for_width(tree, child, avail) as i64293+ height_for_width(tree, child, cross) as i64
273 }294 }
274 };295 };
275 296
@@ -343,7 +364,13 @@ pub fn children_rects(tree: &Tree, id: u32, area: Rect) -> Vec<Rect> {
343 let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box);364 let across = horizontal(&props) && matches!(tree.tag(id), Tag::Box);
344 let spacing = props.cells("spacing", 0);365 let spacing = props.cells("spacing", 0);
345 let children = tree.children(id);366 let children = tree.children(id);
346- let shares = share(tree, id, if across { area.w } else { area.h }, across);367+ let shares = share(
368+ tree,
369+ id,
370+ if across { area.w } else { area.h },
371+ across,
372+ if across { area.h } else { area.w },
373+ );
347 374
348 let mut out = Vec::with_capacity(children.len());375 let mut out = Vec::with_capacity(children.len());
349 let mut at = 0u16;376 let mut at = 0u16;
@@ -435,7 +462,7 @@ mod tests {
435 let a = label(&mut tree, row, "aa");462 let a = label(&mut tree, row, "aa");
436 let b = label(&mut tree, row, "bb");463 let b = label(&mut tree, row, "bb");
437 tree.set(b, "hexpand", Value::Bool(true));464 tree.set(b, "hexpand", Value::Bool(true));
438- assert_eq!(share(&tree, row, 20, true), vec![2, 18]);465+ assert_eq!(share(&tree, row, 20, true, 1), vec![2, 18]);
439 let _ = a;466 let _ = a;
440 }467 }
441 468
@@ -450,7 +477,7 @@ mod tests {
450 label(&mut tree, row, "three four");477 label(&mut tree, row, "three four");
451 // 17 natural, 10 offered: both give up some, neither goes under its478 // 17 natural, 10 offered: both give up some, neither goes under its
452 // longest word.479 // longest word.
453- let shares = share(&tree, row, 10, true);480+ let shares = share(&tree, row, 10, true, 1);
454 assert_eq!(shares.iter().sum::<u16>(), 10);481 assert_eq!(shares.iter().sum::<u16>(), 10);
455 assert!(shares[0] >= 3 && shares[1] >= 5, "{shares:?}");482 assert!(shares[0] >= 3 && shares[1] >= 5, "{shares:?}");
456 }483 }
@@ -467,7 +494,25 @@ mod tests {
467 let b = label(&mut tree, row, "bb");494 let b = label(&mut tree, row, "bb");
468 tree.set(a, "hexpand", Value::Bool(true));495 tree.set(a, "hexpand", Value::Bool(true));
469 tree.set(b, "hexpand", Value::Bool(true));496 tree.set(b, "hexpand", Value::Bool(true));
470- assert_eq!(share(&tree, row, 12, true), vec![5, 5]);497+ assert_eq!(share(&tree, row, 12, true, 1), vec![5, 5]);
498+ }
499+
500+ #[test]
501+ fn a_column_shares_its_rows_out_at_the_width_it_has() {
502+ // A column two rows tall and thirty columns wide holds two labels, and
503+ // each is one row at that width. Measured against the rows instead —
504+ // as this did — "the second line" wraps to five, the overrun comes off
505+ // the end, and the second child is handed nothing: a box that paints
506+ // its first child and drops the rest, which is what the chats list did
507+ // to every Open button in it.
508+ let mut tree = Tree::new();
509+ let col = tree.new_node("vbox");
510+ tree.set(col, "orientation", Value::Str("vertical".into()));
511+ let root = tree.root();
512+ tree.append(root, col);
513+ label(&mut tree, col, "the first line");
514+ label(&mut tree, col, "the second line");
515+ assert_eq!(share(&tree, col, 2, false, 30), vec![1, 1]);
471 }516 }
472 517
473 #[test]518 #[test]
modified crates/jolt-tui/src/paint.rs +16 -1
@@ -123,7 +123,22 @@ impl Painter<'_> {
123123 self.children(id, inner, style, enabled);
124124 }
125125 Tag::Scroll => self.scroll(id, inner, style, enabled),
126- Tag::Box | Tag::Window | Tag::Unknown(_) => self.children(id, inner, style, enabled),
126+ Tag::Box | Tag::Window => self.children(id, inner, style, enabled),
127+ // A tag this backend has not learned paints as a vertical box, so
128+ // whatever is under it still reaches the screen. When there is
129+ // nothing under it, its own text does instead: an unknown *leaf*
130+ // is a widget the caller has and this has not — frq's `:status`
131+ // badge, its `:link` — and painting the box and not the label is
132+ // the one outcome that loses the text altogether. A link vanishing
133+ // out of the middle of a message is not a missing widget; it is a
134+ // missing sentence.
135+ Tag::Unknown(_) => {
136+ if self.tree.child_count(id) == 0 {
137+ self.wrapped(inner, props.label(), style);
138+ } else {
139+ self.children(id, inner, style, enabled);
140+ }
141+ }
127142 Tag::Label => self.wrapped(inner, props.label(), style),
128143 Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)),
129144 Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)),
@@ -123,7 +123,22 @@ impl Painter<'_> {
123 self.children(id, inner, style, enabled);123 self.children(id, inner, style, enabled);
124 }124 }
125 Tag::Scroll => self.scroll(id, inner, style, enabled),125 Tag::Scroll => self.scroll(id, inner, style, enabled),
126- Tag::Box | Tag::Window | Tag::Unknown(_) => self.children(id, inner, style, enabled),126+ Tag::Box | Tag::Window => self.children(id, inner, style, enabled),
127+ // A tag this backend has not learned paints as a vertical box, so
128+ // whatever is under it still reaches the screen. When there is
129+ // nothing under it, its own text does instead: an unknown *leaf*
130+ // is a widget the caller has and this has not — frq's `:status`
131+ // badge, its `:link` — and painting the box and not the label is
132+ // the one outcome that loses the text altogether. A link vanishing
133+ // out of the middle of a message is not a missing widget; it is a
134+ // missing sentence.
135+ Tag::Unknown(_) => {
136+ if self.tree.child_count(id) == 0 {
137+ self.wrapped(inner, props.label(), style);
138+ } else {
139+ self.children(id, inner, style, enabled);
140+ }
141+ }
127 Tag::Label => self.wrapped(inner, props.label(), style),142 Tag::Label => self.wrapped(inner, props.label(), style),
128 Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)),143 Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)),
129 Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)),144 Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)),
modified crates/jolt-tui/src/tests.rs +31 -0
@@ -369,3 +369,34 @@ fn focus_survives_a_repaint_and_lands_somewhere_when_its_widget_is_unmounted() {
369369 ui.frame();
370370 assert_eq!(ui.focus(), first);
371371 }
372+
373+#[test]
374+fn an_unknown_leaf_paints_its_own_text() {
375+ // A tag this backend does not know paints as a box so its children still
376+ // show — but a leaf has none, and a `:status` badge or a `:link` in the
377+ // middle of a message would otherwise be a hole in the sentence.
378+ let mut ui = ui();
379+ let root = ui.tree.root();
380+ node(&mut ui, root, "status", &[("label", "joined")]);
381+ node(&mut ui, root, "label", &[("label", "after")]);
382+ ui.frame();
383+ assert_eq!(ui.screen.line(0), "joined");
384+ assert_eq!(ui.screen.line(1), "after");
385+}
386+
387+#[test]
388+fn a_nested_column_paints_every_child_and_not_only_the_first() {
389+ // Its rows were shared out by measuring each child at the *rows* it had
390+ // rather than the columns, so a label wrapped to a paragraph, the overrun
391+ // came off the end, and everything after the first child was handed
392+ // nothing. Two levels down is where it showed: the top box is as wide as
393+ // the screen and as tall, so the two numbers were close enough to hide it.
394+ let mut ui = ui();
395+ let root = ui.tree.root();
396+ let col = node(&mut ui, root, "vbox", &[("orientation", "vertical")]);
397+ node(&mut ui, col, "label", &[("label", "the first line")]);
398+ node(&mut ui, col, "button", &[("label", "Open")]);
399+ ui.frame();
400+ assert_eq!(ui.screen.line(0), "the first line");
401+ assert_eq!(ui.screen.line(1), "[ Open ]");
402+}
@@ -369,3 +369,34 @@ fn focus_survives_a_repaint_and_lands_somewhere_when_its_widget_is_unmounted() {
369 ui.frame();369 ui.frame();
370 assert_eq!(ui.focus(), first);370 assert_eq!(ui.focus(), first);
371 }371 }
372+
373+#[test]
374+fn an_unknown_leaf_paints_its_own_text() {
375+ // A tag this backend does not know paints as a box so its children still
376+ // show — but a leaf has none, and a `:status` badge or a `:link` in the
377+ // middle of a message would otherwise be a hole in the sentence.
378+ let mut ui = ui();
379+ let root = ui.tree.root();
380+ node(&mut ui, root, "status", &[("label", "joined")]);
381+ node(&mut ui, root, "label", &[("label", "after")]);
382+ ui.frame();
383+ assert_eq!(ui.screen.line(0), "joined");
384+ assert_eq!(ui.screen.line(1), "after");
385+}
386+
387+#[test]
388+fn a_nested_column_paints_every_child_and_not_only_the_first() {
389+ // Its rows were shared out by measuring each child at the *rows* it had
390+ // rather than the columns, so a label wrapped to a paragraph, the overrun
391+ // came off the end, and everything after the first child was handed
392+ // nothing. Two levels down is where it showed: the top box is as wide as
393+ // the screen and as tall, so the two numbers were close enough to hide it.
394+ let mut ui = ui();
395+ let root = ui.tree.root();
396+ let col = node(&mut ui, root, "vbox", &[("orientation", "vertical")]);
397+ node(&mut ui, col, "label", &[("label", "the first line")]);
398+ node(&mut ui, col, "button", &[("label", "Open")]);
399+ ui.frame();
400+ assert_eq!(ui.screen.line(0), "the first line");
401+ assert_eq!(ui.screen.line(1), "[ Open ]");
402+}