Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added masonry/screenshots/portal_scrollbar_autohidden.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions masonry/src/properties/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod gap;
mod line_breaking;
mod object_fit;
mod placeholder_color;
mod portal;
mod progress_bar;
mod scroll_bar;
mod selection;
Expand All @@ -25,6 +26,7 @@ pub use gap::*;
pub use line_breaking::*;
pub use object_fit::*;
pub use placeholder_color::*;
pub use portal::*;
pub use progress_bar::*;
pub use scroll_bar::*;
pub use selection::*;
Expand Down
15 changes: 15 additions & 0 deletions masonry/src/properties/portal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright 2026 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0

use crate::core::Property;

/// Declares if the scroll bar collapses when not being hovered.
#[derive(Default, Clone, Copy, Debug, PartialEq)]
pub struct AutoHideScrollBar(pub bool);

impl Property for AutoHideScrollBar {
fn static_default() -> &'static Self {
static DEFAULT: AutoHideScrollBar = AutoHideScrollBar(false);
&DEFAULT
}
}
103 changes: 95 additions & 8 deletions masonry/src/widgets/portal.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
// Copyright 2020 the Xilem Authors and the Druid Authors
// SPDX-License-Identifier: Apache-2.0

use std::ops::Range;
use std::{any::TypeId, ops::Range};

use accesskit::{Node, Role};
use tracing::{Span, trace_span};

use crate::core::{
AccessCtx, AccessEvent, ChildrenIds, ComposeCtx, EventCtx, FromDynWidget, LayoutCtx,
MeasureCtx, NewWidget, NoAction, PaintCtx, PointerEvent, PointerScrollEvent, PropertiesMut,
PropertiesRef, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId, WidgetMut,
WidgetPod,
PropertiesRef, Property, RegisterCtx, TextEvent, Update, UpdateCtx, UsesProperty, Widget,
WidgetId, WidgetMut, WidgetPod,
};
use crate::dpi::{LogicalPosition, PhysicalPosition};
use crate::imaging::Painter;
use crate::kurbo::{Axis, Point, Rect, Size, Vec2};
use crate::layout::{LayoutSize, LenDef, LenReq, SizeDef};
use crate::properties::AutoHideScrollBar;
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
Expand Down Expand Up @@ -54,6 +58,7 @@ pub struct Portal<W: Widget + ?Sized> {
scrollbar_horizontal_visible: bool,
scrollbar_vertical: WidgetPod<ScrollBar>,
scrollbar_vertical_visible: bool,
nanos_since_last_pointer_move: Option<u64>,
}

// --- MARK: BUILDERS
Expand All @@ -72,6 +77,7 @@ impl<W: Widget + ?Sized> Portal<W> {
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,
}
}

Expand Down Expand Up @@ -399,16 +405,21 @@ impl<W: Widget + FromDynWidget + ?Sized> Portal<W> {
}
}

impl<W: Widget> UsesProperty<AutoHideScrollBar> for Portal<W> {}

// --- MARK: IMPL WIDGET
impl<W: Widget + FromDynWidget + ?Sized> Widget for Portal<W> {
type Action = NoAction;

fn on_pointer_event(
&mut self,
ctx: &mut EventCtx<'_>,
_props: &mut PropertiesMut<'_>,
props: &mut PropertiesMut<'_>,
event: &PointerEvent,
) {
let cache = ctx.property_cache();
let auto_hide_scroll_bar = props.get::<AutoHideScrollBar>(cache).0;

let portal_size = ctx.content_box_size();
let content_size = self.content_size;

Expand Down Expand Up @@ -441,6 +452,19 @@ impl<W: Widget + FromDynWidget + ?Sized> Widget for Portal<W> {
ctx.set_handled();
};
}
PointerEvent::Move(_) if auto_hide_scroll_bar => {
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();
}
_ => (),
}

Expand Down Expand Up @@ -620,6 +644,36 @@ impl<W: Widget + FromDynWidget + ?Sized> Widget for Portal<W> {
}
}

fn on_anim_frame(
&mut self,
ctx: &mut UpdateCtx<'_>,
props: &mut PropertiesMut<'_>,
interval: u64,
) {
let cache = ctx.property_cache();
let auto_hide_scroll_bar = props.get::<AutoHideScrollBar>(cache).0;

if auto_hide_scroll_bar
&& 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);
Expand Down Expand Up @@ -656,6 +710,12 @@ impl<W: Widget + FromDynWidget + ?Sized> Widget for Portal<W> {
}
}

