authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 02:18:09-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 03:11:19-07:00
log9aaaf21dee13990267048e726313ee04c7861882
tree2f97a91df00fb77a3324bb350e7ca7d19a4f8e62
parentab00bdcf0c7a9eddb387eab4ad09df0b862c7b32
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: a scrollbar thumb reaches the end of its track

The drawn thumb always stopped 16 points short of its far end, leaving room for the other axis's bar even in a menu or list, which has none, so at the bottom of a long menu it still looked scrollable. The corner is now left only where both axes scroll, as on the page, and the thumb keeps the same 4 points from either end; the platform's scrollers on Mac OS X 10.6 run full length the same way. Assisted-by: claude-opus-5.5

4 files changed, 64 insertions(+), 15 deletions(-)

crates/snowbound/src/main.rs+1
......@@ -1754,6 +1754,7 @@ impl State {
17541754 -self.view.viewport.origin[index],
17551755 [scroll.min[index], scroll.max[index]],
17561756 self.view.viewport.size[index] as f32,
1757 scroll.max[1 - index] > scroll.min[1 - index],
17571758 [0.18, 0.18, 0.18, 0.45],
17581759 ) {
17591760 let response = self.view.scroll_to(index, offset)?;
crates/ui/src/list.rs+1
......@@ -246,6 +246,7 @@ pub fn list<R: Rows>(
246246 scroll as f32,
247247 [0.0, most as f32],
248248 view as f32,
249 false,
249250 thumb,
250251 ) {
251252 // Rows keep their places relative to the new offset, or next frame's hold on the
crates/ui/src/tests.rs+51-7
......@@ -862,13 +862,20 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() {
862862 "view",
863863 Spec {
864864 flags: Flags::CUSTOM,
865 size: [px(200.0), px(220.0)],
865 size: [px(200.0), px(218.0)],
866866 ..Spec::default()
867867 },
868868 );
869 if let Some(chosen) =
870 scrollbar(ui, "bar", Axis::Y, *offset, [0.0, 1000.0], 400.0, [0.5; 4])
871 {
869 if let Some(chosen) = scrollbar(
870 ui,
871 "bar",
872 Axis::Y,
873 *offset,
874 [0.0, 1000.0],
875 400.0,
876 true,
877 [0.5; 4],
878 ) {
872879 *offset = chosen;
873880 }
874881 ui.close();
......@@ -902,6 +909,36 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() {
902909 assert_eq!(offset, 0.0);
903910}
904911
912#[test]
913fn a_scrollbar_without_a_corner_ends_as_far_from_its_end_as_from_its_start() {
914 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
915 for _ in 0..2 {
916 frame(&mut ui, |ui| {
917 ui.open(
918 "view",
919 Spec {
920 flags: Flags::CUSTOM,
921 size: [px(200.0), px(208.0)],
922 ..Spec::default()
923 },
924 );
925 scrollbar(
926 ui,
927 "bar",
928 Axis::Y,
929 1000.0,
930 [0.0, 1000.0],
931 400.0,
932 false,
933 [0.5; 4],
934 );
935 ui.close();
936 });
937 }
938 let rect = ui.rect(Id::ROOT.child("view").child("bar")).unwrap();
939 assert_eq!(rect[3], 204.0);
940}
941
905942#[test]
906943fn system_scrollers_step_page_and_drag_by_the_parts_the_platform_paints() {
907944 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
......@@ -936,9 +973,16 @@ fn system_scrollers_step_page_and_drag_by_the_parts_the_platform_paints() {
936973 ..Spec::default()
937974 },
938975 );
939 if let Some(chosen) =
940 scrollbar(ui, "bar", Axis::Y, *offset, [0.0, 1000.0], 400.0, [0.5; 4])
941 {
976 if let Some(chosen) = scrollbar(
977 ui,
978 "bar",
979 Axis::Y,
980 *offset,
981 [0.0, 1000.0],
982 400.0,
983 true,
984 [0.5; 4],
985 ) {
942986 *offset = chosen;
943987 }
944988 ui.close();
crates/ui/src/widgets.rs+11-8
......@@ -130,8 +130,10 @@ const REPEAT: Duration = Duration::from_millis(50);
130130
131131/// A scrollbar along the far edge of the current box, for content whose scroll offset
132132/// ranges over `range` while `view` of it shows, all in one unit: the platform's scroller
133/// where `Ui::scrollers` has one, otherwise a thumb in `color` over the content. Returns
134/// the offset the scrollbar chose.
133/// where `Ui::scrollers` has one, otherwise a thumb in `color` over the content. With a
134/// `corner`, its far end leaves room for the other axis's scrollbar. Returns the offset
135/// the scrollbar chose.
136#[allow(clippy::too_many_arguments)]
135137pub fn scrollbar(
136138 ui: &mut Ui,
137139 part: impl Hash,
......@@ -139,18 +141,19 @@ pub fn scrollbar(
139141 offset: f32,
140142 range: [f32; 2],
141143 view: f32,
144 corner: bool,
142145 color: [f32; 4],
143146) -> Option<f32> {
144147 if ui.scrollers.is_some() {
145 return system_scrollbar(ui, part, axis, offset, range, view);
148 return system_scrollbar(ui, part, axis, offset, range, view, corner);
146149 }
147150 let along = usize::from(axis == Axis::Y);
148151 let rect = ui.rect(ui.current())?;
149152 let length = rect[along + 2] - rect[along];
150153 let cross = rect[3 - along] - rect[1 - along];
151154 let span = range[1] - range[0];
152 // The far end leaves room for the other axis's bar.
153 let track = length - 20.0;
155 // A thumb keeps 4 from each end and the box's far edge, and is 6 across.
156 let track = length - 8.0 - if corner { 10.0 } else { 0.0 };
154157 if span <= 0.0 || track <= 0.0 {
155158 return None;
156159 }
......@@ -197,13 +200,13 @@ fn system_scrollbar(
197200 offset: f32,
198201 range: [f32; 2],
199202 view: f32,
203 corner: bool,
200204) -> Option<f32> {
201205 let along = usize::from(axis == Axis::Y);
202206 let rect = ui.rect(ui.current())?;
203207 let span = range[1] - range[0];
204208 let thickness = ui.scrollers.as_ref()?.thickness;
205 // The far end leaves the corner beside the other axis's scroller.
206 let length = rect[along + 2] - rect[along] - thickness;
209 let length = rect[along + 2] - rect[along] - if corner { thickness } else { 0.0 };
207210 if span <= 0.0 || length <= 0.0 {
208211 return None;
209212 }
......@@ -283,7 +286,7 @@ fn system_scrollbar(
283286 ..Spec::default()
284287 },
285288 );
286 if axis == Axis::Y {
289 if axis == Axis::Y && corner {
287290 // The corner between the two scrollers shows the content's white, as AppKit's does.
288291 ui.leaf(
289292 ("corner", &part),