From 71e57fe889df7d4881d48a7a0dee13765c4f224e Mon Sep 17 00:00:00 2001 From: nz366 <180265222+nz366@users.noreply.github.com> Date: Fri, 30 May 2025 17:03:58 +0000 Subject: [PATCH 1/5] Add auto-scrolling support for `scrollable` widget --- widget/src/scrollable.rs | 302 ++++++++++++++++++++++++++++----------- 1 file changed, 216 insertions(+), 86 deletions(-) diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index 2cc1d9e4..913e5f3b 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -567,6 +567,9 @@ where shell: &mut Shell<'_, Message>, _viewport: &Rectangle, ) { + const AUTOSCROLL_DEADZONE: f32 = 20.0; + const AUTOSCROLL_SPEED: f32 = 10.0; + let state = tree.state.downcast_mut::(); let bounds = layout.bounds(); let cursor_over_scrollable = cursor.position_over(bounds); @@ -601,7 +604,7 @@ where } let mut update = || { - if let Some(scroller_grabbed_at) = state.y_scroller_grabbed_at { + if let Some(scroller_grabbed_at) = state.y_scroller_grabbed_at() { match event { Event::Mouse(mouse::Event::CursorMoved { .. }) | Event::Touch(touch::Event::FingerMoved { .. }) => { @@ -657,8 +660,9 @@ where content_bounds, ); - state.y_scroller_grabbed_at = - Some(scroller_grabbed_at); + state.interaction = Interaction::YScrollerGrabbed( + scroller_grabbed_at, + ); let _ = notify_scroll( state, @@ -675,7 +679,7 @@ where } } - if let Some(scroller_grabbed_at) = state.x_scroller_grabbed_at { + if let Some(scroller_grabbed_at) = state.x_scroller_grabbed_at() { match event { Event::Mouse(mouse::Event::CursorMoved { .. }) | Event::Touch(touch::Event::FingerMoved { .. }) => { @@ -730,8 +734,9 @@ where content_bounds, ); - state.x_scroller_grabbed_at = - Some(scroller_grabbed_at); + state.interaction = Interaction::XScrollerGrabbed( + scroller_grabbed_at, + ); let _ = notify_scroll( state, @@ -800,10 +805,19 @@ where | touch::Event::FingerLost { .. } ) ) { - state.scroll_area_touched_at = None; - state.x_scroller_grabbed_at = None; - state.y_scroller_grabbed_at = None; + state.interaction = Interaction::None; + return; + } + if matches!(state.interaction, Interaction::AutoScrolling { .. }) + && matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(_)) + | Event::Touch(_) + | Event::Keyboard(_) + ) + { + state.interaction = Interaction::None; return; } @@ -811,15 +825,6 @@ where return; } - if let Event::Keyboard(keyboard::Event::ModifiersChanged( - modifiers, - )) = event - { - state.keyboard_modifiers = *modifiers; - - return; - } - match event { Event::Mouse(mouse::Event::WheelScrolled { delta }) => { if cursor_over_scrollable.is_none() { @@ -874,58 +879,172 @@ where shell.capture_event(); } } + Event::Mouse(mouse::Event::ButtonPressed( + mouse::Button::Middle, + )) if matches!(state.interaction, Interaction::None) => { + let Some(origin) = cursor_over_scrollable else { + return; + }; + + state.interaction = Interaction::AutoScrolling { + origin, + current: origin, + last_frame: None, + }; + + shell.capture_event(); + } Event::Touch(event) - if state.scroll_area_touched_at.is_some() - || (!mouse_over_y_scrollbar - && !mouse_over_x_scrollbar) => + if matches!( + state.interaction, + Interaction::TouchScrolling(_) + ) || (!mouse_over_y_scrollbar + && !mouse_over_x_scrollbar) => { match event { touch::Event::FingerPressed { .. } => { - if cursor_over_scrollable.is_none() { + let Some(position) = cursor_over_scrollable else { return; - } + }; - state.scroll_area_touched_at = cursor.position(); + state.interaction = + Interaction::TouchScrolling(position); } touch::Event::FingerMoved { .. } => { - if let Some(scroll_box_touched_at) = - state.scroll_area_touched_at - { - let Some(cursor_position) = cursor.position() - else { - return; - }; + let Interaction::TouchScrolling( + scroll_box_touched_at, + ) = state.interaction + else { + return; + }; - let delta = Vector::new( - scroll_box_touched_at.x - cursor_position.x, - scroll_box_touched_at.y - cursor_position.y, - ); + let Some(cursor_position) = cursor.position() + else { + return; + }; - state.scroll( - self.direction.align(delta), - bounds, - content_bounds, - ); + let delta = Vector::new( + scroll_box_touched_at.x - cursor_position.x, + scroll_box_touched_at.y - cursor_position.y, + ); - state.scroll_area_touched_at = - Some(cursor_position); + state.scroll( + self.direction.align(delta), + bounds, + content_bounds, + ); - // TODO: bubble up touch movements if not consumed. - let _ = notify_scroll( - state, - &self.on_scroll, - bounds, - content_bounds, - shell, - ); - } + state.interaction = + Interaction::TouchScrolling(cursor_position); + + // TODO: bubble up touch movements if not consumed. + let _ = notify_scroll( + state, + &self.on_scroll, + bounds, + content_bounds, + shell, + ); } _ => {} } shell.capture_event(); } - Event::Window(window::Event::RedrawRequested(_)) => { + Event::Mouse(mouse::Event::CursorMoved { position }) => { + if let Interaction::AutoScrolling { + origin, + last_frame, + .. + } = state.interaction + { + let delta = *position - origin; + + if delta.x.abs() >= AUTOSCROLL_DEADZONE + || delta.y.abs() >= AUTOSCROLL_DEADZONE + { + state.interaction = Interaction::AutoScrolling { + origin, + current: *position, + last_frame, + }; + + if last_frame.is_none() { + shell.request_redraw(); + } + } + } + } + Event::Keyboard(keyboard::Event::ModifiersChanged( + modifiers, + )) => { + state.keyboard_modifiers = *modifiers; + } + Event::Window(window::Event::RedrawRequested(now)) => { + if let Interaction::AutoScrolling { + origin, + current, + last_frame, + } = state.interaction + { + if last_frame == Some(*now) { + shell.request_redraw(); + return; + } + + state.interaction = Interaction::AutoScrolling { + origin, + current, + last_frame: None, + }; + + let delta = current - origin; + + if delta.x.abs() >= AUTOSCROLL_DEADZONE + || delta.y.abs() >= AUTOSCROLL_DEADZONE + { + let time_delta = + if let Some(last_frame) = last_frame { + *now - last_frame + } else { + Duration::ZERO + }; + + let scroll_factor = + time_delta.as_secs_f32() * AUTOSCROLL_SPEED; + + state.scroll( + self.direction.align(Vector::new( + delta.x * scroll_factor, + delta.y * scroll_factor, + )), + bounds, + content_bounds, + ); + + let has_scrolled = notify_scroll( + state, + &self.on_scroll, + bounds, + content_bounds, + shell, + ); + + if has_scrolled || time_delta.is_zero() { + state.interaction = + Interaction::AutoScrolling { + origin, + current, + last_frame: Some(*now), + }; + + shell.request_redraw(); + } + + return; + } + } + let _ = notify_viewport( state, &self.on_scroll, @@ -940,15 +1059,13 @@ where update(); - let status = if state.y_scroller_grabbed_at.is_some() - || state.x_scroller_grabbed_at.is_some() - { + let status = if state.scrollers_grabbed() { Status::Dragged { is_horizontal_scrollbar_dragged: state - .x_scroller_grabbed_at + .x_scroller_grabbed_at() .is_some(), is_vertical_scrollbar_dragged: state - .y_scroller_grabbed_at + .y_scroller_grabbed_at() .is_some(), is_horizontal_scrollbar_disabled: scrollbars.is_x_disabled(), is_vertical_scrollbar_disabled: scrollbars.is_y_disabled(), @@ -1318,25 +1435,34 @@ fn notify_viewport( #[derive(Debug, Clone, Copy)] struct State { - scroll_area_touched_at: Option, offset_y: Offset, - y_scroller_grabbed_at: Option, offset_x: Offset, - x_scroller_grabbed_at: Option, + interaction: Interaction, keyboard_modifiers: keyboard::Modifiers, last_notified: Option, last_scrolled: Option, is_scrollbar_visible: bool, } +#[derive(Debug, Clone, Copy)] +enum Interaction { + None, + YScrollerGrabbed(f32), + XScrollerGrabbed(f32), + TouchScrolling(Point), + AutoScrolling { + origin: Point, + current: Point, + last_frame: Option, + }, +} + impl Default for State { fn default() -> Self { Self { - scroll_area_touched_at: None, offset_y: Offset::Absolute(0.0), - y_scroller_grabbed_at: None, offset_x: Offset::Absolute(0.0), - x_scroller_grabbed_at: None, + interaction: Interaction::None, keyboard_modifiers: keyboard::Modifiers::default(), last_notified: None, last_scrolled: None, @@ -1455,14 +1581,11 @@ impl Viewport { } impl State { - /// Creates a new [`State`] with the scrollbar(s) at the beginning. - pub fn new() -> Self { + fn new() -> Self { State::default() } - /// Apply a scrolling offset to the current [`State`], given the bounds of - /// the [`Scrollable`] and its contents. - pub fn scroll( + fn scroll( &mut self, delta: Vector, bounds: Rectangle, @@ -1485,11 +1608,7 @@ impl State { } } - /// Scrolls the [`Scrollable`] to a relative amount along the y axis. - /// - /// `0` represents scrollbar at the beginning, while `1` represents scrollbar at - /// the end. - pub fn scroll_y_to( + fn scroll_y_to( &mut self, percentage: f32, bounds: Rectangle, @@ -1499,11 +1618,7 @@ impl State { self.unsnap(bounds, content_bounds); } - /// Scrolls the [`Scrollable`] to a relative amount along the x axis. - /// - /// `0` represents scrollbar at the beginning, while `1` represents scrollbar at - /// the end. - pub fn scroll_x_to( + fn scroll_x_to( &mut self, percentage: f32, bounds: Rectangle, @@ -1513,14 +1628,12 @@ impl State { self.unsnap(bounds, content_bounds); } - /// Snaps the scroll position to a [`RelativeOffset`]. - pub fn snap_to(&mut self, offset: RelativeOffset) { + fn snap_to(&mut self, offset: RelativeOffset) { self.offset_x = Offset::Relative(offset.x.clamp(0.0, 1.0)); self.offset_y = Offset::Relative(offset.y.clamp(0.0, 1.0)); } - /// Scroll to the provided [`AbsoluteOffset`]. - pub fn scroll_to(&mut self, offset: AbsoluteOffset) { + fn scroll_to(&mut self, offset: AbsoluteOffset) { self.offset_x = Offset::Absolute(offset.x.max(0.0)); self.offset_y = Offset::Absolute(offset.y.max(0.0)); } @@ -1580,10 +1693,27 @@ impl State { ) } - /// Returns whether any scroller is currently grabbed or not. - pub fn scrollers_grabbed(&self) -> bool { - self.x_scroller_grabbed_at.is_some() - || self.y_scroller_grabbed_at.is_some() + fn scrollers_grabbed(&self) -> bool { + matches!( + self.interaction, + Interaction::YScrollerGrabbed(_) | Interaction::XScrollerGrabbed(_), + ) + } + + pub fn y_scroller_grabbed_at(&self) -> Option { + let Interaction::YScrollerGrabbed(at) = self.interaction else { + return None; + }; + + Some(at) + } + + pub fn x_scroller_grabbed_at(&self) -> Option { + let Interaction::XScrollerGrabbed(at) = self.interaction else { + return None; + }; + + Some(at) } } From 519e7ae414917562ccee692604f6a25a765629fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 28 Nov 2025 06:08:20 +0100 Subject: [PATCH 2/5] Add `auto_scroll` method to `scrollable` widget --- examples/scrollable/src/main.rs | 9 ++++++--- examples/styling/src/main.rs | 3 ++- examples/tour/src/main.rs | 5 +++-- widget/src/scrollable.rs | 21 ++++++++++++++++++--- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/examples/scrollable/src/main.rs b/examples/scrollable/src/main.rs index 17b1fad2..0b183024 100644 --- a/examples/scrollable/src/main.rs +++ b/examples/scrollable/src/main.rs @@ -210,7 +210,8 @@ impl ScrollableDemo { .width(Fill) .height(Fill) .id(SCROLLABLE) - .on_scroll(Message::Scrolled), + .on_scroll(Message::Scrolled) + .auto_scroll(true), Direction::Horizontal => scrollable( row![ scroll_to_end_button(), @@ -236,7 +237,8 @@ impl ScrollableDemo { .width(Fill) .height(Fill) .id(SCROLLABLE) - .on_scroll(Message::Scrolled), + .on_scroll(Message::Scrolled) + .auto_scroll(true), Direction::Multi => scrollable( //horizontal content row![ @@ -283,7 +285,8 @@ impl ScrollableDemo { .width(Fill) .height(Fill) .id(SCROLLABLE) - .on_scroll(Message::Scrolled), + .on_scroll(Message::Scrolled) + .auto_scroll(true), }); let progress_bars: Element = match self.scrollable_direction { diff --git a/examples/styling/src/main.rs b/examples/styling/src/main.rs index 6ce72ad7..762657f9 100644 --- a/examples/styling/src/main.rs +++ b/examples/styling/src/main.rs @@ -131,7 +131,8 @@ impl Styling { "You did it!" ]) .width(Fill) - .height(Fill); + .height(Fill) + .auto_scroll(true); let check = checkbox(self.checkbox_value) .label("Check me!") diff --git a/examples/tour/src/main.rs b/examples/tour/src/main.rs index 9c229ac3..e611ece0 100644 --- a/examples/tour/src/main.rs +++ b/examples/tour/src/main.rs @@ -174,7 +174,8 @@ impl Tour { } else { content })) - .spacing(10); + .spacing(10) + .auto_scroll(true); center_y(scrollable).padding(10).into() } @@ -626,7 +627,7 @@ pub enum Layout { impl Default for Tour { fn default() -> Self { Self { - screen: Screen::Welcome, + screen: Screen::Scrollable, slider: 50, layout: Layout::Row, spacing: 20, diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index 913e5f3b..62871600 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -75,6 +75,7 @@ pub struct Scrollable< width: Length, height: Length, direction: Direction, + auto_scroll: bool, content: Element<'a, Message, Theme, Renderer>, on_scroll: Option Message + 'a>>, class: Theme::Class<'a>, @@ -103,6 +104,7 @@ where width: Length::Shrink, height: Length::Shrink, direction: direction.into(), + auto_scroll: false, content: content.into(), on_scroll: None, class: Theme::default(), @@ -225,6 +227,15 @@ where self } + /// Sets whether the user should be allowed to auto-scroll the [`Scrollable`] + /// with the middle mouse button. + /// + /// By default, it is disabled. + pub fn auto_scroll(mut self, auto_scroll: bool) -> Self { + self.auto_scroll = auto_scroll; + self + } + /// Sets the style of this [`Scrollable`]. #[must_use] pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self @@ -812,8 +823,10 @@ where if matches!(state.interaction, Interaction::AutoScrolling { .. }) && matches!( event, - Event::Mouse(mouse::Event::ButtonPressed(_)) - | Event::Touch(_) + Event::Mouse( + mouse::Event::ButtonPressed(_) + | mouse::Event::WheelScrolled { .. } + ) | Event::Touch(_) | Event::Keyboard(_) ) { @@ -881,7 +894,9 @@ where } Event::Mouse(mouse::Event::ButtonPressed( mouse::Button::Middle, - )) if matches!(state.interaction, Interaction::None) => { + )) if self.auto_scroll + && matches!(state.interaction, Interaction::None) => + { let Some(origin) = cursor_over_scrollable else { return; }; From eadd7b8e8193d19ae394a73dd7b84452d5311761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 28 Nov 2025 06:29:25 +0100 Subject: [PATCH 3/5] Smooth out auto-scrolling speed scaling in `scrollable` --- examples/gallery/src/main.rs | 4 ++- widget/src/scrollable.rs | 48 ++++++++++++++++++++++-------------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index f6ed0077..198cdc27 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -275,7 +275,9 @@ impl Gallery { .height(grid::aspect_ratio(Preview::WIDTH, Preview::HEIGHT)) .spacing(10); - let content = container(scrollable(gallery).spacing(10)).padding(10); + let content = + container(scrollable(gallery).spacing(10).auto_scroll(true)) + .padding(10); let viewer = self.viewer.view(self.now); stack![content, viewer].into() diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index 62871600..2610cb45 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -579,7 +579,7 @@ where _viewport: &Rectangle, ) { const AUTOSCROLL_DEADZONE: f32 = 20.0; - const AUTOSCROLL_SPEED: f32 = 10.0; + const AUTOSCROLL_SMOOTHNESS: f32 = 1.5; let state = tree.state.downcast_mut::(); let bounds = layout.bounds(); @@ -764,6 +764,21 @@ where } } + if matches!(state.interaction, Interaction::AutoScrolling { .. }) + && matches!( + event, + Event::Mouse( + mouse::Event::ButtonPressed(_) + | mouse::Event::WheelScrolled { .. } + ) | Event::Touch(_) + | Event::Keyboard(_) + ) + { + state.interaction = Interaction::None; + shell.capture_event(); + return; + } + if state.last_scrolled.is_none() || !matches!( event, @@ -820,20 +835,6 @@ where return; } - if matches!(state.interaction, Interaction::AutoScrolling { .. }) - && matches!( - event, - Event::Mouse( - mouse::Event::ButtonPressed(_) - | mouse::Event::WheelScrolled { .. } - ) | Event::Touch(_) - | Event::Keyboard(_) - ) - { - state.interaction = Interaction::None; - return; - } - if shell.is_event_captured() { return; } @@ -1025,13 +1026,22 @@ where Duration::ZERO }; - let scroll_factor = - time_delta.as_secs_f32() * AUTOSCROLL_SPEED; + let scroll_factor = time_delta.as_secs_f32(); state.scroll( self.direction.align(Vector::new( - delta.x * scroll_factor, - delta.y * scroll_factor, + delta.x.signum() + * delta + .x + .abs() + .powf(AUTOSCROLL_SMOOTHNESS) + * scroll_factor, + delta.y.signum() + * delta + .y + .abs() + .powf(AUTOSCROLL_SMOOTHNESS) + * scroll_factor, )), bounds, content_bounds, From 99748b89dea3dc24db24a73d79d5fa3e410b73a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 28 Nov 2025 08:28:03 +0100 Subject: [PATCH 4/5] Implement `AutoScrollIcon` overlay for `scrollable` --- core/src/renderer/null.rs | 4 + core/src/text.rs | 20 +++ graphics/fonts/Iced-Icons.ttf | Bin 5292 -> 5700 bytes renderer/src/fallback.rs | 4 + tiny_skia/src/lib.rs | 4 + wgpu/src/lib.rs | 4 + widget/src/helpers.rs | 2 +- widget/src/overlay/menu.rs | 2 +- widget/src/scrollable.rs | 227 ++++++++++++++++++++++++++++++---- 9 files changed, 238 insertions(+), 29 deletions(-) diff --git a/core/src/renderer/null.rs b/core/src/renderer/null.rs index 8c4b328a..45d10d72 100644 --- a/core/src/renderer/null.rs +++ b/core/src/renderer/null.rs @@ -45,6 +45,10 @@ impl text::Renderer for () { const ICON_FONT: Font = Font::DEFAULT; const CHECKMARK_ICON: char = '0'; const ARROW_DOWN_ICON: char = '0'; + const SCROLL_UP_ICON: char = '0'; + const SCROLL_DOWN_ICON: char = '0'; + const SCROLL_LEFT_ICON: char = '0'; + const SCROLL_RIGHT_ICON: char = '0'; const ICED_LOGO: char = '0'; fn default_font(&self) -> Self::Font { diff --git a/core/src/text.rs b/core/src/text.rs index eddc0532..dfe7cfdb 100644 --- a/core/src/text.rs +++ b/core/src/text.rs @@ -312,6 +312,26 @@ pub trait Renderer: crate::Renderer { /// [`ICON_FONT`]: Self::ICON_FONT const ARROW_DOWN_ICON: char; + /// The `char` representing a ^ icon in the built-in [`ICON_FONT`]. + /// + /// [`ICON_FONT`]: Self::ICON_FONT + const SCROLL_UP_ICON: char; + + /// The `char` representing a v icon in the built-in [`ICON_FONT`]. + /// + /// [`ICON_FONT`]: Self::ICON_FONT + const SCROLL_DOWN_ICON: char; + + /// The `char` representing a < icon in the built-in [`ICON_FONT`]. + /// + /// [`ICON_FONT`]: Self::ICON_FONT + const SCROLL_LEFT_ICON: char; + + /// The `char` representing a > icon in the built-in [`ICON_FONT`]. + /// + /// [`ICON_FONT`]: Self::ICON_FONT + const SCROLL_RIGHT_ICON: char; + /// The 'char' representing the iced logo in the built-in ['ICON_FONT']. /// /// ['ICON_FONT']: Self::ICON_FONT diff --git a/graphics/fonts/Iced-Icons.ttf b/graphics/fonts/Iced-Icons.ttf index 494483b333c384a36b0b9506d196b0df412e851a..f6b5a4edf585e8118f8fd28644719be0beacb1a7 100644 GIT binary patch delta 895 zcmXw%Ur5tY6vxlGzx}<}n#fuNKXpjyR*JFAETlg)*OO&rqjD>MZgX3?`9qf)2!jHN z9?BR(J!BF6f$|{`?4<`Edh)54P@)GRL+UGk%eoTjfH{q_TZ zxk!s9#tMGN=Q5EzmniNZ)YG|p52r5!DE$CJPivz(>D2(cev@^jhi(-I$DjB0P{K4p z<}xWQ+3+WLhhSB7p*=%_lPhuZ!{qPHFfm8{7Kfb!~G1F zU<>OoTFQHV7DHfH7FYzl0Q;Pdh9);U8d|Zj?FhDqcB9X$yp4%EZmbw9LKSf#aF#4> zH)5P)&{*bN#&MQ&V?d~~AVR3TPRyWWN?a4+PDq@awtACk3~+)D2vLmF0RtEiYE?|& z10z8to)9r6gt0=hh?!J`XnB!6Qo<&b`Me$rEc=v9cHdaWAm?#vPlBnVIv(fj4Yj^( z9yvahwPuB&nw3Vo7g@oARt9%h+LhGnaw>D~0KI_Bl(S;i(6&oqP_`~F$cyt=s@Ij% z+CDY9UbUImn%6uo^MHJM{L$dwFfh2eh4?OEkhf%D5SaiJm2r1*bz{(C&<64|fP6LoV0|My zg@7*%42&H>epqsDV!;F5$H#&EA3*GvTvnpMcJ)8dBz^~=yh2(*dT!Um36Ts8%pMF3 z+y&{0#RWid0ibyaK$;^xr?Nin?)v;?Kn*|%#+Z!M#1zF-;SYdp9-soV44?qpPv!_9 z-v!85$;d6K;GV+(G>j3bhCwVRKRJ=%8bcXSe*%!tmz!8oz_6Ji8YmBPutHv9Zt9n& zJ2QcHo&h={upqy<#PQ6|e?SK)0OjoqimXx#o;qsp0qWfW6lep71~4!RtoEqJ^V@u7 zkY|1Y6lb_~;8hrieqyNkoPpv02Ii&Ae3LC0WhIw_b%3N`fE6slHaU;6&4&TxYM{kX zECW^S0TclRy9_YUb$~P*kY;3HdcpXC=l_2Oh8GMFn*S$Ilnp2kGJ$1t1d}c=9BzO0st?0Z7=`; diff --git a/renderer/src/fallback.rs b/renderer/src/fallback.rs index 61312bb0..08254805 100644 --- a/renderer/src/fallback.rs +++ b/renderer/src/fallback.rs @@ -98,6 +98,10 @@ where const ICON_FONT: Self::Font = A::ICON_FONT; const CHECKMARK_ICON: char = A::CHECKMARK_ICON; const ARROW_DOWN_ICON: char = A::ARROW_DOWN_ICON; + const SCROLL_UP_ICON: char = A::SCROLL_UP_ICON; + const SCROLL_DOWN_ICON: char = A::SCROLL_DOWN_ICON; + const SCROLL_LEFT_ICON: char = A::SCROLL_LEFT_ICON; + const SCROLL_RIGHT_ICON: char = A::SCROLL_RIGHT_ICON; const ICED_LOGO: char = A::ICED_LOGO; fn default_font(&self) -> Self::Font { diff --git a/tiny_skia/src/lib.rs b/tiny_skia/src/lib.rs index 05b498ff..9226e1a8 100644 --- a/tiny_skia/src/lib.rs +++ b/tiny_skia/src/lib.rs @@ -256,6 +256,10 @@ impl core::text::Renderer for Renderer { const CHECKMARK_ICON: char = '\u{f00c}'; const ARROW_DOWN_ICON: char = '\u{e800}'; const ICED_LOGO: char = '\u{e801}'; + const SCROLL_UP_ICON: char = '\u{e802}'; + const SCROLL_DOWN_ICON: char = '\u{e803}'; + const SCROLL_LEFT_ICON: char = '\u{e804}'; + const SCROLL_RIGHT_ICON: char = '\u{e805}'; fn default_font(&self) -> Self::Font { self.default_font diff --git a/wgpu/src/lib.rs b/wgpu/src/lib.rs index 11e0f086..616c09ae 100644 --- a/wgpu/src/lib.rs +++ b/wgpu/src/lib.rs @@ -722,6 +722,10 @@ impl core::text::Renderer for Renderer { const CHECKMARK_ICON: char = '\u{f00c}'; const ARROW_DOWN_ICON: char = '\u{e800}'; const ICED_LOGO: char = '\u{e801}'; + const SCROLL_UP_ICON: char = '\u{e802}'; + const SCROLL_DOWN_ICON: char = '\u{e803}'; + const SCROLL_LEFT_ICON: char = '\u{e804}'; + const SCROLL_RIGHT_ICON: char = '\u{e805}'; fn default_font(&self) -> Self::Font { self.default_font diff --git a/widget/src/helpers.rs b/widget/src/helpers.rs index 0f858ffd..5ec702c2 100644 --- a/widget/src/helpers.rs +++ b/widget/src/helpers.rs @@ -1048,7 +1048,7 @@ pub fn scrollable<'a, Message, Theme, Renderer>( ) -> Scrollable<'a, Message, Theme, Renderer> where Theme: scrollable::Catalog + 'a, - Renderer: core::Renderer, + Renderer: core::text::Renderer, { Scrollable::new(content) } diff --git a/widget/src/overlay/menu.rs b/widget/src/overlay/menu.rs index d804695a..f4f14871 100644 --- a/widget/src/overlay/menu.rs +++ b/widget/src/overlay/menu.rs @@ -164,7 +164,7 @@ impl Default for State { struct Overlay<'a, 'b, Message, Theme, Renderer> where Theme: Catalog, - Renderer: crate::core::Renderer, + Renderer: text::Renderer, { position: Point, viewport: Rectangle, diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index 2610cb45..61e7b662 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -20,12 +20,14 @@ //! } //! ``` use crate::container; +use crate::core::alignment; use crate::core::border::{self, Border}; use crate::core::keyboard; use crate::core::layout; use crate::core::mouse; use crate::core::overlay; use crate::core::renderer; +use crate::core::text; use crate::core::time::{Duration, Instant}; use crate::core::touch; use crate::core::widget; @@ -69,7 +71,7 @@ pub struct Scrollable< Renderer = crate::Renderer, > where Theme: Catalog, - Renderer: core::Renderer, + Renderer: text::Renderer, { id: Option, width: Length, @@ -85,7 +87,7 @@ pub struct Scrollable< impl<'a, Message, Theme, Renderer> Scrollable<'a, Message, Theme, Renderer> where Theme: Catalog, - Renderer: core::Renderer, + Renderer: text::Renderer, { /// Creates a new vertical [`Scrollable`]. pub fn new( @@ -399,7 +401,7 @@ impl Widget for Scrollable<'_, Message, Theme, Renderer> where Theme: Catalog, - Renderer: core::Renderer, + Renderer: text::Renderer, { fn tag(&self) -> tree::Tag { tree::Tag::of::() @@ -776,6 +778,8 @@ where { state.interaction = Interaction::None; shell.capture_event(); + shell.invalidate_layout(); + shell.request_redraw(); return; } @@ -909,6 +913,8 @@ where }; shell.capture_event(); + shell.invalidate_layout(); + shell.request_redraw(); } Event::Touch(event) if matches!( @@ -976,18 +982,17 @@ where { let delta = *position - origin; - if delta.x.abs() >= AUTOSCROLL_DEADZONE - || delta.y.abs() >= AUTOSCROLL_DEADZONE - { - state.interaction = Interaction::AutoScrolling { - origin, - current: *position, - last_frame, - }; + state.interaction = Interaction::AutoScrolling { + origin, + current: *position, + last_frame, + }; - if last_frame.is_none() { - shell.request_redraw(); - } + if (delta.x.abs() >= AUTOSCROLL_DEADZONE + || delta.y.abs() >= AUTOSCROLL_DEADZONE) + && last_frame.is_none() + { + shell.request_redraw(); } } } @@ -1014,11 +1019,17 @@ where last_frame: None, }; - let delta = current - origin; + let mut delta = current - origin; - if delta.x.abs() >= AUTOSCROLL_DEADZONE - || delta.y.abs() >= AUTOSCROLL_DEADZONE - { + if delta.x.abs() < AUTOSCROLL_DEADZONE { + delta.x = 0.0; + } + + if delta.y.abs() < AUTOSCROLL_DEADZONE { + delta.y = 0.0; + } + + if delta.x != 0.0 || delta.y != 0.0 { let time_delta = if let Some(last_frame) = last_frame { *now - last_frame @@ -1354,24 +1365,186 @@ where viewport: &Rectangle, translation: Vector, ) -> Option> { + let state = tree.state.downcast_ref::(); let bounds = layout.bounds(); let content_layout = layout.children().next().unwrap(); let content_bounds = content_layout.bounds(); let visible_bounds = bounds.intersection(viewport).unwrap_or(*viewport); + let offset = state.translation(self.direction, bounds, content_bounds); - let offset = tree.state.downcast_ref::().translation( - self.direction, - bounds, - content_bounds, - ); - - self.content.as_widget_mut().overlay( + let overlay = self.content.as_widget_mut().overlay( &mut tree.children[0], layout.children().next().unwrap(), renderer, &visible_bounds, translation - offset, - ) + ); + + let icon = if let Interaction::AutoScrolling { origin, .. } = + state.interaction + { + let scrollbars = + Scrollbars::new(state, self.direction, bounds, content_bounds); + + Some(overlay::Element::new(Box::new(AutoScrollIcon { + origin, + vertical: scrollbars.y.is_some(), + horizontal: scrollbars.x.is_some(), + }))) + } else { + None + }; + + match (overlay, icon) { + (None, None) => None, + (None, Some(icon)) => Some(icon), + (Some(overlay), None) => Some(overlay), + (Some(overlay), Some(icon)) => Some(overlay::Element::new( + Box::new(overlay::Group::with_children(vec![overlay, icon])), + )), + } + } +} + +struct AutoScrollIcon { + origin: Point, + vertical: bool, + horizontal: bool, +} + +impl AutoScrollIcon { + const SIZE: f32 = 40.0; + const DOT: f32 = Self::SIZE / 10.0; + const PADDING: f32 = Self::SIZE / 10.0; +} + +impl core::Overlay + for AutoScrollIcon +where + Renderer: text::Renderer, +{ + fn layout(&mut self, _renderer: &Renderer, _bounds: Size) -> layout::Node { + layout::Node::new(Size::new(Self::SIZE, Self::SIZE)) + .move_to(self.origin - Vector::new(Self::SIZE, Self::SIZE) / 2.0) + } + + fn draw( + &self, + renderer: &mut Renderer, + _theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + ) { + let bounds = layout.bounds(); + + renderer.with_layer(Rectangle::INFINITE, |renderer| { + renderer.fill_quad( + renderer::Quad { + bounds, + border: border::rounded(bounds.width) + .color(Color::BLACK) + .width(1.0), + shadow: core::Shadow { + color: Color::BLACK.scale_alpha(0.8), + offset: Vector::new(1.0, 1.0), + blur_radius: 3.0, + }, + snap: false, + }, + Color::WHITE.scale_alpha(0.8), + ); + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle::new( + bounds.center() + - Vector::new(Self::DOT, Self::DOT) / 2.0, + Size::new(Self::DOT, Self::DOT), + ), + border: border::rounded(bounds.width), + snap: false, + ..renderer::Quad::default() + }, + Color::BLACK.scale_alpha(0.8), + ); + + let arrow = core::Text { + content: String::new(), + bounds: bounds.size(), + size: Pixels::from(12), + line_height: text::LineHeight::Relative(1.0), + font: Renderer::ICON_FONT, + align_x: text::Alignment::Center, + align_y: alignment::Vertical::Center, + shaping: text::Shaping::Basic, + wrapping: text::Wrapping::None, + }; + + if self.vertical { + renderer.fill_text( + core::Text { + content: Renderer::SCROLL_UP_ICON.to_string(), + align_y: alignment::Vertical::Top, + ..arrow + }, + Point::new( + bounds.center_x(), + bounds.y + Self::PADDING - 1.0, + ), + Color::BLACK.scale_alpha(0.8), + bounds, + ); + + renderer.fill_text( + core::Text { + content: Renderer::SCROLL_DOWN_ICON.to_string(), + align_y: alignment::Vertical::Bottom, + ..arrow + }, + Point::new( + bounds.center_x(), + bounds.y + bounds.height - Self::PADDING + 1.0, + ), + Color::BLACK.scale_alpha(0.8), + bounds, + ); + } + + if self.horizontal { + renderer.fill_text( + core::Text { + content: Renderer::SCROLL_LEFT_ICON.to_string(), + align_x: text::Alignment::Left, + ..arrow + }, + Point::new( + bounds.x + Self::PADDING, + bounds.center_y() + 1.0, + ), + Color::BLACK.scale_alpha(0.8), + bounds, + ); + + renderer.fill_text( + core::Text { + content: Renderer::SCROLL_RIGHT_ICON.to_string(), + align_x: text::Alignment::Right, + ..arrow + }, + Point::new( + bounds.x + bounds.width - Self::PADDING, + bounds.center_y() + 1.0, + ), + Color::BLACK.scale_alpha(0.8), + bounds, + ); + } + }); + } + + fn index(&self) -> f32 { + f32::MAX } } @@ -1381,7 +1554,7 @@ impl<'a, Message, Theme, Renderer> where Message: 'a, Theme: 'a + Catalog, - Renderer: 'a + core::Renderer, + Renderer: 'a + text::Renderer, { fn from( text_input: Scrollable<'a, Message, Theme, Renderer>, From c58268751862b50981f7d1f312ca8700ed5bc61f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Fri, 28 Nov 2025 08:56:32 +0100 Subject: [PATCH 5/5] Allow `AutoScroll` styling in `scrollable` widget --- widget/src/scrollable.rs | 60 +++++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/widget/src/scrollable.rs b/widget/src/scrollable.rs index 61e7b662..850f2f32 100644 --- a/widget/src/scrollable.rs +++ b/widget/src/scrollable.rs @@ -1390,6 +1390,7 @@ where origin, vertical: scrollbars.y.is_some(), horizontal: scrollbars.x.is_some(), + class: &self.class, }))) } else { None @@ -1406,22 +1407,24 @@ where } } -struct AutoScrollIcon { +struct AutoScrollIcon<'a, Class> { origin: Point, vertical: bool, horizontal: bool, + class: &'a Class, } -impl AutoScrollIcon { +impl AutoScrollIcon<'_, Class> { const SIZE: f32 = 40.0; const DOT: f32 = Self::SIZE / 10.0; const PADDING: f32 = Self::SIZE / 10.0; } impl core::Overlay - for AutoScrollIcon + for AutoScrollIcon<'_, Theme::Class<'_>> where Renderer: text::Renderer, + Theme: Catalog, { fn layout(&mut self, _renderer: &Renderer, _bounds: Size) -> layout::Node { layout::Node::new(Size::new(Self::SIZE, Self::SIZE)) @@ -1431,20 +1434,27 @@ where fn draw( &self, renderer: &mut Renderer, - _theme: &Theme, + theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, _cursor: mouse::Cursor, ) { let bounds = layout.bounds(); + let style = theme + .style( + self.class, + Status::Active { + is_horizontal_scrollbar_disabled: false, + is_vertical_scrollbar_disabled: false, + }, + ) + .auto_scroll; renderer.with_layer(Rectangle::INFINITE, |renderer| { renderer.fill_quad( renderer::Quad { bounds, - border: border::rounded(bounds.width) - .color(Color::BLACK) - .width(1.0), + border: style.border, shadow: core::Shadow { color: Color::BLACK.scale_alpha(0.8), offset: Vector::new(1.0, 1.0), @@ -1452,7 +1462,7 @@ where }, snap: false, }, - Color::WHITE.scale_alpha(0.8), + style.background, ); renderer.fill_quad( @@ -1466,7 +1476,7 @@ where snap: false, ..renderer::Quad::default() }, - Color::BLACK.scale_alpha(0.8), + style.icon, ); let arrow = core::Text { @@ -1492,7 +1502,7 @@ where bounds.center_x(), bounds.y + Self::PADDING - 1.0, ), - Color::BLACK.scale_alpha(0.8), + style.icon, bounds, ); @@ -1506,7 +1516,7 @@ where bounds.center_x(), bounds.y + bounds.height - Self::PADDING + 1.0, ), - Color::BLACK.scale_alpha(0.8), + style.icon, bounds, ); } @@ -1522,7 +1532,7 @@ where bounds.x + Self::PADDING, bounds.center_y() + 1.0, ), - Color::BLACK.scale_alpha(0.8), + style.icon, bounds, ); @@ -1536,7 +1546,7 @@ where bounds.x + bounds.width - Self::PADDING, bounds.center_y() + 1.0, ), - Color::BLACK.scale_alpha(0.8), + style.icon, bounds, ); } @@ -2263,6 +2273,8 @@ pub struct Style { pub horizontal_rail: Rail, /// The [`Background`] of the gap between a horizontal and vertical scrollbar. pub gap: Option, + /// The appearance of the [`AutoScroll`] overlay. + pub auto_scroll: AutoScroll, } /// The appearance of the scrollbar of a scrollable. @@ -2285,6 +2297,17 @@ pub struct Scroller { pub border: Border, } +/// The appearance of the autoscroll overlay of a scrollable. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AutoScroll { + /// The [`Background`] of the [`AutoScroll`] overlay. + pub background: Background, + /// The [`Border`] of the [`AutoScroll`] overlay. + pub border: Border, + /// The [`Color`] for the arrow icons of the [`AutoScroll`] overlay. + pub icon: Color, +} + /// The theme catalog of a [`Scrollable`]. pub trait Catalog { /// The item class of the [`Catalog`]. @@ -2325,12 +2348,21 @@ pub fn default(theme: &Theme, status: Status) -> Style { }, }; + let auto_scroll = AutoScroll { + background: palette.background.weak.color.scale_alpha(0.9).into(), + border: border::rounded(u32::MAX) + .width(1) + .color(palette.background.weak.text.scale_alpha(0.8)), + icon: palette.background.weak.text.scale_alpha(0.8), + }; + match status { Status::Active { .. } => Style { container: container::Style::default(), vertical_rail: scrollbar, horizontal_rail: scrollbar, gap: None, + auto_scroll, }, Status::Hovered { is_horizontal_scrollbar_hovered, @@ -2358,6 +2390,7 @@ pub fn default(theme: &Theme, status: Status) -> Style { scrollbar }, gap: None, + auto_scroll, } } Status::Dragged { @@ -2386,6 +2419,7 @@ pub fn default(theme: &Theme, status: Status) -> Style { scrollbar }, gap: None, + auto_scroll, } } }