fn property_changed(&mut self, ctx: &mut UpdateCtx<'_>, property_type: TypeId) {
if AutoHideScrollBar::matches(property_type) {
ctx.request_layout();
}
}

fn measure(
&mut self,
ctx: &mut MeasureCtx<'_>,
Expand Down Expand Up @@ -688,7 +748,19 @@ impl<W: Widget + FromDynWidget + ?Sized> Widget for Portal<W> {
}
}

fn layout(&mut self, ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, size: Size) {
fn layout(&mut self, ctx: &mut LayoutCtx<'_>, props: &PropertiesRef<'_>, size: Size) {
// TODO: should it be possible to do this in `property_changed`?
let cache = ctx.property_cache();
let auto_hide_scroll_bar = props.get::<AutoHideScrollBar>(cache).0;

if self.nanos_since_last_pointer_move.is_none() {
let rest_opacity = if auto_hide_scroll_bar { 0. } else { 1. };
let f =
move |bar: WidgetMut<'_, ScrollBar>| bar.widget.opacity.move_to(rest_opacity, 0.);
ctx.mutate_child_later(&mut self.scrollbar_horizontal, f);
ctx.mutate_child_later(&mut self.scrollbar_vertical, f);
}

let auto_size = SizeDef::new(
match self.constrain_horizontal {
true => LenDef::FitContent(size.width),
Expand Down Expand Up @@ -925,12 +997,9 @@ mod tests {

let mut harness = TestHarness::create_with_size(test_property_set(), widget, (400, 400));

assert_render_snapshot!(harness, "portal_button_list_no_scroll");

harness.edit_root_widget(|mut portal| {
Portal::set_viewport_pos(&mut portal, Point::new(0.0, 130.0))
});

assert_render_snapshot!(harness, "portal_button_list_scrolled");

harness.scroll_into_view(harness.get_widget(button_3).id());
Expand Down Expand Up @@ -960,6 +1029,24 @@ mod tests {
assert_render_snapshot!(harness, "portal_scrolled_button_into_view");
}

#[test]
fn autohidden_scrollbar() {
let widget = Portal::new(Flex::column().with_fixed_spacer(1000.px()).prepare())
.prepare()
.with_props(AutoHideScrollBar(true));

let mut default_props = test_property_set();
default_props.insert::<ScrollBar, _>(crate::properties::Collapsible(true));

let mut harness = TestHarness::create_with_size(default_props, widget, (200, 200));

assert_render_snapshot!(harness, "portal_scrollbar_autohidden");

harness.mouse_move((150., 50.));
harness.animate_ms(300);
assert_render_snapshot!(harness, "portal_scrollbar_shown_on_mouse_jiggle");
}

#[test]
fn portal_accessibility_node_exposes_scroll() {
let portal_tag = WidgetTag::named("portal");
Expand Down
43 changes: 41 additions & 2 deletions masonry/src/widgets/scroll_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ use crate::core::{
PaintCtx, PointerButtonEvent, PointerEvent, PointerUpdate, PropertiesMut, PropertiesRef,
Property, RegisterCtx, TextEvent, UpdateCtx, UsesProperty, 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::properties::Collapsible;
use crate::theme;
use crate::widgets::AnimatedF32;

// TODO
// - Fade scrollbars? Find out how Linux/macOS/Windows do it
// - Rename cursor to oval/rect/bar/grabber/grabbybar
// - Rename progress to something more descriptive
// - Document names
Expand Down Expand Up @@ -48,6 +48,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<f64>,
}

Expand All @@ -64,6 +65,7 @@ impl ScrollBar {
moved: false,
portal_size,
content_size,
opacity: AnimatedF32::stable(1.),
grab_anchor: None,
}
}
Expand Down Expand Up @@ -341,6 +343,30 @@ impl Widget for ScrollBar {
}
}

fn on_anim_frame(
&mut self,
ctx: &mut UpdateCtx<'_>,
props: &mut PropertiesMut<'_>,
interval: u64,
) {
let cache = ctx.property_cache();
let collapsible = props.get::<Collapsible>(cache).0;

if !collapsible {
self.opacity.move_to(1., 0.);
ctx.request_paint_only();
return;
}

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 property_changed(&mut self, ctx: &mut UpdateCtx<'_>, property_type: TypeId) {
Expand Down Expand Up @@ -386,6 +412,14 @@ impl Widget for ScrollBar {
let cache = ctx.property_cache();
let collapsible = props.get::<Collapsible>(cache).0;

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;
Expand Down Expand Up @@ -413,6 +447,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 {
Expand Down
10 changes: 10 additions & 0 deletions masonry/src/widgets/variable_label.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading