diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ade7525f..3743a610f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ You can find its changes [documented below](#060---2020-06-01). - `WindowLevel` to control system window Z order, with Mac and GTK implementations ([#1231] by [@rjwittams]) - WIDGET_PADDING items added to theme and `Flex::with_default_spacer`/`Flex::add_default_spacer` ([#1220] by [@cmyr]) - CONFIGURE_WINDOW command to allow reconfiguration of an existing window. ([#1235] by [@rjwittams]) +- Added a ClipBox widget for building scrollable widgets ([#1248] by [@jneem]) - `RawLabel` widget displays text `Data`. ([#1252] by [@cmyr]) - 'Tabs' widget allowing static and dynamic tabbed layouts. ([#1160] by [@rjwittams]) - `RichText` and `Attribute` types for creating rich text ([#1255] by [@cmyr]) @@ -494,6 +495,7 @@ Last release without a changelog :( [#1238]: https://github.com/linebender/druid/pull/1238 [#1241]: https://github.com/linebender/druid/pull/1241 [#1245]: https://github.com/linebender/druid/pull/1245 +[#1248]: https://github.com/linebender/druid/pull/1248 [#1251]: https://github.com/linebender/druid/pull/1251 [#1252]: https://github.com/linebender/druid/pull/1252 [#1255]: https://github.com/linebender/druid/pull/1255 diff --git a/druid/src/scroll_component.rs b/druid/src/scroll_component.rs index c7a0e6f5f8..d777fd1818 100644 --- a/druid/src/scroll_component.rs +++ b/druid/src/scroll_component.rs @@ -17,11 +17,10 @@ use std::time::Duration; -use crate::kurbo::{Affine, Point, Rect, RoundedRect, Size, Vec2}; +use crate::kurbo::{Point, Rect, Vec2}; use crate::theme; -use crate::{ - Env, Event, EventCtx, LifeCycle, LifeCycleCtx, PaintCtx, Region, RenderContext, TimerToken, -}; +use crate::widget::Viewport; +use crate::{Env, Event, EventCtx, LifeCycle, LifeCycleCtx, PaintCtx, RenderContext, TimerToken}; //TODO: Add this to env /// Minimum length for any scrollbar to be when measured on that @@ -63,111 +62,67 @@ pub enum BarHeldState { 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 +/// In most situations composing [`Scroll`] 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. +/// `ScrollComponent` contains the input-handling and scrollbar-positioning logic used by +/// [`Scroll`]. It 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. +/// It can be used like this: +/// - Store an instance of `ScrollComponent` in your widget's struct, and wrap the child widget to +/// be scrolled in a [`ClipBox`]. +/// - 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. +/// - Call [`draw_bars`] to draw the scrollbars. /// -/// Also, taking a look at the [`Scroll`] source code can be helpful. +/// Taking a look at the [`Scroll`] source code can be helpful. You can also do scrolling +/// without wrapping a child in a [`ClipBox`], but you will need to do certain event and +/// paint transformations yourself; see the [`ClipBox`] source code for an example. /// /// [`Scroll`]: ../widget/struct.Scroll.html /// [`List`]: ../widget/struct.List.html -/// [`content_size`]: struct.ScrollComponent.html#structfield.content_size +/// [`ClipBox`]: ../widget/struct.ClipBox.html /// [`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, + /// 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 ScrollComponent { fn default() -> Self { - ScrollComponent::new() + Self { + opacity: 0.0, + timer_id: TimerToken::INVALID, + hovered: BarHoveredState::None, + held: BarHeldState::None, + } } } 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(), - } + Default::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 - } + /// true if either scrollbar is currently held down/being dragged + pub fn are_bars_held(&self) -> bool { + !matches!(self.held, BarHeldState::None) } /// Makes the scrollbars visible, and resets the fade timer. @@ -175,111 +130,114 @@ impl ScrollComponent { where F: FnOnce(Duration) -> TimerToken, { - self.scrollbars.opacity = env.get(theme::SCROLLBAR_MAX_OPACITY); + self.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.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; + /// Calculates the paint rect of the vertical scrollbar, or `None` if the vertical scrollbar is + /// not visible. + pub fn calc_vertical_bar_bounds(&self, port: &Viewport, env: &Env) -> Option { + let viewport_size = port.rect.size(); + let content_size = port.content_size; + let scroll_offset = port.rect.origin().to_vec2(); + + if viewport_size.height >= content_size.height { + return None; } 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 percent_visible = viewport_size.height / content_size.height; + let percent_scrolled = scroll_offset.y / (content_size.height - viewport_size.height); - let length = (percent_visible * viewport.height()).ceil(); + let length = (percent_visible * viewport_size.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(); + ((viewport_size.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 x0 = scroll_offset.x + viewport_size.width - bar_width - bar_pad; + let y0 = 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; + let x1 = scroll_offset.x + viewport_size.width - bar_pad; + let y1 = scroll_offset.y + bottom_y_offset; - Rect::new(x0, y0, x1, y1) + Some(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; + /// Calculates the paint rect of the horizontal scrollbar, or `None` if the horizontal + /// scrollbar is not visible. + pub fn calc_horizontal_bar_bounds(&self, port: &Viewport, env: &Env) -> Option { + let viewport_size = port.rect.size(); + let content_size = port.content_size; + let scroll_offset = port.rect.origin().to_vec2(); + + if viewport_size.width >= content_size.width { + return None; } 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 percent_visible = viewport_size.width / content_size.width; + let percent_scrolled = scroll_offset.x / (content_size.width - viewport_size.width); - let length = (percent_visible * viewport.width()).ceil(); + let length = (percent_visible * viewport_size.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(); + ((viewport_size.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 x0 = scroll_offset.x + left_x_offset + bar_pad; + let y0 = scroll_offset.y + viewport_size.height - bar_width - bar_pad; - let x1 = self.scroll_offset.x + right_x_offset; - let y1 = self.scroll_offset.y + viewport.height() - bar_pad; + let x1 = scroll_offset.x + right_x_offset; + let y1 = scroll_offset.y + viewport_size.height - bar_pad; - Rect::new(x0, y0, x1, y1) + Some(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 { + pub fn draw_bars(&self, ctx: &mut PaintCtx, port: &Viewport, env: &Env) { + let scroll_offset = port.rect.origin().to_vec2(); + if self.opacity <= 0.0 { return; } - let brush = ctx.render_ctx.solid_brush( - env.get(theme::SCROLLBAR_COLOR) - .with_alpha(self.scrollbars.opacity), - ); + let brush = ctx + .render_ctx + .solid_brush(env.get(theme::SCROLLBAR_COLOR).with_alpha(self.opacity)); let border_brush = ctx.render_ctx.solid_brush( env.get(theme::SCROLLBAR_BORDER_COLOR) - .with_alpha(self.scrollbars.opacity), + .with_alpha(self.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); + if let Some(bounds) = self.calc_vertical_bar_bounds(port, env) { + let rect = (bounds - scroll_offset) + .inset(-edge_width / 2.0) + .to_rounded_rect(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); + if let Some(bounds) = self.calc_horizontal_bar_bounds(port, env) { + let rect = (bounds - scroll_offset) + .inset(-edge_width / 2.0) + .to_rounded_rect(radius); ctx.render_ctx.fill(rect, &brush); ctx.render_ctx.stroke(rect, &border_brush, edge_width); } @@ -288,11 +246,13 @@ impl ScrollComponent { /// 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 { + pub fn point_hits_vertical_bar(&self, port: &Viewport, pos: Point, env: &Env) -> bool { + let viewport_size = port.rect.size(); + let scroll_offset = port.rect.origin().to_vec2(); + + if let Some(mut bounds) = self.calc_vertical_bar_bounds(port, env) { // 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.x1 = scroll_offset.x + viewport_size.width; bounds.contains(pos) } else { false @@ -302,11 +262,13 @@ impl ScrollComponent { /// 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 { + pub fn point_hits_horizontal_bar(&self, port: &Viewport, pos: Point, env: &Env) -> bool { + let viewport_size = port.rect.size(); + let scroll_offset = port.rect.origin().to_vec2(); + + if let Some(mut bounds) = self.calc_horizontal_bar_bounds(port, env) { // 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.y1 = scroll_offset.y + viewport_size.height; bounds.contains(pos) } else { false @@ -316,38 +278,43 @@ impl ScrollComponent { /// 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 = size.to_rect(); + pub fn event(&mut self, port: &mut Viewport, ctx: &mut EventCtx, event: &Event, env: &Env) { + let viewport_size = port.rect.size(); + let content_size = port.content_size; + let scroll_offset = port.rect.origin().to_vec2(); 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) + let offset_pos = e.pos + scroll_offset; + self.point_hits_vertical_bar(port, offset_pos, env) + || self.point_hits_horizontal_bar(port, offset_pos, env) } _ => false, }; - if self.scrollbars.are_held() { + if self.are_bars_held() { // if we're dragging a scrollbar match event { Event::MouseMove(event) => { - match self.scrollbars.held { + match self.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 scale_y = viewport_size.height / content_size.height; + let bounds = self + .calc_vertical_bar_bounds(port, env) + .unwrap_or(Rect::ZERO); + let mouse_y = event.pos.y + scroll_offset.y; let delta = mouse_y - bounds.y0 - offset; - self.scroll(Vec2::new(0f64, (delta / scale_y).ceil()), size); + port.pan_by(Vec2::new(0f64, (delta / scale_y).ceil())); 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 scale_x = viewport_size.height / content_size.width; + let bounds = self + .calc_horizontal_bar_bounds(port, env) + .unwrap_or(Rect::ZERO); + let mouse_x = event.pos.x + scroll_offset.x; let delta = mouse_x - bounds.x0 - offset; - self.scroll(Vec2::new((delta / scale_x).ceil(), 0f64), size); + port.pan_by(Vec2::new((delta / scale_x).ceil(), 0f64)); ctx.set_handled(); } _ => (), @@ -355,11 +322,11 @@ impl ScrollComponent { ctx.request_paint(); } Event::MouseUp(_) => { - self.scrollbars.held = BarHeldState::None; + self.held = BarHeldState::None; ctx.set_active(false); if !scrollbar_is_hovered { - self.scrollbars.hovered = BarHoveredState::None; + self.hovered = BarHoveredState::None; self.reset_scrollbar_fade(|d| ctx.request_timer(d), env); } @@ -371,32 +338,34 @@ impl ScrollComponent { // 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; + let offset_pos = event.pos + scroll_offset; + if self.point_hits_vertical_bar(port, offset_pos, env) { + self.hovered = BarHoveredState::Vertical; + } else if self.point_hits_horizontal_bar(port, offset_pos, env) { + self.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 + self.opacity = env.get(theme::SCROLLBAR_MAX_OPACITY); + self.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; + let pos = event.pos + scroll_offset; - if self.point_hits_vertical_bar(viewport, pos, env) { + if self.point_hits_vertical_bar(port, pos, env) { ctx.set_active(true); - self.scrollbars.held = BarHeldState::Vertical( - pos.y - self.calc_vertical_bar_bounds(viewport, env).y0, + self.held = BarHeldState::Vertical( + // The bounds must be non-empty, because the point hits the scrollbar. + pos.y - self.calc_vertical_bar_bounds(port, env).unwrap().y0, ); - } else if self.point_hits_horizontal_bar(viewport, pos, env) { + } else if self.point_hits_horizontal_bar(port, pos, env) { ctx.set_active(true); - self.scrollbars.held = BarHeldState::Horizontal( - pos.x - self.calc_horizontal_bar_bounds(viewport, env).x0, + self.held = BarHeldState::Horizontal( + // The bounds must be non-empty, because the point hits the scrollbar. + pos.x - self.calc_horizontal_bar_bounds(port, env).unwrap().x0, ); } else { unreachable!(); @@ -412,38 +381,33 @@ impl ScrollComponent { match event { Event::MouseMove(_) => { // if we have just stopped hovering - if self.scrollbars.hovered.is_hovered() && !scrollbar_is_hovered { - self.scrollbars.hovered = BarHoveredState::None; + if self.hovered.is_hovered() && !scrollbar_is_hovered { + self.hovered = BarHoveredState::None; self.reset_scrollbar_fade(|d| ctx.request_timer(d), env); } } - Event::Timer(id) if *id == self.scrollbars.timer_id => { + Event::Timer(id) if *id == self.timer_id => { // Schedule scroll bars animation ctx.request_anim_frame(); - self.scrollbars.timer_id = TimerToken::INVALID; + self.timer_id = TimerToken::INVALID; ctx.set_handled(); } Event::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 { + if self.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 { + self.opacity -= diff; + if self.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 let Some(bounds) = self.calc_horizontal_bar_bounds(port, env) { + ctx.request_paint_rect(bounds - scroll_offset); } - if viewport.height() < self.content_size.height { - ctx.request_paint_rect( - self.calc_vertical_bar_bounds(viewport, env) - self.scroll_offset, - ); + if let Some(bounds) = self.calc_vertical_bar_bounds(port, env) { + ctx.request_paint_rect(bounds - scroll_offset); } } } @@ -454,10 +418,16 @@ impl ScrollComponent { } /// Applies mousewheel scrolling if the event has not already been handled - pub fn handle_scroll(&mut self, ctx: &mut EventCtx, event: &Event, env: &Env) { + pub fn handle_scroll( + &mut self, + port: &mut Viewport, + 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()) { + if port.pan_by(mouse.wheel_delta) { ctx.request_paint(); ctx.set_handled(); self.reset_scrollbar_fade(|d| ctx.request_timer(d), env); @@ -475,24 +445,4 @@ impl ScrollComponent { 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/clip_box.rs b/druid/src/widget/clip_box.rs new file mode 100644 index 0000000000..039c18ee35 --- /dev/null +++ b/druid/src/widget/clip_box.rs @@ -0,0 +1,264 @@ +// 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. + +use crate::kurbo::{Affine, Point, Rect, Size, Vec2}; +use crate::widget::prelude::*; +use crate::{Data, WidgetPod}; + +/// Represents the size and position of a rectangular "viewport" into a larger area. +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub struct Viewport { + /// The size of the area that we have a viewport into. + pub content_size: Size, + /// The view rectangle. + pub rect: Rect, +} + +impl Viewport { + /// Tries to find a position for the view rectangle that is contained in the content rectangle. + /// + /// If the supplied origin is good, returns it; if it isn't, we try to return the nearest + /// origin that would make the view rectangle contained in the content rectangle. (This will + /// fail if the content is smaller than the view, and we return `0.0` in each dimension where + /// the content is smaller.) + pub fn clamp_view_origin(&self, origin: Point) -> Point { + let x = origin + .x + .min(self.content_size.width - self.rect.width()) + .max(0.0); + let y = origin + .y + .min(self.content_size.height - self.rect.height()) + .max(0.0); + Point::new(x, y) + } + + /// Changes the viewport offset by `delta`, while trying to keep the view rectangle inside the + /// content rectangle. + /// + /// Returns true if the offset actually changed. Even if `delta` is non-zero, the offset might + /// not change. For example, if you try to move the viewport down but it is already at the + /// bottom of the child widget, then the offset will not change and this function will return + /// false. + pub fn pan_by(&mut self, delta: Vec2) -> bool { + self.pan_to(self.rect.origin() + delta) + } + + /// Sets the viewport origin to `pos`, while trying to keep the view rectangle inside the + /// content rectangle. + /// + /// Returns true if the position changed. Note that the valid values for the viewport origin + /// are constrained by the size of the child, and so the origin might not get set to exactly + /// `pos`. + pub fn pan_to(&mut self, origin: Point) -> bool { + let new_origin = self.clamp_view_origin(origin); + if (new_origin - self.rect.origin()).hypot2() > 1e-12 { + self.rect = self.rect.with_origin(new_origin); + true + } else { + false + } + } +} + +/// A widget exposing a rectangular view into its child, which can be used as a building block for +/// widgets that scroll their child. +pub struct ClipBox { + child: WidgetPod, + port: Viewport, + constrain_horizontal: bool, + constrain_vertical: bool, +} + +impl> ClipBox { + /// Creates a new `ClipBox` wrapping `child`. + pub fn new(child: W) -> Self { + ClipBox { + child: WidgetPod::new(child), + port: Default::default(), + constrain_horizontal: false, + constrain_vertical: false, + } + } + + /// Returns a reference to the child widget. + pub fn child(&self) -> &W { + self.child.widget() + } + + /// Returns a mutable reference to the child widget. + pub fn child_mut(&mut self) -> &mut W { + self.child.widget_mut() + } + + /// Returns a the viewport describing this `ClipBox`'s position. + pub fn viewport(&self) -> Viewport { + self.port + } + + /// Returns the size of the rectangular viewport into the child widget. + /// To get the position of the viewport, see [`viewport_origin`]. + /// + /// [`viewport_origin`]: struct.ClipBox.html#method.viewport_origin + pub fn viewport_size(&self) -> Size { + self.port.rect.size() + } + + /// Returns the size of the child widget. + pub fn content_size(&self) -> Size { + self.port.content_size + } + + /// Builder-style method for deciding whether to constrain the child horizontally. The default + /// is `false`. See [`constrain_vertical`] for more details. + /// + /// [`constrain_vertical`]: struct.ClipBox.html#constrain_vertical + pub fn constrain_horizontal(mut self, constrain: bool) -> Self { + self.constrain_horizontal = constrain; + self + } + + /// Determine whether to constrain the child horizontally. + /// + /// See [`constrain_vertical`] for more details. + /// + /// [`constrain_vertical`]: struct.ClipBox.html#constrain_vertical + pub fn set_constrain_horizontal(&mut self, constrain: bool) { + self.constrain_horizontal = constrain; + } + + /// Builder-style method for deciding whether to constrain the child vertically. The default + /// is `false`. + /// + /// This setting affects how a `ClipBox` lays out its child. + /// + /// - When it is `false` (the default), the child does receive any upper bound on its height: + /// the idea is that the child can be as tall as it wants, and the viewport will somehow get + /// moved around to see all of it. + /// - When it is `true`, the viewport's maximum height will be passed down as an upper bound on + /// the height of the child, and the viewport will set its own height to be the same as its + /// child's height. + pub fn constrain_vertical(mut self, constrain: bool) -> Self { + self.constrain_vertical = constrain; + self + } + + /// Determine whether to constrain the child vertically. + /// + /// See [`constrain_vertical`] for more details. + /// + /// [`constrain_vertical`]: struct.ClipBox.html#constrain_vertical + pub fn set_constrain_vertical(&mut self, constrain: bool) { + self.constrain_vertical = constrain; + } + + /// Changes the viewport offset by `delta`. + /// + /// Returns true if the offset actually changed. Even if `delta` is non-zero, the offset might + /// not change. For example, if you try to move the viewport down but it is already at the + /// bottom of the child widget, then the offset will not change and this function will return + /// false. + pub fn pan_by(&mut self, delta: Vec2) -> bool { + self.pan_to(self.viewport_origin() + delta) + } + + /// Sets the viewport origin to `pos`. + /// + /// Returns true if the position changed. Note that the valid values for the viewport origin + /// are constrained by the size of the child, and so the origin might not get set to exactly + /// `pos`. + pub fn pan_to(&mut self, origin: Point) -> bool { + if self.port.pan_to(origin) { + self.child + .set_viewport_offset(self.viewport_origin().to_vec2()); + true + } else { + false + } + } + + /// Returns the origin of the viewport rectangle. + pub fn viewport_origin(&self) -> Point { + self.port.rect.origin() + } + + /// Allows this `ClipBox`'s viewport rectangle to be modified. The provided callback function + /// can modify its argument, and when it is done then this `ClipBox` will be modified to have + /// the new viewport rectangle. + pub fn with_port(&mut self, f: F) { + f(&mut self.port); + self.child + .set_viewport_offset(self.viewport_origin().to_vec2()); + } +} + +impl> Widget for ClipBox { + fn event(&mut self, ctx: &mut EventCtx, ev: &Event, data: &mut T, env: &Env) { + let viewport = ctx.size().to_rect(); + let force_event = self.child.is_hot() || self.child.is_active(); + if let Some(child_event) = + ev.transform_scroll(self.viewport_origin().to_vec2(), viewport, force_event) + { + self.child.event(ctx, &child_event, data, env); + } + } + + fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, ev: &LifeCycle, data: &T, env: &Env) { + self.child.lifecycle(ctx, ev, data, env); + } + + fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) { + self.child.update(ctx, data, env); + } + + fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size { + bc.debug_check("ClipBox"); + + let max_child_width = if self.constrain_horizontal { + bc.max().width + } else { + f64::INFINITY + }; + let max_child_height = if self.constrain_vertical { + bc.max().height + } else { + f64::INFINITY + }; + let child_bc = + BoxConstraints::new(Size::ZERO, Size::new(max_child_width, max_child_height)); + + let content_size = self.child.layout(ctx, &child_bc, data, env); + self.port.content_size = content_size; + self.child + .set_layout_rect(ctx, data, env, content_size.to_rect()); + + self.port.rect = self.port.rect.with_size(bc.constrain(content_size)); + let new_offset = self.port.clamp_view_origin(self.viewport_origin()); + self.pan_to(new_offset); + self.viewport_size() + } + + fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) { + let viewport = ctx.size().to_rect(); + let offset = self.viewport_origin().to_vec2(); + ctx.with_save(|ctx| { + ctx.clip(viewport); + ctx.transform(Affine::translate(-offset)); + + let mut visible = ctx.region().clone(); + visible += offset; + ctx.with_child_ctx(visible, |ctx| self.child.paint_raw(ctx, data, env)); + }); + } +} diff --git a/druid/src/widget/mod.rs b/druid/src/widget/mod.rs index 7dab9e57bc..fb148a35af 100644 --- a/druid/src/widget/mod.rs +++ b/druid/src/widget/mod.rs @@ -18,6 +18,7 @@ mod align; mod button; mod checkbox; mod click; +mod clip_box; mod common; mod container; mod controller; @@ -58,6 +59,7 @@ pub use align::Align; pub use button::Button; pub use checkbox::Checkbox; pub use click::Click; +pub use clip_box::{ClipBox, Viewport}; pub use common::FillStrat; pub use container::Container; pub use controller::{Controller, ControllerHost}; diff --git a/druid/src/widget/scroll.rs b/druid/src/widget/scroll.rs index fbe302c971..568ee2cc35 100644 --- a/druid/src/widget/scroll.rs +++ b/druid/src/widget/scroll.rs @@ -14,17 +14,9 @@ //! A container that scrolls its contents. -use std::f64::INFINITY; - use crate::widget::prelude::*; -use crate::{scroll_component::*, Data, Vec2, WidgetPod}; - -#[derive(Debug, Clone)] -enum ScrollDirection { - Bidirectional, - Vertical, - Horizontal, -} +use crate::widget::ClipBox; +use crate::{scroll_component::*, Data, Vec2}; /// A container that scrolls its contents. /// @@ -39,9 +31,8 @@ enum ScrollDirection { /// [`vertical`]: struct.Scroll.html#method.vertical /// [`horizontal`]: struct.Scroll.html#method.horizontal pub struct Scroll { - child: WidgetPod, + clip: ClipBox, scroll_component: ScrollComponent, - direction: ScrollDirection, } impl> Scroll { @@ -52,105 +43,89 @@ impl> Scroll { /// [horizontal](#method.horizontal) methods to limit scrolling to a specific axis. pub fn new(child: W) -> Scroll { Scroll { - child: WidgetPod::new(child), + clip: ClipBox::new(child), scroll_component: ScrollComponent::new(), - direction: ScrollDirection::Bidirectional, } } /// Restrict scrolling to the vertical axis while locking child width. pub fn vertical(mut self) -> Self { - self.direction = ScrollDirection::Vertical; + self.clip.set_constrain_vertical(false); + self.clip.set_constrain_horizontal(true); self } /// Restrict scrolling to the horizontal axis while locking child height. pub fn horizontal(mut self) -> Self { - self.direction = ScrollDirection::Horizontal; + self.clip.set_constrain_vertical(true); + self.clip.set_constrain_horizontal(false); self } /// Returns a reference to the child widget. pub fn child(&self) -> &W { - self.child.widget() + self.clip.child() } /// Returns a mutable reference to the child widget. pub fn child_mut(&mut self) -> &mut W { - self.child.widget_mut() + self.clip.child_mut() } /// Returns the size of the child widget. pub fn child_size(&self) -> Size { - self.scroll_component.content_size + self.clip.content_size() } /// Returns the current scroll offset. pub fn offset(&self) -> Vec2 { - self.scroll_component.scroll_offset + self.clip.viewport_origin().to_vec2() } /// Scroll `delta` units. /// /// Returns `true` if the scroll offset has changed. - pub fn scroll(&mut self, delta: Vec2, layout_size: Size) -> bool { - let scrolled = self.scroll_component.scroll(delta, layout_size); - self.child.set_viewport_offset(self.offset()); - scrolled + pub fn scroll_by(&mut self, delta: Vec2) -> bool { + self.clip.pan_by(delta) } } impl> Widget for Scroll { fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { - self.scroll_component.event(ctx, event, env); + let scroll_component = &mut self.scroll_component; + self.clip.with_port(|port| { + scroll_component.event(port, ctx, event, env); + }); if !ctx.is_handled() { - let viewport = ctx.size().to_rect(); - - let force_event = self.child.is_hot() || self.child.is_active(); - 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); - }; + self.clip.event(ctx, event, data, env); } - self.scroll_component.handle_scroll(ctx, event, env); - // In order to ensure that invalidation regions are correctly propagated up the tree, - // we need to set the viewport offset on our child whenever we change our scroll offset. - self.child.set_viewport_offset(self.offset()); + self.clip.with_port(|port| { + scroll_component.handle_scroll(port, ctx, event, env); + }); } fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) { self.scroll_component.lifecycle(ctx, event, env); - self.child.lifecycle(ctx, event, data, env); + self.clip.lifecycle(ctx, event, data, env); } - fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) { - self.child.update(ctx, data, env); + fn update(&mut self, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) { + self.clip.update(ctx, old_data, data, env); } fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size { bc.debug_check("Scroll"); - 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); + let old_size = self.clip.viewport().rect.size(); + let child_size = self.clip.layout(ctx, &bc, data, env); log_size_warnings(child_size); - let old_size = self.scroll_component.content_size; - self.scroll_component.content_size = child_size; - self.child - .set_layout_rect(ctx, data, env, child_size.to_rect()); let self_size = bc.constrain(child_size); - let _ = self.scroll_component.scroll(Vec2::new(0.0, 0.0), self_size); - self.child.set_viewport_offset(self.offset()); - - if old_size != self.scroll_component.content_size { + // The new size might have made the current scroll offset invalid. This makes it valid + // again. + let _ = self.scroll_by(Vec2::ZERO); + if old_size != self_size { self.scroll_component .reset_scrollbar_fade(|d| ctx.request_timer(d), env); } @@ -159,10 +134,9 @@ impl> Widget for Scroll { } fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) { + self.clip.paint(ctx, data, env); self.scroll_component - .paint_content(ctx, env, |visible, ctx| { - ctx.with_child_ctx(visible, |ctx| self.child.paint_raw(ctx, data, env)); - }); + .draw_bars(ctx, &self.clip.viewport(), env); } }