diff --git a/masonry/src/widgets/virtual_scroll.rs b/masonry/src/widgets/virtual_scroll.rs index c6248d9e3..dc7c96dad 100644 --- a/masonry/src/widgets/virtual_scroll.rs +++ b/masonry/src/widgets/virtual_scroll.rs @@ -8,8 +8,8 @@ use std::{collections::HashMap, ops::Range}; use vello::kurbo::{Point, Size, Vec2}; use crate::core::{ - BoxConstraints, FromDynWidget, PointerEvent, PropertiesMut, PropertiesRef, ScrollDelta, - TextEvent, Widget, WidgetMut, WidgetPod, + BoxConstraints, EventCtx, FromDynWidget, PointerEvent, PropertiesMut, PropertiesRef, + ScrollDelta, TextEvent, Widget, WidgetMut, WidgetPod, keyboard::{Key, KeyState, NamedKey}, }; @@ -225,6 +225,9 @@ pub struct VirtualScroll { warned_not_dense: bool, /// We don't want to spam warnings about missing an action, but we want the user to be aware of it. missed_actions_count: u32, + + /// The amount to scroll by in each frame, intended for loose benchmarking. + scroll_per_frame: Option, } impl std::fmt::Debug for VirtualScroll { @@ -240,6 +243,7 @@ impl std::fmt::Debug for VirtualScroll { .field("mean_item_height", &self.mean_item_height) .field("anchor_height", &self.anchor_height) .field("warned_not_dense", &self.warned_not_dense) + .field("scroll_per_frame", &self.scroll_per_frame) .finish() } } @@ -266,6 +270,7 @@ impl VirtualScroll { mean_item_height: DEFAULT_MEAN_ITEM_HEIGHT, anchor_height: DEFAULT_MEAN_ITEM_HEIGHT, warned_not_dense: false, + scroll_per_frame: None, } } @@ -284,6 +289,15 @@ impl VirtualScroll { self } + /// Set the number of pixels to scroll in each frame. + /// + /// This is intended to be used only for benchmarking, as a more + /// comprehensive animation system is not yet in place. + pub fn with_scroll_per_frame(mut self, amount: Option) -> Self { + self.scroll_per_frame = amount; + self + } + fn validate_valid_range(&mut self) { if self.valid_range.end < self.valid_range.start { debug_panic!( @@ -412,6 +426,16 @@ impl VirtualScroll { this.ctx.request_layout(); } + /// Set the number of pixels to scroll in each frame. + /// + /// This is intended to be used only for benchmarking, as a more + /// comprehensive animation system is not yet in place. + /// Runtime equivalent of [`with_scroll_per_frame`](Self::with_scroll_per_frame). + pub fn set_scroll_per_frame(this: &mut WidgetMut<'_, Self>, amount: Option) { + this.widget.scroll_per_frame = amount; + this.ctx.request_anim_frame(); + } + /// Forcefully align the top of the item at `idx` with the top of the /// virtual scroll area. /// @@ -425,23 +449,31 @@ impl VirtualScroll { this.ctx.request_layout(); } - fn post_scroll(&mut self, ctx: &mut crate::core::EventCtx<'_>) { + /// Operations to be ran after scrolling in response to an event. + fn post_scroll_in_event(&mut self, ctx: &mut EventCtx) { + if self.post_scroll(ctx.size().height) { + ctx.request_layout(); + } else { + ctx.request_compose(); + } + } + + /// Operations to be ran after a scrolling. + /// + /// If this returns true, a layout is required; otherwise, a compose must be requested. + #[must_use] + fn post_scroll(&mut self, viewport_height: f64) -> bool { // We only lock scrolling if we're *exactly* at the end of the range, because // if the valid range has changed "during" an active scroll, we still want to handle // that scroll (specifically, in case it happens to scroll us back into the active // range "naturally") if self.anchor_index + 1 == self.valid_range.end { - self.cap_scroll_range_down(self.anchor_height, ctx.size().height); + self.cap_scroll_range_down(self.anchor_height, viewport_height); } if self.anchor_index == self.valid_range.start { self.cap_scroll_range_up(); } - if self.scroll_offset_from_anchor < 0. - || self.scroll_offset_from_anchor >= self.anchor_height - { - ctx.request_layout(); - } - ctx.request_compose(); + self.scroll_offset_from_anchor < 0. || self.scroll_offset_from_anchor >= self.anchor_height } /// Lock scrolling so that: @@ -471,7 +503,7 @@ const DEFAULT_MEAN_ITEM_HEIGHT: f64 = 60.; impl Widget for VirtualScroll { fn on_pointer_event( &mut self, - ctx: &mut crate::core::EventCtx, + ctx: &mut EventCtx, _props: &mut PropertiesMut<'_>, event: &PointerEvent, ) { @@ -483,7 +515,7 @@ impl Widget for VirtualScroll { _ => 0.0, }; self.scroll_offset_from_anchor += delta; - self.post_scroll(ctx); + self.post_scroll_in_event(ctx); } _ => (), } @@ -491,7 +523,7 @@ impl Widget for VirtualScroll { fn on_text_event( &mut self, - ctx: &mut crate::core::EventCtx, + ctx: &mut EventCtx, _props: &mut PropertiesMut<'_>, event: &TextEvent, ) { @@ -505,11 +537,11 @@ impl Widget for VirtualScroll { let delta = 20000.; if matches!(key_event.key, Key::Named(NamedKey::PageDown)) { self.scroll_offset_from_anchor += delta; - self.post_scroll(ctx); + self.post_scroll_in_event(ctx); } if matches!(key_event.key, Key::Named(NamedKey::PageUp)) { self.scroll_offset_from_anchor -= delta; - self.post_scroll(ctx); + self.post_scroll_in_event(ctx); } } } @@ -519,7 +551,7 @@ impl Widget for VirtualScroll { fn on_access_event( &mut self, - _ctx: &mut crate::core::EventCtx, + _ctx: &mut EventCtx, _props: &mut PropertiesMut<'_>, _event: &crate::core::AccessEvent, ) { @@ -535,12 +567,16 @@ impl Widget for VirtualScroll { fn update( &mut self, - _ctx: &mut crate::core::UpdateCtx, + ctx: &mut crate::core::UpdateCtx, _props: &mut PropertiesMut<'_>, event: &crate::core::Update, ) { match event { - crate::core::Update::WidgetAdded => {} + crate::core::Update::WidgetAdded => { + if self.scroll_per_frame.is_some() { + ctx.request_anim_frame(); + } + } crate::core::Update::DisabledChanged(_) => {} crate::core::Update::StashedChanged(_) => {} crate::core::Update::RequestPanToChild(_rect) => {} // TODO, @@ -554,12 +590,37 @@ impl Widget for VirtualScroll { } } + fn on_anim_frame( + &mut self, + ctx: &mut crate::core::UpdateCtx, + _props: &mut PropertiesMut<'_>, + _interval: u64, + ) { + if let Some(scroll_per_frame) = self.scroll_per_frame { + // tracing::info!( + // "Virtual Scroll Frame time: {:.1?}", + // Duration::from_nanos(_interval) + // ); + ctx.request_anim_frame(); + // Note: This is the reason that `post_scroll` doesn't just accept an `UpdateCtx`. + // Ideally, there'd be some shared, standard way to request updates. + self.scroll_offset_from_anchor += scroll_per_frame; + // TODO: This is self.post_scroll, but with this `UpdateCtx` instead of + // with `EventCtx`. This is a really poor thing for abstraction + if self.post_scroll(ctx.size().height) { + ctx.request_layout(); + } else { + ctx.request_compose(); + } + } + } + fn layout( &mut self, ctx: &mut crate::core::LayoutCtx, _props: &mut PropertiesMut<'_>, - bc: &crate::core::BoxConstraints, - ) -> vello::kurbo::Size { + bc: &BoxConstraints, + ) -> Size { let viewport_size = bc.max(); ctx.set_clip_path(viewport_size.to_rect()); let child_bc = BoxConstraints::new( @@ -705,11 +766,11 @@ impl Widget for VirtualScroll { } // Load a page and a half above the screen - let cutoff_up = viewport_size.height * 1.5; + let cutoff_up = viewport_size.height * 0.1; // Load a page and a half below the screen (note that this cutoff "includes" the screen) // We also need to allow scrolling *at least* to the top of the current anchor; therefore, we load items sufficiently // that scrolling the bottom of the anchor to the top of the screen, we still have the desired margin - let cutoff_down = viewport_size.height * 2.5 + self.anchor_height; + let cutoff_down = viewport_size.height * 1.1 + self.anchor_height; let mut item_crossing_top = None; let mut item_crossing_bottom = self.active_range.start; @@ -823,7 +884,7 @@ impl Widget for VirtualScroll { fn compose(&mut self, ctx: &mut crate::core::ComposeCtx) { let translation = Vec2 { x: 0., - y: -self.scroll_offset_from_anchor, + y: self.scroll_offset_from_anchor, }; for idx in self.active_range.clone() { if let Some(child) = self.items.get_mut(&idx) { @@ -912,9 +973,12 @@ impl Widget for VirtualScroll { /// } /// ``` /// as an iterator -#[allow( - dead_code, - reason = "Plan to expose this publicly in `VirtualScrollAction`, keep its tests around" +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "Plan to expose this publicly in `VirtualScrollAction`, keep its tests around" + ) )] fn opt_iter_difference( old_range: &Range, diff --git a/masonry_winit/Cargo.toml b/masonry_winit/Cargo.toml index ba14fb7e6..434d6f8de 100644 --- a/masonry_winit/Cargo.toml +++ b/masonry_winit/Cargo.toml @@ -17,6 +17,11 @@ targets = [] # rustdoc-scrape-examples tracking issue https://github.com/rust-lang/rust/issues/88791 cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] +# This makes the examples discoverable to (e.g.) Android GPU inspector without needing to provide the full name manually. +# Do not use when releasing a production app. +[package.metadata.android.application] +debuggable = true + [features] default = [] # Enables tracing using tracy if the default Masonry tracing is used. @@ -38,6 +43,9 @@ wgpu-profiler = { optional = true, version = "0.22.0", default-features = false [target.'cfg(target_arch = "wasm32")'.dependencies] web-time.workspace = true +[target.'cfg(target_os = "android")'.dev-dependencies] +winit = { features = ["android-native-activity"], workspace = true } + [dev-dependencies] parley.workspace = true smallvec.workspace = true @@ -58,3 +66,14 @@ name = "calc_masonry" # This actually enables scraping for all examples, not just this one. # However it is possible to set doc-scrape-examples to false for other specific examples. doc-scrape-examples = true + +[[example]] +name = "virtual_stresstest" + +# Also add to ANDROID_TARGETS in .github/ci.yml if adding a new Android example +[[example]] +# A custom example target which uses the same `mason.rs` file but for android +name = "virtual_stresstest_android" +path = "examples/virtual_stresstest.rs" +# cdylib is required for cargo-apk +crate-type = ["cdylib"] diff --git a/masonry_winit/examples/virtual_stresstest.rs b/masonry_winit/examples/virtual_stresstest.rs new file mode 100644 index 000000000..9a074603d --- /dev/null +++ b/masonry_winit/examples/virtual_stresstest.rs @@ -0,0 +1,137 @@ +// Copyright 2025 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +//! A demonstration of the [`VirtualScroll`] widget, producing an automatically scrolling list of inputs. + +// On Windows platform, don't show a console when opening the app. +#![windows_subsystem = "windows"] + +use masonry_winit::app::{AppDriver, DriverCtx, EventLoop, EventLoopBuilder}; +use masonry_winit::core::{Action, StyleProperty, WidgetId, WidgetPod}; +use masonry_winit::dpi::LogicalSize; +use masonry_winit::widgets::{Label, RootWidget, VirtualScroll, VirtualScrollAction}; + +use parley::FontFamily; +use winit::error::EventLoopError; +use winit::window::Window; + +/// The widget kind contained in the scroll area. This is a type parameter (`W`) of [`VirtualScroll`], +/// although note that [`dyn Widget`](masonry::core::Widget) can also be used for dynamic children kinds. +/// +/// We use a type alias for this, as when we downcast to the `VirtualScroll`, we need to be sure to +/// always use the same type for `W`. +type ScrollContents = Label; + +/// Function to create the virtual scroll area. +fn init() -> VirtualScroll { + // We start our scrolling with the top of the screen at item 0 + VirtualScroll::new(0) + .with_valid_range(0..i64::MAX) + .with_scroll_per_frame(Some(599.0)) +} + +const FONT_SIZE: f32 = 8_f32; + +struct Driver { + scroll_id: WidgetId, +} + +impl AppDriver for Driver { + fn on_action(&mut self, ctx: &mut DriverCtx<'_>, widget_id: WidgetId, action: Action) { + if widget_id == self.scroll_id { + if let Action::Other(action) = action { + // The VirtualScroll widget will send us a VirtualScrollAction every time it wants different + // items to be loaded or unloaded. + let action = action.downcast::().unwrap(); + ctx.render_root().edit_root_widget(|mut root| { + let mut root = root.downcast::(); + let mut scroll = RootWidget::child_mut(&mut root); + let mut scroll = scroll.downcast::>(); + // We need to tell the `VirtualScroll` which request this is associated with + // This is so that the controller knows which actions have been handled. + VirtualScroll::will_handle_action(&mut scroll, &action); + for idx in action.old_active.clone() { + if !action.target.contains(&idx) { + // If we had different work to do in response to the item being unloaded + // (for example, saving some related data?), then we'd do it here + VirtualScroll::remove_child(&mut scroll, idx); + } + } + for idx in action.target.clone() { + if !action.old_active.contains(&idx) { + let label = calc_label(idx); + + VirtualScroll::add_child( + &mut scroll, + idx, + WidgetPod::new( + Label::new(label) + .with_hint(true) + .with_style(StyleProperty::FontSize(FONT_SIZE)) + .with_style(StyleProperty::LineHeight(1.0)) + .with_style(FontFamily::Named(std::borrow::Cow::Borrowed( + "Roboto", + ))), + ), + ); + } + } + }); + } + } else { + tracing::warn!("Got unexpected action {action:?}"); + } + } +} + +// Works around rustfmt failing if this is inlined. +fn calc_label(idx: i64) -> String { + format!( + "{idx}: Rust UI and Jetpack Compose Virtual Scrolling Comparison - this text at 8pt just fits the width of my Google Pixel 6." + ) +} + +fn run(event_loop: EventLoopBuilder) -> Result<(), EventLoopError> { + let main_widget = WidgetPod::new(init()); + let driver = Driver { + scroll_id: main_widget.id(), + }; + let window_size = LogicalSize::new(800.0, 500.0); + let window_attributes = Window::default_attributes() + .with_title("Infinite FizzBuzz") + .with_resizable(true) + .with_min_inner_size(window_size); + + masonry_winit::app::run( + event_loop, + window_attributes, + RootWidget::from_pod(main_widget.erased()), + driver, + ) +} + +// Boilerplate code: Identical across all applications which support Android + +#[expect(clippy::allow_attributes, reason = "No way to specify the condition")] +#[allow(dead_code, reason = "False positive: needed in not-_android version")] +// This is treated as dead code by the Android version of the example, but is actually live +// This hackery is required because Cargo doesn't care to support this use case, of one +// example which works across Android and desktop +fn main() -> Result<(), EventLoopError> { + run(EventLoop::with_user_event()) +} +#[cfg(target_os = "android")] +// Safety: We are following `android_activity`'s docs here +#[expect( + unsafe_code, + reason = "We believe that there are no other declarations using this name in the compiled objects here" +)] +#[unsafe(no_mangle)] +fn android_main(app: winit::platform::android::activity::AndroidApp) { + use winit::platform::android::EventLoopBuilderExtAndroid; + + let mut event_loop = EventLoop::with_user_event(); + event_loop.with_android_app(app); + + run(event_loop).expect("Can create app"); +} diff --git a/masonry_winit/src/event_loop_runner.rs b/masonry_winit/src/event_loop_runner.rs index 92357f0b3..4c7437187 100644 --- a/masonry_winit/src/event_loop_runner.rs +++ b/masonry_winit/src/event_loop_runner.rs @@ -449,7 +449,7 @@ impl MasonryState<'_> { { let _render_poll_span = tracing::info_span!("Waiting for GPU to finish rendering").entered(); - device.poll(wgpu::Maintain::Wait); + device.poll(wgpu::Maintain::Poll); } #[cfg(feature = "tracy")] @@ -509,6 +509,8 @@ impl MasonryState<'_> { WinitWindowEvent::RedrawRequested => { let _span = info_span!("redraw"); self.render_root.handle_window_event(WindowEvent::AnimFrame); + // Handle any signals caused by the animation frame + self.handle_signals(event_loop, app_driver); let (scene, tree_update) = self.render_root.redraw(); self.render(scene); let WindowState::Rendering {