authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 13:14:26-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 13:35:43-07:00
log30964a9cc80080214547f04dcb744f23406dffd6
treecc008f0bba52e72455a7fea3e512da1d094f35a3
parentda750b15d3ea1dddf92d129bfd17e7c4c9f1712d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: a new section's rename field waits for its tab to be laid out

Ui::rect answered a zeroed rectangle for a box built this frame for the first time, so for one frame the rename field over a just-created section tab was laid out -6 px tall, above the window. It now answers none until the box is laid out, and the field shows from the next frame. Assisted-by: claude-opus-5.5

2 files changed, 31 insertions(+), 6 deletions(-)

crates/ui/src/lib.rs+8-6
...@@ -557,7 +557,8 @@ struct State {...@@ -557,7 +557,8 @@ struct State {
557 scroll_target: f32,557 scroll_target: f32,
558 /// Height of a scrolling box's children last frame.558 /// Height of a scrolling box's children last frame.
559 content: f32,559 content: f32,
560 rect: [f32; 4],560 /// As last laid out; none for a box built this frame for the first time.
561 rect: Option<[f32; 4]>,
561 /// A text field's selection, and the selection and unit of the press a drag extends.562 /// A text field's selection, and the selection and unit of the press a drag extends.
562 selection: Selection,563 selection: Selection,
563 press: (Selection, SelectionUnit),564 press: (Selection, SelectionUnit),
...@@ -860,9 +861,9 @@ impl Ui {...@@ -860,9 +861,9 @@ impl Ui {
860 Some(layout::narrowest(&self.nodes, index))861 Some(layout::narrowest(&self.nodes, index))
861 }862 }
862863
863 /// The box's rectangle from the latest layout.864 /// The box's rectangle from the latest layout; none for one not laid out yet.
864 pub fn rect(&self, id: Id) -> Option<[f32; 4]> {865 pub fn rect(&self, id: Id) -> Option<[f32; 4]> {
865 self.states.get(&id).map(|state| state.rect)866 self.states.get(&id).and_then(|state| state.rect)
866 }867 }
867868
868 /// Scrolls `scroller`, a scrolling box, until `target` within it starts at its top, both869 /// Scrolls `scroller`, a scrolling box, until `target` within it starts at its top, both
...@@ -872,7 +873,7 @@ impl Ui {...@@ -872,7 +873,7 @@ impl Ui {
872 return;873 return;
873 };874 };
874 let state = self.states.entry(scroller).or_default();875 let state = self.states.entry(scroller).or_default();
875 let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0);876 let most = (state.content - (start[3] - start[1])).max(0.0);
876 state.scroll_target = (state.scroll + top[1] - start[1]).clamp(0.0, most);877 state.scroll_target = (state.scroll + top[1] - start[1]).clamp(0.0, most);
877 self.animating |= state.scroll != state.scroll_target;878 self.animating |= state.scroll != state.scroll_target;
878 }879 }
...@@ -1035,7 +1036,8 @@ impl Ui {...@@ -1035,7 +1036,8 @@ impl Ui {
1035 }1036 }
1036 Some(id) => {1037 Some(id) => {
1037 let state = self.states.entry(id).or_default();1038 let state = self.states.entry(id).or_default();
1038 let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0);1039 let shown = state.rect.map_or(0.0, |rect| rect[3] - rect[1]);
1040 let most = (state.content - shown).max(0.0);
1039 // The wheel moves the content at once, as on the page; only jumps ease.1041 // The wheel moves the content at once, as on the page; only jumps ease.
1040 state.scroll = (state.scroll - delta[1]).clamp(0.0, most);1042 state.scroll = (state.scroll - delta[1]).clamp(0.0, most);
1041 state.scroll_target = state.scroll;1043 state.scroll_target = state.scroll;
...@@ -1363,7 +1365,7 @@ impl Ui {...@@ -1363,7 +1365,7 @@ impl Ui {
1363 );1365 );
1364 for node in &self.nodes {1366 for node in &self.nodes {
1365 let state = self.states.entry(node.id).or_default();1367 let state = self.states.entry(node.id).or_default();
1366 state.rect = node.rect;1368 state.rect = Some(node.rect);
1367 state.hovers = node.hover_fill.is_some() || node.hover_border.is_some();1369 state.hovers = node.hover_fill.is_some() || node.hover_border.is_some();
1368 if node.flags.contains(Flags::SCROLL) {1370 if node.flags.contains(Flags::SCROLL) {
1369 state.content = node.content;1371 state.content = node.content;
crates/ui/src/tests.rs+23
...@@ -3154,3 +3154,26 @@ fn a_box_squeezed_past_its_inset_paints_empty_not_inverted() {...@@ -3154,3 +3154,26 @@ fn a_box_squeezed_past_its_inset_paints_empty_not_inverted() {
3154 [left, top, right, bottom]3154 [left, top, right, bottom]
3155 );3155 );
3156}3156}
3157
3158#[test]
3159fn a_box_has_no_rectangle_until_it_is_first_laid_out() {
3160 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
3161 let build = |ui: &mut Ui| {
3162 ui.leaf(
3163 "new",
3164 Spec {
3165 size: [px(40.0), px(20.0)],
3166 ..Spec::default()
3167 },
3168 );
3169 ui.rect(Id::ROOT.child("new"))
3170 };
3171 let mut seen = Vec::new();
3172 frame(&mut ui, |ui| seen.push(build(ui)));
3173 frame(&mut ui, |ui| seen.push(build(ui)));
3174 assert_eq!(
3175 seen[0], None,
3176 "a box placed against nothing would sit at the origin"
3177 );
3178 assert_eq!(seen[1], Some([0.0, 0.0, 40.0, 20.0]));
3179}