diff --git a/AUTHORS b/AUTHORS index 14d9778a34..e02de26cac 100644 --- a/AUTHORS +++ b/AUTHORS @@ -11,4 +11,5 @@ Kaiyin Zhong Kaur Kuut Leopold Luley Andrey Kabylin -Robert Wittams \ No newline at end of file +Garrett Risley +Robert Wittams diff --git a/CHANGELOG.md b/CHANGELOG.md index c00a250559..b584ef6a94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ You can find its changes [documented below](#060---2020-06-01). - Export `Image` and `ImageData` by default. ([#1011] by [@covercash2]) - Re-export `druid_shell::Scalable` under `druid` namespace. ([#1075] by [@ForLoveOfCats]) - `TextBox` now supports ctrl and shift hotkeys. ([#1076] by [@vkahl]) +- `ScrollComponent` for ease of adding consistent, customized, scrolling behavior to a widget. ([#1107] by [@ForLoveOfCats]) - Selection text color to textbox. ([#1093] by [@sysint64]) - `BoxConstraints::UNBOUNDED` constant. ([#1126] by [@danieldulaney]) - Close requests from the shell can now be intercepted ([#1118] by [@jneem]) @@ -396,6 +397,7 @@ Last release without a changelog :( [#1093]: https://github.com/linebender/druid/pull/1093 [#1100]: https://github.com/linebender/druid/pull/1100 [#1103]: https://github.com/linebender/druid/pull/1103 +[#1107]: https://github.com/linebender/druid/pull/1107 [#1118]: https://github.com/linebender/druid/pull/1118 [#1119]: https://github.com/linebender/druid/pull/1119 [#1120]: https://github.com/linebender/druid/pull/1120 diff --git a/druid/src/lib.rs b/druid/src/lib.rs index 42e9ac03d5..794968edf7 100644 --- a/druid/src/lib.rs +++ b/druid/src/lib.rs @@ -157,6 +157,7 @@ pub mod lens; mod localization; mod menu; mod mouse; +pub mod scroll_component; #[cfg(not(target_arch = "wasm32"))] #[cfg(test)] mod tests; diff --git a/druid/src/scroll_component.rs b/druid/src/scroll_component.rs new file mode 100644 index 0000000000..aa862b8ab7 --- /dev/null +++ b/druid/src/scroll_component.rs @@ -0,0 +1,502 @@ +// Copyright 2020 The Druid Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! A component for embedding in another widget to provide consistent and +//! extendable scrolling behavior + +use std::time::Duration; + +use crate::kurbo::{Affine, Point, Rect, RoundedRect, Size, Vec2}; +use crate::theme; +use crate::{ + Env, Event, EventCtx, LifeCycle, LifeCycleCtx, PaintCtx, Region, RenderContext, TimerToken, +}; + +//TODO: Add this to env +/// Minimum length for any scrollbar to be when measured on that +/// scrollbar's primary axis. +pub const SCROLLBAR_MIN_SIZE: f64 = 45.0; + +/// Denotes which scrollbar, if any, is currently being hovered over +/// by the mouse. +#[derive(Debug, Copy, Clone)] +pub enum BarHoveredState { + /// Neither scrollbar is being hovered by the mouse. + None, + /// The vertical scrollbar is being hovered by the mouse. + Vertical, + /// The horizontal scrollbar is being hovered by the mouse. + Horizontal, +} + +impl BarHoveredState { + /// Determines if any scrollbar is currently being hovered by the mouse. + pub fn is_hovered(self) -> bool { + matches!( + self, + BarHoveredState::Vertical | BarHoveredState::Horizontal + ) + } +} + +/// Denotes which scrollbar, if any, is currently being dragged. +#[derive(Debug, Copy, Clone)] +pub enum BarHeldState { + /// Neither scrollbar is being dragged. + None, + /// Vertical scrollbar is being dragged. Contains an `f64` with + /// the initial y-offset of the dragging input. + Vertical(f64), + /// Horizontal scrollbar is being dragged. Contains an `f64` with + /// the initial x-offset of the dragging input. + Horizontal(f64), +} + +/// Backing struct for storing scrollbar state +#[derive(Debug, Copy, Clone)] +pub struct ScrollbarsState { + /// Current opacity for both scrollbars + pub opacity: f64, + /// ID for the timer which schedules scrollbar fade out + pub timer_id: TimerToken, + /// Which if any scrollbar is currently hovered by the mouse + pub hovered: BarHoveredState, + /// Which if any scrollbar is currently being dragged by the mouse + pub held: BarHeldState, +} + +impl Default for ScrollbarsState { + fn default() -> Self { + Self { + opacity: 0.0, + timer_id: TimerToken::INVALID, + hovered: BarHoveredState::None, + held: BarHeldState::None, + } + } +} + +impl ScrollbarsState { + /// true if either scrollbar is currently held down/being dragged + pub fn are_held(&self) -> bool { + !matches!(self.held, BarHeldState::None) + } +} + +/// Embeddable component exposing reusable scroll handling logic. +/// +/// In most situations composing [`Scroll`] or [`List`] is a better idea +/// for general UI construction. However some cases are not covered by +/// composing those widgets, such as when a widget needs fine grained +/// control over its scrolling state or doesn't make sense to exist alone +/// without scrolling behavior. +/// +/// `ScrollComponent` contains the unified and consistent scroll logic +/// used by both [`Scroll`] and [`List`]. This can be used to add this +/// logic to a custom widget when the need arises. +/// +/// It should be used like this: +/// - Store an instance of `ScrollComponent` in your widget's struct. +/// - During layout, set the [`content_size`] field to the child's size. +/// - Call [`event`] and [`lifecycle`] with all event and lifecycle events before propagating them to children. +/// - Call [`handle_scroll`] with all events after handling / propagating them. +/// - And finally perform painting using the provided [`paint_content`] function. +/// +/// Also, taking a look at the [`Scroll`] source code can be helpful. +/// +/// [`Scroll`]: ../widget/struct.Scroll.html +/// [`List`]: ../widget/struct.List.html +/// [`content_size`]: struct.ScrollComponent.html#structfield.content_size +/// [`event`]: struct.ScrollComponent.html#method.event +/// [`handle_scroll`]: struct.ScrollComponent.html#method.handle_scroll +/// [`lifecycle`]: struct.ScrollComponent.html#method.lifecycle +/// [`paint_content`]: struct.ScrollComponent.html#method.paint_content +#[derive(Debug, Copy, Clone)] +pub struct ScrollComponent { + /// The size of the scrollable content, make sure to keep up this + /// accurate to the content being scrolled + pub content_size: Size, + /// Current offset of the scrolling content + pub scroll_offset: Vec2, + /// Current state of both scrollbars + pub scrollbars: ScrollbarsState, +} + +impl Default for ScrollComponent { + fn default() -> Self { + ScrollComponent::new() + } +} + +impl ScrollComponent { + /// Constructs a new [`ScrollComponent`](struct.ScrollComponent.html) for use. + pub fn new() -> ScrollComponent { + ScrollComponent { + content_size: Size::default(), + scroll_offset: Vec2::new(0.0, 0.0), + scrollbars: ScrollbarsState::default(), + } + } + + /// Scroll `delta` units. + /// + /// Returns `true` if the scroll offset has changed. + pub fn scroll(&mut self, delta: Vec2, layout_size: Size) -> bool { + let mut offset = self.scroll_offset + delta; + offset.x = offset + .x + .min(self.content_size.width - layout_size.width) + .max(0.0); + offset.y = offset + .y + .min(self.content_size.height - layout_size.height) + .max(0.0); + if (offset - self.scroll_offset).hypot2() > 1e-12 { + self.scroll_offset = offset; + true + } else { + false + } + } + + /// Makes the scrollbars visible, and resets the fade timer. + pub fn reset_scrollbar_fade(&mut self, request_timer: F, env: &Env) + where + F: FnOnce(Duration) -> TimerToken, + { + self.scrollbars.opacity = env.get(theme::SCROLLBAR_MAX_OPACITY); + let fade_delay = env.get(theme::SCROLLBAR_FADE_DELAY); + let deadline = Duration::from_millis(fade_delay); + self.scrollbars.timer_id = request_timer(deadline); + } + + /// Calculates the paint rect of the vertical scrollbar. + /// + /// Returns `Rect::ZERO` if the vertical scrollbar is not visible. + pub fn calc_vertical_bar_bounds(&self, viewport: Rect, env: &Env) -> Rect { + if viewport.height() >= self.content_size.height { + return Rect::ZERO; + } + + let bar_width = env.get(theme::SCROLLBAR_WIDTH); + let bar_pad = env.get(theme::SCROLLBAR_PAD); + + let percent_visible = viewport.height() / self.content_size.height; + let percent_scrolled = + self.scroll_offset.y / (self.content_size.height - viewport.height()); + + let length = (percent_visible * viewport.height()).ceil(); + let length = length.max(SCROLLBAR_MIN_SIZE); + + let vertical_padding = bar_pad + bar_pad + bar_width; + + let top_y_offset = + ((viewport.height() - length - vertical_padding) * percent_scrolled).ceil(); + let bottom_y_offset = top_y_offset + length; + + let x0 = self.scroll_offset.x + viewport.width() - bar_width - bar_pad; + let y0 = self.scroll_offset.y + top_y_offset + bar_pad; + + let x1 = self.scroll_offset.x + viewport.width() - bar_pad; + let y1 = self.scroll_offset.y + bottom_y_offset; + + Rect::new(x0, y0, x1, y1) + } + + /// Calculates the paint rect of the horizontal scrollbar. + /// + /// Returns `Rect::ZERO` if the horizontal scrollbar is not visible. + pub fn calc_horizontal_bar_bounds(&self, viewport: Rect, env: &Env) -> Rect { + if viewport.width() >= self.content_size.width { + return Rect::ZERO; + } + + let bar_width = env.get(theme::SCROLLBAR_WIDTH); + let bar_pad = env.get(theme::SCROLLBAR_PAD); + + let percent_visible = viewport.width() / self.content_size.width; + let percent_scrolled = self.scroll_offset.x / (self.content_size.width - viewport.width()); + + let length = (percent_visible * viewport.width()).ceil(); + let length = length.max(SCROLLBAR_MIN_SIZE); + + let horizontal_padding = bar_pad + bar_pad + bar_width; + + let left_x_offset = + ((viewport.width() - length - horizontal_padding) * percent_scrolled).ceil(); + let right_x_offset = left_x_offset + length; + + let x0 = self.scroll_offset.x + left_x_offset + bar_pad; + let y0 = self.scroll_offset.y + viewport.height() - bar_width - bar_pad; + + let x1 = self.scroll_offset.x + right_x_offset; + let y1 = self.scroll_offset.y + viewport.height() - bar_pad; + + Rect::new(x0, y0, x1, y1) + } + + /// Draw scroll bars. + pub fn draw_bars(&self, ctx: &mut PaintCtx, viewport: Rect, env: &Env) { + if self.scrollbars.opacity <= 0.0 { + return; + } + + let brush = ctx.render_ctx.solid_brush( + env.get(theme::SCROLLBAR_COLOR) + .with_alpha(self.scrollbars.opacity), + ); + let border_brush = ctx.render_ctx.solid_brush( + env.get(theme::SCROLLBAR_BORDER_COLOR) + .with_alpha(self.scrollbars.opacity), + ); + + let radius = env.get(theme::SCROLLBAR_RADIUS); + let edge_width = env.get(theme::SCROLLBAR_EDGE_WIDTH); + + // Vertical bar + if viewport.height() < self.content_size.height { + let bounds = self + .calc_vertical_bar_bounds(viewport, env) + .inset(-edge_width / 2.0); + let rect = RoundedRect::from_rect(bounds, radius); + ctx.render_ctx.fill(rect, &brush); + ctx.render_ctx.stroke(rect, &border_brush, edge_width); + } + + // Horizontal bar + if viewport.width() < self.content_size.width { + let bounds = self + .calc_horizontal_bar_bounds(viewport, env) + .inset(-edge_width / 2.0); + let rect = RoundedRect::from_rect(bounds, radius); + ctx.render_ctx.fill(rect, &brush); + ctx.render_ctx.stroke(rect, &border_brush, edge_width); + } + } + + /// Tests if the specified point overlaps the vertical scrollbar + /// + /// Returns false if the vertical scrollbar is not visible + pub fn point_hits_vertical_bar(&self, viewport: Rect, pos: Point, env: &Env) -> bool { + if viewport.height() < self.content_size.height { + // Stretch hitbox to edge of widget + let mut bounds = self.calc_vertical_bar_bounds(viewport, env); + bounds.x1 = self.scroll_offset.x + viewport.width(); + bounds.contains(pos) + } else { + false + } + } + + /// Tests if the specified point overlaps the horizontal scrollbar + /// + /// Returns false if the horizontal scrollbar is not visible + pub fn point_hits_horizontal_bar(&self, viewport: Rect, pos: Point, env: &Env) -> bool { + if viewport.width() < self.content_size.width { + // Stretch hitbox to edge of widget + let mut bounds = self.calc_horizontal_bar_bounds(viewport, env); + bounds.y1 = self.scroll_offset.y + viewport.height(); + bounds.contains(pos) + } else { + false + } + } + + /// Checks if the event applies to the scroll behavior, uses it, and marks it handled + /// + /// Make sure to call on every event + pub fn event(&mut self, ctx: &mut EventCtx, event: &Event, env: &Env) { + let size = ctx.size(); + let viewport = Rect::from_origin_size(Point::ORIGIN, size); + + let scrollbar_is_hovered = match event { + Event::MouseMove(e) | Event::MouseUp(e) | Event::MouseDown(e) => { + let offset_pos = e.pos + self.scroll_offset; + self.point_hits_vertical_bar(viewport, offset_pos, env) + || self.point_hits_horizontal_bar(viewport, offset_pos, env) + } + _ => false, + }; + + if self.scrollbars.are_held() { + // if we're dragging a scrollbar + match event { + Event::MouseMove(event) => { + match self.scrollbars.held { + BarHeldState::Vertical(offset) => { + let scale_y = viewport.height() / self.content_size.height; + let bounds = self.calc_vertical_bar_bounds(viewport, env); + let mouse_y = event.pos.y + self.scroll_offset.y; + let delta = mouse_y - bounds.y0 - offset; + self.scroll(Vec2::new(0f64, (delta / scale_y).ceil()), size); + ctx.set_handled(); + } + BarHeldState::Horizontal(offset) => { + let scale_x = viewport.width() / self.content_size.width; + let bounds = self.calc_horizontal_bar_bounds(viewport, env); + let mouse_x = event.pos.x + self.scroll_offset.x; + let delta = mouse_x - bounds.x0 - offset; + self.scroll(Vec2::new((delta / scale_x).ceil(), 0f64), size); + ctx.set_handled(); + } + _ => (), + } + ctx.request_paint(); + } + Event::MouseUp(_) => { + self.scrollbars.held = BarHeldState::None; + ctx.set_active(false); + + if !scrollbar_is_hovered { + self.scrollbars.hovered = BarHoveredState::None; + self.reset_scrollbar_fade(|d| ctx.request_timer(d), env); + } + + ctx.set_handled(); + } + _ => (), // other events are a noop + } + } else if scrollbar_is_hovered { + // if we're over a scrollbar but not dragging + match event { + Event::MouseMove(event) => { + let offset_pos = event.pos + self.scroll_offset; + if self.point_hits_vertical_bar(viewport, offset_pos, env) { + self.scrollbars.hovered = BarHoveredState::Vertical; + } else if self.point_hits_horizontal_bar(viewport, offset_pos, env) { + self.scrollbars.hovered = BarHoveredState::Horizontal; + } else { + unreachable!(); + } + + self.scrollbars.opacity = env.get(theme::SCROLLBAR_MAX_OPACITY); + self.scrollbars.timer_id = TimerToken::INVALID; // Cancel any fade out in progress + ctx.request_paint(); + ctx.set_handled(); + } + Event::MouseDown(event) => { + let pos = event.pos + self.scroll_offset; + + if self.point_hits_vertical_bar(viewport, pos, env) { + ctx.set_active(true); + self.scrollbars.held = BarHeldState::Vertical( + pos.y - self.calc_vertical_bar_bounds(viewport, env).y0, + ); + } else if self.point_hits_horizontal_bar(viewport, pos, env) { + ctx.set_active(true); + self.scrollbars.held = BarHeldState::Horizontal( + pos.x - self.calc_horizontal_bar_bounds(viewport, env).x0, + ); + } else { + unreachable!(); + } + + ctx.set_handled(); + } + // if the mouse was downed elsewhere, moved over a scroll bar and released: noop. + Event::MouseUp(_) => (), + _ => unreachable!(), + } + } else { + match event { + Event::MouseMove(_) => { + // if we have just stopped hovering + if self.scrollbars.hovered.is_hovered() && !scrollbar_is_hovered { + self.scrollbars.hovered = BarHoveredState::None; + self.reset_scrollbar_fade(|d| ctx.request_timer(d), env); + } + } + Event::Timer(id) if *id == self.scrollbars.timer_id => { + // Schedule scroll bars animation + ctx.request_anim_frame(); + self.scrollbars.timer_id = TimerToken::INVALID; + ctx.set_handled(); + } + _ => (), + } + } + } + + /// Applies mousewheel scrolling if the event has not already been handled + pub fn handle_scroll(&mut self, ctx: &mut EventCtx, event: &Event, env: &Env) { + if !ctx.is_handled() { + if let Event::Wheel(mouse) = event { + if self.scroll(mouse.wheel_delta, ctx.size()) { + ctx.request_paint(); + ctx.set_handled(); + self.reset_scrollbar_fade(|d| ctx.request_timer(d), env); + } + } + } + } + + /// Perform any necessary action prompted by a lifecycle event + /// + /// Make sure to call on every lifecycle event + pub fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, env: &Env) { + match event { + LifeCycle::AnimFrame(interval) => { + // Guard by the timer id being invalid, otherwise the scroll bars would fade + // immediately if some other widget started animating. + if self.scrollbars.timer_id == TimerToken::INVALID { + // Animate scroll bars opacity + let diff = 2.0 * (*interval as f64) * 1e-9; + self.scrollbars.opacity -= diff; + if self.scrollbars.opacity > 0.0 { + ctx.request_anim_frame(); + } + + let viewport = ctx.size().to_rect(); + if viewport.width() < self.content_size.width { + ctx.request_paint_rect( + self.calc_horizontal_bar_bounds(viewport, env) - self.scroll_offset, + ); + } + if viewport.height() < self.content_size.height { + ctx.request_paint_rect( + self.calc_vertical_bar_bounds(viewport, env) - self.scroll_offset, + ); + } + } + } + + // Show the scrollbars any time our size changes + LifeCycle::Size(_) => { + self.reset_scrollbar_fade(|d| ctx.request_timer(d), &env); + } + + _ => {} + } + } + + /// Helper function to paint a closure at the correct offset with clipping and scrollbars + pub fn paint_content( + self, + ctx: &mut PaintCtx, + env: &Env, + f: impl FnOnce(Region, &mut PaintCtx), + ) { + let viewport = ctx.size().to_rect(); + ctx.with_save(|ctx| { + ctx.clip(viewport); + ctx.transform(Affine::translate(-self.scroll_offset)); + + let mut visible = ctx.region().clone(); + visible += self.scroll_offset; + f(visible, ctx); + + self.draw_bars(ctx, viewport, env); + }); + } +} diff --git a/druid/src/widget/scroll.rs b/druid/src/widget/scroll.rs index 4e3d2346c5..e1453c6c6e 100644 --- a/druid/src/widget/scroll.rs +++ b/druid/src/widget/scroll.rs @@ -15,127 +15,59 @@ //! A container that scrolls its contents. use std::f64::INFINITY; -use std::time::Duration; -use crate::kurbo::{Affine, Point, Rect, RoundedRect, Size, Vec2}; -use crate::theme; +use crate::kurbo::{Point, Rect, Size, Vec2}; use crate::{ - BoxConstraints, Data, Env, Event, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx, PaintCtx, - RenderContext, TimerToken, UpdateCtx, Widget, WidgetPod, + scroll_component::*, BoxConstraints, Data, Env, Event, EventCtx, LayoutCtx, LifeCycle, + LifeCycleCtx, PaintCtx, UpdateCtx, Widget, WidgetPod, }; -const SCROLLBAR_MIN_SIZE: f64 = 45.0; - #[derive(Debug, Clone)] enum ScrollDirection { - Horizontal, - Vertical, - All, -} - -impl ScrollDirection { - /// Return the maximum size the container can be given - /// its scroll direction and box constraints. - /// In practice vertical scrolling will be width limited to - /// box constraints and horizontal will be height limited. - pub fn max_size(&self, bc: &BoxConstraints) -> Size { - match self { - ScrollDirection::Horizontal => Size::new(INFINITY, bc.max().height), - ScrollDirection::Vertical => Size::new(bc.max().width, INFINITY), - ScrollDirection::All => Size::new(INFINITY, INFINITY), - } - } -} - -enum BarHoveredState { - None, + Bidirectional, Vertical, Horizontal, } -impl BarHoveredState { - fn is_hovered(&self) -> bool { - matches!( - self, - BarHoveredState::Vertical | BarHoveredState::Horizontal - ) - } -} - -enum BarHeldState { - None, - /// Vertical scrollbar is being dragged. Contains an `f64` with - /// the initial y-offset of the dragging input - Vertical(f64), - /// Horizontal scrollbar is being dragged. Contains an `f64` with - /// the initial x-offset of the dragging input - Horizontal(f64), -} - -struct ScrollbarsState { - opacity: f64, - timer_id: TimerToken, - hovered: BarHoveredState, - held: BarHeldState, -} - -impl Default for ScrollbarsState { - fn default() -> Self { - Self { - opacity: 0.0, - timer_id: TimerToken::INVALID, - hovered: BarHoveredState::None, - held: BarHeldState::None, - } - } -} - -impl ScrollbarsState { - /// true if either scrollbar is currently held down/being dragged - fn are_held(&self) -> bool { - !matches!(self.held, BarHeldState::None) - } -} - /// A container that scrolls its contents. /// /// This container holds a single child, and uses the wheel to scroll it /// when the child's bounds are larger than the viewport. /// -/// The child is laid out with completely unconstrained layout bounds. +/// The child is laid out with completely unconstrained layout bounds by +/// default. Restrict to a specific axis with [`vertical`] or [`horizontal`]. +/// When restricted to scrolling on a specific axis the child's size is +/// locked on the opposite axis. +/// +/// [`vertical`]: struct.Scroll.html#method.vertical +/// [`horizontal`]: struct.Scroll.html#method.horizontal pub struct Scroll { child: WidgetPod, - child_size: Size, - scroll_offset: Vec2, + scroll_component: ScrollComponent, direction: ScrollDirection, - scrollbars: ScrollbarsState, } impl> Scroll { /// Create a new scroll container. /// /// This method will allow scrolling in all directions if child's bounds - /// are larger than the viewport. Use [vertical](#method.vertical) - /// and [horizontal](#method.horizontal) methods to limit scroll behavior. + /// are larger than the viewport. Use [vertical](#method.vertical) and + /// [horizontal](#method.horizontal) methods to limit scrolling to a specific axis. pub fn new(child: W) -> Scroll { Scroll { child: WidgetPod::new(child), - child_size: Default::default(), - scroll_offset: Vec2::new(0.0, 0.0), - direction: ScrollDirection::All, - scrollbars: ScrollbarsState::default(), + scroll_component: ScrollComponent::new(), + direction: ScrollDirection::Bidirectional, } } - /// Limit scroll behavior to allow only vertical scrolling (Y-axis). - /// The child is laid out with constrained width and infinite height. + /// Restrict scrolling to the vertical axis while locking child width. pub fn vertical(mut self) -> Self { self.direction = ScrollDirection::Vertical; self } - /// Limit scroll behavior to allow only horizontal scrolling (X-axis). - /// The child is laid out with constrained height and infinite width. + /// Restrict scrolling to the horizontal axis while locking child height. pub fn horizontal(mut self) -> Self { self.direction = ScrollDirection::Horizontal; self @@ -153,301 +85,35 @@ impl> Scroll { /// Returns the size of the child widget. pub fn child_size(&self) -> Size { - self.child_size - } - - /// Update the scroll. - /// - /// Returns `true` if the scroll has been updated. - pub fn scroll(&mut self, delta: Vec2, size: Size) -> bool { - let mut offset = self.scroll_offset + delta; - offset.x = offset.x.min(self.child_size.width - size.width).max(0.0); - offset.y = offset.y.min(self.child_size.height - size.height).max(0.0); - if (offset - self.scroll_offset).hypot2() > 1e-12 { - self.scroll_offset = offset; - self.child.set_viewport_offset(offset); - true - } else { - false - } - } - - /// Makes the scrollbars visible, and resets the fade timer. - pub fn reset_scrollbar_fade(&mut self, request_timer: F, env: &Env) - where - F: FnOnce(Duration) -> TimerToken, - { - // Display scroll bars and schedule their disappearance - self.scrollbars.opacity = env.get(theme::SCROLLBAR_MAX_OPACITY); - let fade_delay = env.get(theme::SCROLLBAR_FADE_DELAY); - let deadline = Duration::from_millis(fade_delay); - self.scrollbars.timer_id = request_timer(deadline); + self.scroll_component.content_size } /// Returns the current scroll offset. pub fn offset(&self) -> Vec2 { - self.scroll_offset - } - - fn calc_vertical_bar_bounds(&self, viewport: Rect, env: &Env) -> Rect { - let bar_width = env.get(theme::SCROLLBAR_WIDTH); - let bar_pad = env.get(theme::SCROLLBAR_PAD); - - let percent_visible = viewport.height() / self.child_size.height; - let percent_scrolled = self.scroll_offset.y / (self.child_size.height - viewport.height()); - - let length = (percent_visible * viewport.height()).ceil(); - let length = length.max(SCROLLBAR_MIN_SIZE); - - let vertical_padding = bar_pad + bar_pad + bar_width; - - let top_y_offset = - ((viewport.height() - length - vertical_padding) * percent_scrolled).ceil(); - let bottom_y_offset = top_y_offset + length; - - let x0 = self.scroll_offset.x + viewport.width() - bar_width - bar_pad; - let y0 = self.scroll_offset.y + top_y_offset + bar_pad; - - let x1 = self.scroll_offset.x + viewport.width() - bar_pad; - let y1 = self.scroll_offset.y + bottom_y_offset; - - Rect::new(x0, y0, x1, y1) - } - - fn calc_horizontal_bar_bounds(&self, viewport: Rect, env: &Env) -> Rect { - let bar_width = env.get(theme::SCROLLBAR_WIDTH); - let bar_pad = env.get(theme::SCROLLBAR_PAD); - - let percent_visible = viewport.width() / self.child_size.width; - let percent_scrolled = self.scroll_offset.x / (self.child_size.width - viewport.width()); - - let length = (percent_visible * viewport.width()).ceil(); - let length = length.max(SCROLLBAR_MIN_SIZE); - - let horizontal_padding = bar_pad + bar_pad + bar_width; - - let left_x_offset = - ((viewport.width() - length - horizontal_padding) * percent_scrolled).ceil(); - let right_x_offset = left_x_offset + length; - - let x0 = self.scroll_offset.x + left_x_offset + bar_pad; - let y0 = self.scroll_offset.y + viewport.height() - bar_width - bar_pad; - - let x1 = self.scroll_offset.x + right_x_offset; - let y1 = self.scroll_offset.y + viewport.height() - bar_pad; - - Rect::new(x0, y0, x1, y1) - } - - /// Draw scroll bars. - fn draw_bars(&self, ctx: &mut PaintCtx, viewport: Rect, env: &Env) { - if self.scrollbars.opacity <= 0.0 { - return; - } - - let brush = ctx.render_ctx.solid_brush( - env.get(theme::SCROLLBAR_COLOR) - .with_alpha(self.scrollbars.opacity), - ); - let border_brush = ctx.render_ctx.solid_brush( - env.get(theme::SCROLLBAR_BORDER_COLOR) - .with_alpha(self.scrollbars.opacity), - ); - - let radius = env.get(theme::SCROLLBAR_RADIUS); - let edge_width = env.get(theme::SCROLLBAR_EDGE_WIDTH); - - // Vertical bar - if viewport.height() < self.child_size.height { - let bounds = self - .calc_vertical_bar_bounds(viewport, env) - .inset(-edge_width / 2.0); - let rect = RoundedRect::from_rect(bounds, radius); - ctx.render_ctx.fill(rect, &brush); - ctx.render_ctx.stroke(rect, &border_brush, edge_width); - } - - // Horizontal bar - if viewport.width() < self.child_size.width { - let bounds = self - .calc_horizontal_bar_bounds(viewport, env) - .inset(-edge_width / 2.0); - let rect = RoundedRect::from_rect(bounds, radius); - ctx.render_ctx.fill(rect, &brush); - ctx.render_ctx.stroke(rect, &border_brush, edge_width); - } - } - - fn point_hits_vertical_bar(&self, viewport: Rect, pos: Point, env: &Env) -> bool { - if viewport.height() < self.child_size.height { - // Stretch hitbox to edge of widget - let mut bounds = self.calc_vertical_bar_bounds(viewport, env); - bounds.x1 = self.scroll_offset.x + viewport.width(); - bounds.contains(pos) - } else { - false - } - } - - fn point_hits_horizontal_bar(&self, viewport: Rect, pos: Point, env: &Env) -> bool { - if viewport.width() < self.child_size.width { - // Stretch hitbox to edge of widget - let mut bounds = self.calc_horizontal_bar_bounds(viewport, env); - bounds.y1 = self.scroll_offset.y + viewport.height(); - bounds.contains(pos) - } else { - false - } + self.scroll_component.scroll_offset } } impl> Widget for Scroll { fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { - let size = ctx.size(); - let viewport = Rect::from_origin_size(Point::ORIGIN, size); - - let scrollbar_is_hovered = match event { - Event::MouseMove(e) | Event::MouseUp(e) | Event::MouseDown(e) => { - let offset_pos = e.pos + self.scroll_offset; - self.point_hits_vertical_bar(viewport, offset_pos, env) - || self.point_hits_horizontal_bar(viewport, offset_pos, env) - } - _ => false, - }; - - if self.scrollbars.are_held() { - // if we're dragging a scrollbar - match event { - Event::MouseMove(event) => { - match self.scrollbars.held { - BarHeldState::Vertical(offset) => { - let scale_y = viewport.height() / self.child_size.height; - let bounds = self.calc_vertical_bar_bounds(viewport, env); - let mouse_y = event.pos.y + self.scroll_offset.y; - let delta = mouse_y - bounds.y0 - offset; - self.scroll(Vec2::new(0f64, (delta / scale_y).ceil()), size); - } - BarHeldState::Horizontal(offset) => { - let scale_x = viewport.width() / self.child_size.width; - let bounds = self.calc_horizontal_bar_bounds(viewport, env); - let mouse_x = event.pos.x + self.scroll_offset.x; - let delta = mouse_x - bounds.x0 - offset; - self.scroll(Vec2::new((delta / scale_x).ceil(), 0f64), size); - } - _ => (), - } - ctx.request_paint(); - } - Event::MouseUp(_) => { - self.scrollbars.held = BarHeldState::None; - ctx.set_active(false); - - if !scrollbar_is_hovered { - self.scrollbars.hovered = BarHoveredState::None; - self.reset_scrollbar_fade(|d| ctx.request_timer(d), env); - } - } - _ => (), // other events are a noop - } - } else if scrollbar_is_hovered { - // if we're over a scrollbar but not dragging - match event { - Event::MouseMove(event) => { - let offset_pos = event.pos + self.scroll_offset; - if self.point_hits_vertical_bar(viewport, offset_pos, env) { - self.scrollbars.hovered = BarHoveredState::Vertical; - } else { - self.scrollbars.hovered = BarHoveredState::Horizontal; - } - - self.scrollbars.opacity = env.get(theme::SCROLLBAR_MAX_OPACITY); - self.scrollbars.timer_id = TimerToken::INVALID; // Cancel any fade out in progress - ctx.request_paint(); - } - Event::MouseDown(event) => { - let pos = event.pos + self.scroll_offset; + self.scroll_component.event(ctx, event, env); + if !ctx.is_handled() { + let viewport = Rect::from_origin_size(Point::ORIGIN, ctx.size()); - if self.point_hits_vertical_bar(viewport, pos, env) { - ctx.set_active(true); - self.scrollbars.held = BarHeldState::Vertical( - pos.y - self.calc_vertical_bar_bounds(viewport, env).y0, - ); - } else if self.point_hits_horizontal_bar(viewport, pos, env) { - ctx.set_active(true); - self.scrollbars.held = BarHeldState::Horizontal( - pos.x - self.calc_horizontal_bar_bounds(viewport, env).x0, - ); - } - } - // if the mouse was downed elsewhere, moved over a scroll bar and released: noop. - Event::MouseUp(_) => (), - _ => unreachable!(), - } - } else { let force_event = self.child.is_hot() || self.child.is_active(); - let child_event = event.transform_scroll(self.scroll_offset, viewport, force_event); + let child_event = + event.transform_scroll(self.scroll_component.scroll_offset, viewport, force_event); if let Some(child_event) = child_event { self.child.event(ctx, &child_event, data, env); }; - - match event { - Event::MouseMove(_) => { - // if we have just stopped hovering - if self.scrollbars.hovered.is_hovered() && !scrollbar_is_hovered { - self.scrollbars.hovered = BarHoveredState::None; - self.reset_scrollbar_fade(|d| ctx.request_timer(d), env); - } - } - Event::Timer(id) if *id == self.scrollbars.timer_id => { - // Schedule scroll bars animation - ctx.request_anim_frame(); - self.scrollbars.timer_id = TimerToken::INVALID; - } - _ => (), - } } - if !ctx.is_handled() { - if let Event::Wheel(mouse) = event { - if self.scroll(mouse.wheel_delta, size) { - ctx.request_paint(); - ctx.set_handled(); - self.reset_scrollbar_fade(|d| ctx.request_timer(d), env); - } - } - } + self.scroll_component.handle_scroll(ctx, event, env); } fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) { - match event { - LifeCycle::AnimFrame(interval) => { - // Guard by the timer id being invalid, otherwise the scroll bars would fade - // immediately if some other widget started animating. - if self.scrollbars.timer_id == TimerToken::INVALID { - // Animate scroll bars opacity - let diff = 2.0 * (*interval as f64) * 1e-9; - self.scrollbars.opacity -= diff; - if self.scrollbars.opacity > 0.0 { - ctx.request_anim_frame(); - } - let viewport = ctx.size().to_rect(); - if viewport.width() < self.child_size.width { - ctx.request_paint_rect( - self.calc_horizontal_bar_bounds(viewport, env) - self.scroll_offset, - ); - } - if viewport.height() < self.child_size.height { - ctx.request_paint_rect( - self.calc_vertical_bar_bounds(viewport, env) - self.scroll_offset, - ); - } - } - } - // Show the scrollbars any time our size changes - LifeCycle::Size(_) => self.reset_scrollbar_fade(|d| ctx.request_timer(d), &env), - _ => (), - } - self.child.lifecycle(ctx, event, data, env) + self.scroll_component.lifecycle(ctx, event, env); + self.child.lifecycle(ctx, event, data, env); } fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) { @@ -457,29 +123,29 @@ impl> Widget for Scroll { fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size { bc.debug_check("Scroll"); - let child_bc = BoxConstraints::new(Size::ZERO, self.direction.max_size(bc)); - let size = self.child.layout(ctx, &child_bc, data, env); - log_size_warnings(size); + let max_bc = match self.direction { + ScrollDirection::Bidirectional => Size::new(INFINITY, INFINITY), + ScrollDirection::Vertical => Size::new(bc.max().width, INFINITY), + ScrollDirection::Horizontal => Size::new(INFINITY, bc.max().height), + }; + + let child_bc = BoxConstraints::new(Size::ZERO, max_bc); + let child_size = self.child.layout(ctx, &child_bc, data, env); + log_size_warnings(child_size); + self.scroll_component.content_size = child_size; + self.child + .set_layout_rect(ctx, data, env, child_size.to_rect()); - self.child_size = size; - self.child.set_layout_rect(ctx, data, env, size.to_rect()); - let self_size = bc.constrain(self.child_size); - let _ = self.scroll(Vec2::new(0.0, 0.0), self_size); + let self_size = bc.constrain(max_bc); + let _ = self.scroll_component.scroll(Vec2::new(0.0, 0.0), self_size); self_size } fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) { - let viewport = ctx.size().to_rect(); - ctx.with_save(|ctx| { - ctx.clip(viewport); - ctx.transform(Affine::translate(-self.scroll_offset)); - - let mut visible = ctx.region().clone(); - visible += self.scroll_offset; - ctx.with_child_ctx(visible, |ctx| self.child.paint_raw(ctx, data, env)); - - self.draw_bars(ctx, viewport, env); - }); + self.scroll_component + .paint_content(ctx, env, |visible, ctx| { + ctx.with_child_ctx(visible, |ctx| self.child.paint_raw(ctx, data, env)); + }); } }