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 {...@@ -1754,6 +1754,7 @@ impl State {
1754 -self.view.viewport.origin[index],1754 -self.view.viewport.origin[index],
1755 [scroll.min[index], scroll.max[index]],1755 [scroll.min[index], scroll.max[index]],
1756 self.view.viewport.size[index] as f32,1756 self.view.viewport.size[index] as f32,
1757 scroll.max[1 - index] > scroll.min[1 - index],
1757 [0.18, 0.18, 0.18, 0.45],1758 [0.18, 0.18, 0.18, 0.45],
1758 ) {1759 ) {
1759 let response = self.view.scroll_to(index, offset)?;1760 let response = self.view.scroll_to(index, offset)?;
crates/ui/src/list.rs+1
...@@ -246,6 +246,7 @@ pub fn list<R: Rows>(...@@ -246,6 +246,7 @@ pub fn list<R: Rows>(
246 scroll as f32,246 scroll as f32,
247 [0.0, most as f32],247 [0.0, most as f32],
248 view as f32,248 view as f32,
249 false,
249 thumb,250 thumb,
250 ) {251 ) {
251 // Rows keep their places relative to the new offset, or next frame's hold on the252 // 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() {...@@ -862,13 +862,20 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() {
862 "view",862 "view",
863 Spec {863 Spec {
864 flags: Flags::CUSTOM,864 flags: Flags::CUSTOM,
865 size: [px(200.0), px(220.0)],865 size: [px(200.0), px(218.0)],
866 ..Spec::default()866 ..Spec::default()
867 },867 },
868 );868 );
869 if let Some(chosen) =869 if let Some(chosen) = scrollbar(
870 scrollbar(ui, "bar", Axis::Y, *offset, [0.0, 1000.0], 400.0, [0.5; 4])870 ui,
871 {871 "bar",
872 Axis::Y,
873 *offset,
874 [0.0, 1000.0],
875 400.0,
876 true,
877 [0.5; 4],
878 ) {
872 *offset = chosen;879 *offset = chosen;
873 }880 }
874 ui.close();881 ui.close();
...@@ -902,6 +909,36 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() {...@@ -902,6 +909,36 @@ fn scrollbar_thumbs_track_the_offset_and_drags_reach_both_ends() {
902 assert_eq!(offset, 0.0);909 assert_eq!(offset, 0.0);
903}910}
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
905#[test]942#[test]
906fn system_scrollers_step_page_and_drag_by_the_parts_the_platform_paints() {943fn system_scrollers_step_page_and_drag_by_the_parts_the_platform_paints() {
907 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);944 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() {...@@ -936,9 +973,16 @@ fn system_scrollers_step_page_and_drag_by_the_parts_the_platform_paints() {
936 ..Spec::default()973 ..Spec::default()
937 },974 },
938 );975 );
939 if let Some(chosen) =976 if let Some(chosen) = scrollbar(
940 scrollbar(ui, "bar", Axis::Y, *offset, [0.0, 1000.0], 400.0, [0.5; 4])977 ui,
941 {978 "bar",
979 Axis::Y,
980 *offset,
981 [0.0, 1000.0],
982 400.0,
983 true,
984 [0.5; 4],
985 ) {
942 *offset = chosen;986 *offset = chosen;
943 }987 }
944 ui.close();988 ui.close();
crates/ui/src/widgets.rs+11-8
...@@ -130,8 +130,10 @@ const REPEAT: Duration = Duration::from_millis(50);...@@ -130,8 +130,10 @@ const REPEAT: Duration = Duration::from_millis(50);
130130
131/// A scrollbar along the far edge of the current box, for content whose scroll offset131/// A scrollbar along the far edge of the current box, for content whose scroll offset
132/// ranges over `range` while `view` of it shows, all in one unit: the platform's scroller132/// 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. Returns133/// where `Ui::scrollers` has one, otherwise a thumb in `color` over the content. With a
134/// the offset the scrollbar chose.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)]
135pub fn scrollbar(137pub fn scrollbar(
136 ui: &mut Ui,138 ui: &mut Ui,
137 part: impl Hash,139 part: impl Hash,
...@@ -139,18 +141,19 @@ pub fn scrollbar(...@@ -139,18 +141,19 @@ pub fn scrollbar(
139 offset: f32,141 offset: f32,
140 range: [f32; 2],142 range: [f32; 2],
141 view: f32,143 view: f32,
144 corner: bool,
142 color: [f32; 4],145 color: [f32; 4],
143) -> Option<f32> {146) -> Option<f32> {
144 if ui.scrollers.is_some() {147 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);
146 }149 }
147 let along = usize::from(axis == Axis::Y);150 let along = usize::from(axis == Axis::Y);
148 let rect = ui.rect(ui.current())?;151 let rect = ui.rect(ui.current())?;
149 let length = rect[along + 2] - rect[along];152 let length = rect[along + 2] - rect[along];
150 let cross = rect[3 - along] - rect[1 - along];153 let cross = rect[3 - along] - rect[1 - along];
151 let span = range[1] - range[0];154 let span = range[1] - range[0];
152 // The far end leaves room for the other axis's bar.155 // A thumb keeps 4 from each end and the box's far edge, and is 6 across.
153 let track = length - 20.0;156 let track = length - 8.0 - if corner { 10.0 } else { 0.0 };
154 if span <= 0.0 || track <= 0.0 {157 if span <= 0.0 || track <= 0.0 {
155 return None;158 return None;
156 }159 }
...@@ -197,13 +200,13 @@ fn system_scrollbar(...@@ -197,13 +200,13 @@ fn system_scrollbar(
197 offset: f32,200 offset: f32,
198 range: [f32; 2],201 range: [f32; 2],
199 view: f32,202 view: f32,
203 corner: bool,
200) -> Option<f32> {204) -> Option<f32> {
201 let along = usize::from(axis == Axis::Y);205 let along = usize::from(axis == Axis::Y);
202 let rect = ui.rect(ui.current())?;206 let rect = ui.rect(ui.current())?;
203 let span = range[1] - range[0];207 let span = range[1] - range[0];
204 let thickness = ui.scrollers.as_ref()?.thickness;208 let thickness = ui.scrollers.as_ref()?.thickness;
205 // The far end leaves the corner beside the other axis's scroller.209 let length = rect[along + 2] - rect[along] - if corner { thickness } else { 0.0 };
206 let length = rect[along + 2] - rect[along] - thickness;
207 if span <= 0.0 || length <= 0.0 {210 if span <= 0.0 || length <= 0.0 {
208 return None;211 return None;
209 }212 }
...@@ -283,7 +286,7 @@ fn system_scrollbar(...@@ -283,7 +286,7 @@ fn system_scrollbar(
283 ..Spec::default()286 ..Spec::default()
284 },287 },
285 );288 );
286 if axis == Axis::Y {289 if axis == Axis::Y && corner {
287 // The corner between the two scrollers shows the content's white, as AppKit's does.290 // The corner between the two scrollers shows the content's white, as AppKit's does.
288 ui.leaf(291 ui.leaf(
289 ("corner", &part),292 ("corner", &part),