diff --git a/masonry/screenshots/portal_button_list_mouse_jiggle.png b/masonry/screenshots/portal_button_list_mouse_jiggle.png new file mode 100644 index 000000000..c06bc7ec8 Binary files /dev/null and b/masonry/screenshots/portal_button_list_mouse_jiggle.png differ diff --git a/masonry/screenshots/portal_button_list_no_scroll.png b/masonry/screenshots/portal_button_list_no_scroll.png index 75c491540..d0fb07191 100644 Binary files a/masonry/screenshots/portal_button_list_no_scroll.png and b/masonry/screenshots/portal_button_list_no_scroll.png differ diff --git a/masonry/screenshots/portal_button_list_scroll_to_item_13.png b/masonry/screenshots/portal_button_list_scroll_to_item_13.png index bbe3932f7..6319d828c 100644 Binary files a/masonry/screenshots/portal_button_list_scroll_to_item_13.png and b/masonry/screenshots/portal_button_list_scroll_to_item_13.png differ diff --git a/masonry/screenshots/portal_button_list_scroll_to_item_3.png b/masonry/screenshots/portal_button_list_scroll_to_item_3.png index fd73e8bf1..7b09e0d85 100644 Binary files a/masonry/screenshots/portal_button_list_scroll_to_item_3.png and b/masonry/screenshots/portal_button_list_scroll_to_item_3.png differ diff --git a/masonry/screenshots/portal_button_list_scrolled.png b/masonry/screenshots/portal_button_list_scrolled.png index fd73e8bf1..7b09e0d85 100644 Binary files a/masonry/screenshots/portal_button_list_scrolled.png and b/masonry/screenshots/portal_button_list_scrolled.png differ diff --git a/masonry/screenshots/portal_scrolled_button_into_view.png b/masonry/screenshots/portal_scrolled_button_into_view.png index da95ffb49..b91bc7720 100644 Binary files a/masonry/screenshots/portal_scrolled_button_into_view.png and b/masonry/screenshots/portal_scrolled_button_into_view.png differ diff --git a/masonry/screenshots/scrollbar_bottom.png b/masonry/screenshots/scrollbar_bottom.png index bf2b4b76f..adf42f412 100644 Binary files a/masonry/screenshots/scrollbar_bottom.png and b/masonry/screenshots/scrollbar_bottom.png differ diff --git a/masonry/screenshots/scrollbar_default.png b/masonry/screenshots/scrollbar_default.png index c23e07dce..fe787c286 100644 Binary files a/masonry/screenshots/scrollbar_default.png and b/masonry/screenshots/scrollbar_default.png differ diff --git a/masonry/screenshots/scrollbar_down.png b/masonry/screenshots/scrollbar_down.png index 6cc8a4e12..b4bc6e978 100644 Binary files a/masonry/screenshots/scrollbar_down.png and b/masonry/screenshots/scrollbar_down.png differ diff --git a/masonry/screenshots/scrollbar_horizontal.png b/masonry/screenshots/scrollbar_horizontal.png index 9a2daebc4..6a37404a3 100644 Binary files a/masonry/screenshots/scrollbar_horizontal.png and b/masonry/screenshots/scrollbar_horizontal.png differ diff --git a/masonry/screenshots/scrollbar_horizontal_hovered.png b/masonry/screenshots/scrollbar_horizontal_hovered.png new file mode 100644 index 000000000..9a2daebc4 Binary files /dev/null and b/masonry/screenshots/scrollbar_horizontal_hovered.png differ diff --git a/masonry/screenshots/scrollbar_hovered.png b/masonry/screenshots/scrollbar_hovered.png new file mode 100644 index 000000000..c23e07dce Binary files /dev/null and b/masonry/screenshots/scrollbar_hovered.png differ diff --git a/masonry/src/widgets/portal.rs b/masonry/src/widgets/portal.rs index 9a894d90a..0518472af 100644 --- a/masonry/src/widgets/portal.rs +++ b/masonry/src/widgets/portal.rs @@ -18,6 +18,9 @@ use crate::kurbo::{Axis, Point, Rect, Size, Vec2}; use crate::layout::{LayoutSize, LenDef, LenReq, SizeDef}; use crate::widgets::ScrollBar; +// TODO: make this configurable or move to theme +const SCROLLBAR_ANIM_OVER_MILLIS: f32 = 300.; + // TODO - refactor - see https://github.com/linebender/xilem/issues/366 // TODO - rename "Portal" to "ScrollPortal"? // TODO - Document which cases need request_layout, request_compose and request_render @@ -54,6 +57,7 @@ pub struct Portal { scrollbar_horizontal_visible: bool, scrollbar_vertical: WidgetPod, scrollbar_vertical_visible: bool, + nanos_since_last_pointer_move: Option, } // --- MARK: BUILDERS @@ -72,6 +76,7 @@ impl Portal { scrollbar_horizontal_visible: false, scrollbar_vertical: WidgetPod::new(ScrollBar::new(Axis::Vertical, 0.0, 0.0)), scrollbar_vertical_visible: false, + nanos_since_last_pointer_move: None, } } @@ -441,6 +446,19 @@ impl Widget for Portal { ctx.set_handled(); }; } + PointerEvent::Move(_) => { + let f = |mut bar: WidgetMut<'_, ScrollBar>| { + if bar.widget.opacity.value() == 0. { + bar.widget.opacity.move_to(1., SCROLLBAR_ANIM_OVER_MILLIS); + bar.ctx.request_anim_frame(); + } + }; + ctx.mutate_child_later(&mut self.scrollbar_horizontal, f); + ctx.mutate_child_later(&mut self.scrollbar_vertical, f); + + self.nanos_since_last_pointer_move = Some(0); + ctx.request_anim_frame(); + } _ => (), } @@ -620,6 +638,31 @@ impl Widget for Portal { } } + fn on_anim_frame( + &mut self, + ctx: &mut UpdateCtx<'_>, + _props: &mut PropertiesMut<'_>, + interval: u64, + ) { + if let Some(mut since_last_move) = self.nanos_since_last_pointer_move.take() { + since_last_move += interval; + + // TODO: make this configurable or move to theme + const VISIBILITY_TIMEOUT: u64 = 400_000_000; + if since_last_move >= VISIBILITY_TIMEOUT { + let f = |mut bar: WidgetMut<'_, ScrollBar>| { + bar.widget.opacity.move_to(0., SCROLLBAR_ANIM_OVER_MILLIS); + bar.ctx.request_anim_frame(); + }; + ctx.mutate_child_later(&mut self.scrollbar_horizontal, f); + ctx.mutate_child_later(&mut self.scrollbar_vertical, f); + } else { + self.nanos_since_last_pointer_move = Some(since_last_move); + ctx.request_anim_frame(); + } + } + } + fn register_children(&mut self, ctx: &mut RegisterCtx<'_>) { ctx.register_child(&mut self.child); ctx.register_child(&mut self.scrollbar_horizontal); @@ -927,6 +970,10 @@ mod tests { assert_render_snapshot!(harness, "portal_button_list_no_scroll"); + harness.mouse_move((200., 200.)); + harness.animate_ms(300); + assert_render_snapshot!(harness, "portal_button_list_mouse_jiggle"); + harness.edit_root_widget(|mut portal| { Portal::set_viewport_pos(&mut portal, Point::new(0.0, 130.0)) }); diff --git a/masonry/src/widgets/scroll_bar.rs b/masonry/src/widgets/scroll_bar.rs index f6b3f86a3..4305c90b4 100644 --- a/masonry/src/widgets/scroll_bar.rs +++ b/masonry/src/widgets/scroll_bar.rs @@ -9,12 +9,13 @@ use crate::core::keyboard::{Key, KeyState, NamedKey}; use crate::core::{ AccessCtx, AccessEvent, AllowRawMut, ChildrenIds, EventCtx, LayoutCtx, MeasureCtx, NoAction, PaintCtx, PointerButtonEvent, PointerEvent, PointerUpdate, PropertiesMut, PropertiesRef, - RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId, WidgetMut, + RegisterCtx, TextEvent, UpdateCtx, Widget, WidgetId, WidgetMut, }; -use crate::imaging::Painter; +use crate::imaging::{Composite, GroupRef, Painter}; use crate::kurbo::{Axis, Point, Rect, Size, Stroke}; use crate::layout::LenReq; use crate::theme; +use crate::widgets::AnimatedF32; // TODO // - Fade scrollbars? Find out how Linux/macOS/Windows do it @@ -45,6 +46,7 @@ pub struct ScrollBar { pub(crate) moved: bool, pub(crate) portal_size: f64, pub(crate) content_size: f64, + pub(crate) opacity: AnimatedF32, grab_anchor: Option, } @@ -61,6 +63,7 @@ impl ScrollBar { moved: false, portal_size, content_size, + opacity: AnimatedF32::stable(0.), grab_anchor: None, } } @@ -336,16 +339,23 @@ impl Widget for ScrollBar { } } - fn register_children(&mut self, _ctx: &mut RegisterCtx<'_>) {} - - fn update( + fn on_anim_frame( &mut self, - _ctx: &mut UpdateCtx<'_>, + ctx: &mut UpdateCtx<'_>, _props: &mut PropertiesMut<'_>, - _event: &Update, + interval: u64, ) { + let millis = (interval as f64 * 1e-6) as f32; + let result = self.opacity.advance(millis); + ctx.request_paint_only(); + + if !result.is_completed() { + ctx.request_anim_frame(); + } } + fn register_children(&mut self, _ctx: &mut RegisterCtx<'_>) {} + fn measure( &mut self, _ctx: &mut MeasureCtx<'_>, @@ -380,16 +390,31 @@ impl Widget for ScrollBar { _props: &PropertiesRef<'_>, painter: &mut Painter<'_>, ) { + if self.opacity.value() != 1. { + painter.push_fill_clip(ctx.border_box()); + painter.push_group(GroupRef::new().with_composite(Composite::new( + crate::peniko::BlendMode::default(), + self.opacity.value(), + ))); + } + let radius = theme::SCROLLBAR_RADIUS; let edge_width = theme::SCROLLBAR_EDGE_WIDTH; let cursor_padding = theme::SCROLLBAR_PAD; let cursor_min_length = theme::SCROLLBAR_MIN_SIZE; + let scrollbar_width = theme::SCROLLBAR_WIDTH; let size = ctx.content_box_size(); - let (inset_x, inset_y) = self.axis.pack_xy(0.0, cursor_padding); + let inset_start = if ctx.is_hovered() || self.grab_anchor.is_some() { + cursor_padding + } else { + cursor_padding + scrollbar_width / 2. + }; + let (inset_x0, inset_y0) = self.axis.pack_xy(0.0, inset_start); + let (inset_x1, inset_y1) = self.axis.pack_xy(0.0, cursor_padding); let cursor_rect = self .cursor_rect(size, cursor_min_length) - .inset((-inset_x, -inset_y)) + .inset((-inset_x0, -inset_y0, -inset_x1, -inset_y1)) .to_rounded_rect(radius); painter.fill(cursor_rect, theme::SCROLLBAR_COLOR).draw(); @@ -400,6 +425,11 @@ impl Widget for ScrollBar { theme::SCROLLBAR_BORDER_COLOR, ) .draw(); + + if self.opacity.value() != 1. { + painter.pop_group(); + painter.pop_clip(); + } } fn accessibility_role(&self) -> Role { @@ -473,14 +503,21 @@ mod tests { #[test] fn simple_scrollbar() { - let widget = NewWidget::new(ScrollBar::new(Axis::Vertical, 200.0, 600.0)) - .with_props(Dimensions::FIT); + let mut widget = ScrollBar::new(Axis::Vertical, 200.0, 600.0); + widget.opacity.move_to(1., 0.); + let widget = NewWidget::new(widget).with_props(Dimensions::FIT); let mut harness = TestHarness::create_with_size(test_property_set(), widget, (50, 200)); let scrollbar_id = harness.root_id(); assert_render_snapshot!(harness, "scrollbar_default"); + harness.mouse_move((5., 50.)); + assert_render_snapshot!(harness, "scrollbar_hovered"); + + harness.mouse_move((5., 50.)); + assert_render_snapshot!(harness, "scrollbar_hovered"); + assert!(harness.pop_action_erased().is_none()); harness.mouse_click_on(scrollbar_id, None); @@ -500,14 +537,18 @@ mod tests { #[test] fn horizontal_scrollbar() { - let widget = NewWidget::new(ScrollBar::new(Axis::Horizontal, 200.0, 600.0)) - .with_props(Dimensions::FIT); + let mut widget = ScrollBar::new(Axis::Horizontal, 200.0, 600.0); + widget.opacity.move_to(1., 0.); + let widget = NewWidget::new(widget).with_props(Dimensions::FIT); let mut harness = TestHarness::create_with_size(test_property_set(), widget, (200, 50)); let scrollbar_id = harness.root_id(); assert_render_snapshot!(harness, "scrollbar_horizontal"); + harness.mouse_move((50., 5.)); + assert_render_snapshot!(harness, "scrollbar_horizontal_hovered"); + assert!(harness.pop_action_erased().is_none()); harness.mouse_click_on(scrollbar_id, None); diff --git a/masonry/src/widgets/variable_label.rs b/masonry/src/widgets/variable_label.rs index 610f1157d..b9b94a25f 100644 --- a/masonry/src/widgets/variable_label.rs +++ b/masonry/src/widgets/variable_label.rs @@ -96,6 +96,16 @@ impl AnimatedF32 { AnimationStatus::Ongoing } } + + /// Returns the target value. + pub fn target(&self) -> f32 { + self.target + } + + /// Returns the current value. + pub fn value(&self) -> f32 { + self.value + } } /// The status an animation can be in.