Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/elements/clipped_scrollable.rs
StratoSDK / crates / strato-ui-core / src / elements / clipped_scrollable.rs
1use parking_lot::Mutex;
2use std::sync::Arc;
3 
4use pathfinder_geometry::{rect::RectF, vector::Vector2F};
5 
6use crate::scene::ClipBounds;
7use crate::units::{IntoPixels, Pixels};
8use crate::{
9 event::DispatchedEvent, AfterLayoutContext, AppContext, Element, EventContext, LayoutContext,
10 PaintContext, SizeConstraint,
11};
12 
13use super::{
14 new_scrollable::util::scroll_delta_for_axis, Axis, F32Ext, Fill, Point, ScrollData,
15 ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Selection, Vector2FExt,
16};
17 
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub enum ScrollToPositionMode {
20 /// Scroll the minimum amount to bring as much of the element into view
21 /// as possible.
22 FullyIntoView,
23 /// Show as much of the element as possible, prioritising the top (leading)
24 /// edge. Behaves like [`FullyIntoView`] when the element fits within the
25 /// viewport, but when the element is taller than the viewport it aligns
26 /// the element's top with the viewport's top.
27 TopIntoView,
28}
29 
30#[derive(Clone)]
31pub struct ScrollTarget {
32 pub position_id: String,
33 pub mode: ScrollToPositionMode,
34}
35 
36#[derive(Clone, Default)]
37pub struct ClippedScrollData {
38 scroll_start_px: Pixels,
39 pub(super) scroll_to_position: Option<ScrollTarget>,
40 selection_scroll_anchor: Option<ClippedSelectionScrollAnchor>,
41}
42 
43#[derive(Clone, Copy)]
44struct ClippedSelectionScrollAnchor {
45 selection: Selection,
46 scroll_start_px: Pixels,
47}
48 
49impl ClippedSelectionScrollAnchor {
50 fn matches(&self, selection: Selection) -> bool {
51 self.selection == selection
52 }
53}
54 
55#[derive(Clone, Default)]
56pub struct ClippedScrollStateHandle {
57 /// The scroll state for the [`Scrollable`] that wraps the [`ClippedScrollable`].
58 /// This is included as part of this handle for ergonomics; otherwise, each
59 /// [`ClippedScrollable`] consumer would need to separately maintain a
60 /// [`ScrollStateHandle`] and a [`ClippedScrollStateHandle`].
61 scrollable_data: ScrollStateHandle,
62 pub(super) clipped_scroll_data: Arc<Mutex<ClippedScrollData>>,
63}
64 
65impl ClippedScrollStateHandle {
66 pub fn new() -> Self {
67 Self::default()
68 }
69 
70 pub fn scroll_to(&self, start: Pixels) {
71 self.clipped_scroll_data.lock().scroll_start_px = start.max(Pixels::zero());
72 }
73 
74 pub fn scroll_start(&self) -> Pixels {
75 self.clipped_scroll_data.lock().scroll_start_px
76 }
77 
78 pub fn scroll_by(&self, delta: Pixels) {
79 self.scroll_to(self.scroll_start() + delta);
80 }
81 
82 /// Records `selection` as the current selection scroll anchor (if not already recorded) and
83 /// returns a copy of it whose coordinates have been compensated for any scroll that has
84 /// happened since the anchor was first recorded.
85 ///
86 /// This is **not** a pure transformation — it mutates the handle's internal
87 /// `selection_scroll_anchor` state as a side effect:
88 /// - Passing `None` clears the anchor and returns `None`.
89 /// - Passing a `Selection` that does not match the currently recorded anchor replaces the
90 /// anchor with a new one capturing `selection` at the current scroll position and returns
91 /// `selection` unchanged.
92 /// - Passing a `Selection` that matches the currently recorded anchor leaves the anchor in
93 /// place and returns `selection` shifted by the delta between the current scroll position
94 /// and the anchor's recorded scroll position.
95 ///
96 /// The net effect is that as long as callers feed the same selection in across repaints, the
97 /// returned selection tracks the underlying content even while the surface is being scrolled.
98 pub(crate) fn anchor_and_adjust_selection_for_scroll(
99 &self,
100 selection: Option<Selection>,
101 axis: Axis,
102 ) -> Option<Selection> {
103 let Some(selection) = selection else {
104 self.clipped_scroll_data.lock().selection_scroll_anchor = None;
105 return None;
106 };
107 
108 let mut scroll_data = self.clipped_scroll_data.lock();
109 let scroll_start_px = scroll_data.scroll_start_px;
110 let anchor_scroll_start = match scroll_data.selection_scroll_anchor {
111 Some(anchor) if anchor.matches(selection) => anchor.scroll_start_px,
112 _ => {
113 scroll_data.selection_scroll_anchor = Some(ClippedSelectionScrollAnchor {
114 selection,
115 scroll_start_px,
116 });
117 scroll_start_px
118 }
119 };
120 
121 let scroll_delta = (scroll_start_px - anchor_scroll_start).as_f32().along(axis);
122 Some(Selection {
123 start: selection.start - scroll_delta,
124 end: selection.end - scroll_delta,
125 is_rect: selection.is_rect,
126 })
127 }
128 
129 pub(crate) fn clear_selection_scroll_anchor(&self) {
130 self.clipped_scroll_data.lock().selection_scroll_anchor = None;
131 }
132 
133 pub fn set_start(&self, position: f32) {
134 self.scrollable_data.lock().unwrap().started = Some(position);
135 }
136 
137 pub fn reset_start(&self) {
138 self.scrollable_data.lock().unwrap().started = None;
139 }
140 
141 pub fn start(&self) -> Option<f32> {
142 self.scrollable_data.lock().unwrap().started
143 }
144 
145 /// Scrolls the bounds of the element described by `target` into view.
146 /// This is a no-op if the position is already in view or is not within
147 /// the bounds of the `ClippedScrollable`.
148 pub fn scroll_to_position(&self, target: ScrollTarget) {
149 self.clipped_scroll_data.lock().scroll_to_position = Some(target);
150 }
151 
152 pub fn hovered(&self) -> bool {
153 self.scrollable_data.lock().unwrap().hovered
154 }
155 
156 pub fn set_hovered(&self, hovered: bool) {
157 self.scrollable_data.lock().unwrap().hovered = hovered;
158 }
159 
160 pub(in crate::elements) fn set_child_hovered(&self, hovered: bool) {
161 self.scrollable_data
162 .lock()
163 .expect("lock should be held")
164 .child_hovered = hovered;
165 }
166 
167 pub(in crate::elements) fn child_hovered(&self) -> bool {
168 self.scrollable_data
169 .lock()
170 .expect("lock should be held")
171 .child_hovered
172 }
173}
174 
175/// Implements a generic scrollable interface around an arbitrary child element
176/// tree using clipping to control what's rendered.
177/// Note that this scroll path is by its nature slow because in order to
178/// use it we need to fully lay out the child tree, determine its size
179/// paint it, and then clip it.
180/// It's much better to have a child that explicitly implements ScrollableElement
181/// where possible, but it's fine to use this when that's not possible.
182///
183/// TODO: there is currently a bug with constraint-passing when nesting
184/// [`ClippedScrollable`]s (e.g. to get clipped scrolling in both directions).
185pub struct ClippedScrollable {
186 axis: Axis,
187 child: Box<dyn Element>,
188 state: ClippedScrollStateHandle,
189 size: Option<Vector2F>,
190 origin: Option<Point>,
191 /// When true, the child constraint's min on the main axis is set to the
192 /// incoming constraint's max on the main axis (if finite). This allows
193 /// the child (e.g. an [`Align`]) to know the visible height and center
194 /// content within the scrollable area.
195 fill_min_main_axis: bool,
196}
197 
198impl ClippedScrollable {
199 fn new(axis: Axis, child: Box<dyn Element>, state: ClippedScrollStateHandle) -> Self {
200 Self {
201 axis,
202 child,
203 state,
204 size: None,
205 origin: None,
206 fill_min_main_axis: false,
207 }
208 }
209 
210 /// Constructs a new [`Scrollable`] element that scrolls vertically,
211 /// using a [`ClippedScrollable`] as the concrete [`ScrollableElement`].
212 pub fn vertical(
213 state: ClippedScrollStateHandle,
214 child: Box<dyn Element>,
215 scrollbar_size: ScrollbarWidth,
216 nonactive_scrollbar_thumb_background: Fill,
217 active_scrollbar_thumb_background: Fill,
218 scrollbar_track_background: Fill,
219 ) -> Scrollable {
220 Scrollable::vertical(
221 state.scrollable_data.clone(),
222 ClippedScrollable::new(Axis::Vertical, child, state).finish_scrollable(),
223 scrollbar_size,
224 nonactive_scrollbar_thumb_background,
225 active_scrollbar_thumb_background,
226 scrollbar_track_background,
227 )
228 }
229 
230 /// Like [`vertical`](Self::vertical), but passes the visible height as
231 /// the child's min-height constraint. This allows the child (e.g. wrapped
232 /// in [`Align`]) to center its content within the visible area while
233 /// still being scrollable when content overflows.
234 pub fn vertical_centered(
235 state: ClippedScrollStateHandle,
236 child: Box<dyn Element>,
237 scrollbar_size: ScrollbarWidth,
238 nonactive_scrollbar_thumb_background: Fill,
239 active_scrollbar_thumb_background: Fill,
240 scrollbar_track_background: Fill,
241 ) -> Scrollable {
242 let mut cs = ClippedScrollable::new(Axis::Vertical, child, state.clone());
243 cs.fill_min_main_axis = true;
244 Scrollable::vertical(
245 state.scrollable_data.clone(),
246 cs.finish_scrollable(),
247 scrollbar_size,
248 nonactive_scrollbar_thumb_background,
249 active_scrollbar_thumb_background,
250 scrollbar_track_background,
251 )
252 }
253 
254 /// Constructs a new [`Scrollable`] element that scrolls horizontally,
255 /// using a [`ClippedScrollable`] as the concrete [`ScrollableElement`].
256 pub fn horizontal(
257 state: ClippedScrollStateHandle,
258 child: Box<dyn Element>,
259 scrollbar_size: ScrollbarWidth,
260 nonactive_scrollbar_thumb_background: Fill,
261 active_scrollbar_thumb_background: Fill,
262 scrollbar_track_background: Fill,
263 ) -> Scrollable {
264 Scrollable::horizontal(
265 state.scrollable_data.clone(),
266 ClippedScrollable::new(Axis::Horizontal, child, state).finish_scrollable(),
267 scrollbar_size,
268 nonactive_scrollbar_thumb_background,
269 active_scrollbar_thumb_background,
270 scrollbar_track_background,
271 )
272 }
273 
274 fn paint_internal(
275 &mut self,
276 origin: Vector2F,
277 ctx: &mut PaintContext,
278 app: &AppContext,
279 size: Vector2F,
280 ) {
281 ctx.scene
282 .start_layer(ClipBounds::BoundedBy(RectF::new(origin, size)));
283 self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
284 
285 // It's possible that children elements of this ClippedScrollabe are not a part
286 // of a stack and therefore won't have their position's flushed to the position cache.
287 // The start() and end() calls here ensure that the positions are saved so we can scroll
288 // to the position of a child.
289 ctx.position_cache.start();
290 self.child.paint(
291 origin - self.state.scroll_start().as_f32().along(self.axis),
292 ctx,
293 app,
294 );
295 ctx.position_cache.end();
296 
297 ctx.scene.stop_layer();
298 }
299 
300 /// Scrolls the provided `position_id` into view, if it exists, and paints the object.
301 fn scroll_to_position_and_paint(
302 &mut self,
303 origin: Vector2F,
304 ctx: &mut PaintContext,
305 app: &AppContext,
306 size: Vector2F,
307 position_id: String,
308 mode: ScrollToPositionMode,
309 ) {
310 // The relevant position can be a child of the `ClippedScrollable` so we need to first paint the
311 // `ClippedScrollable` before we can determine the position, scroll the position into view, and paint the element as intended.
312 // In order to prevent the first paint from having side effects, we clone the scene
313 // before we invoke the first paint.
314 //
315 // Cloning the scene is cheap! On a bundled app, the following operations take < 10 microseconds:
316 // - 100 warp tabs open
317 // - Set line height to 0.2 and fill the block list and make a large number of glyphs
318 // - Expanded all folders in warp drive and opened command palette (to check non-view ported elements)
319 // - Render many images (as it turns out the scene only holds a rect and Arc, not the image content itself)
320 // We want to avoid excesively cloning the scene though, because calling clone on the scene on multiple
321 // `ClippedScrollable` elements in the paint code path caused this latency to be an order of magnitude
322 // higher (300 microseconds).
323 let cached_scene = ctx.scene.clone();
324 self.paint_internal(origin, ctx, app, size);
325 
326 if let Some(position_bounds) = ctx.position_cache.get_position(position_id) {
327 let child_bounds = self.child.bounds().expect("bounds on child should be set");
328 // It doesn't make sense to scroll to a position that is unrelated to the `ClippedScrollable`
329 // so no-op if it is not within the bounds of the child element.
330 if child_bounds.contains_rect(position_bounds) {
331 let scroll_top = self.state.scroll_start();
332 let viewport_bounds = self.bounds().expect("bounds should be set");
333 
334 let scroll_delta =
335 scroll_delta_for_axis(self.axis, viewport_bounds, position_bounds, mode);
336 
337 self.state
338 .scroll_to(scroll_top + scroll_delta.into_pixels());
339 
340 *ctx.scene = cached_scene;
341 self.paint_internal(origin, ctx, app, size);
342 }
343 }
344 }
345}
346 
347impl Element for ClippedScrollable {
348 fn layout(
349 &mut self,
350 constraint: SizeConstraint,
351 ctx: &mut LayoutContext,
352 app: &AppContext,
353 ) -> Vector2F {
354 // The child should only be constrained horizontally, and allowed to grow
355 // as tall as it desires. The height of the ClippedScrollable will still be
356 // constrained by the incoming constraints.
357 let mut child_constraint = SizeConstraint::tight_on_cross_axis(self.axis, constraint);
358 
359 // When fill_min_main_axis is set, pass the visible size along the main
360 // axis as the child's min constraint so centering elements (e.g. Align)
361 // can fill and center their content within the visible area.
362 if self.fill_min_main_axis {
363 let visible = constraint.max.along(self.axis);
364 if visible.is_finite() {
365 match self.axis {
366 Axis::Vertical => child_constraint.min.set_y(visible),
367 Axis::Horizontal => child_constraint.min.set_x(visible),
368 }
369 }
370 }
371 
372 let child_size = self.child.layout(child_constraint, ctx, app);
373 let size = constraint.apply(child_size);
374 self.size = Some(size);
375 size
376 }
377 
378 fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
379 let size = self.size().expect("size should be set by paint time");
380 ctx.scene
381 .draw_rect_with_hit_recording(RectF::new(origin, size));
382 
383 let scroll_target = self
384 .state
385 .clipped_scroll_data
386 .lock()
387 .scroll_to_position
388 .take();
389 if let Some(ScrollTarget { position_id, mode }) = scroll_target {
390 self.scroll_to_position_and_paint(origin, ctx, app, size, position_id, mode);
391 } else {
392 self.paint_internal(origin, ctx, app, size);
393 }
394 }
395 
396 fn size(&self) -> Option<Vector2F> {
397 self.size
398 }
399 
400 fn origin(&self) -> Option<Point> {
401 self.origin
402 }
403 
404 fn dispatch_event(
405 &mut self,
406 event: &DispatchedEvent,
407 ctx: &mut EventContext,
408 app: &AppContext,
409 ) -> bool {
410 self.child.dispatch_event(event, ctx, app)
411 }
412 
413 fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
414 self.child.after_layout(ctx, app);
415 // Make sure that the new layout doesn't put the scroll bar in an invalid
416 // location.
417 if let Some(scroll_data) = self.scroll_data(app) {
418 let max_scroll_top =
419 (scroll_data.total_size - scroll_data.visible_px).max(Pixels::zero());
420 let scroll_top = scroll_data.scroll_start;
421 if scroll_top > max_scroll_top {
422 self.state.scroll_to(max_scroll_top);
423 }
424 }
425 }
426}
427 
428impl ScrollableElement for ClippedScrollable {
429 fn scroll_data(&self, _app: &AppContext) -> Option<ScrollData> {
430 Some(ScrollData {
431 scroll_start: self.state.scroll_start(),
432 visible_px: (self.size()?.along(self.axis)).into_pixels(),
433 total_size: self.child.size()?.along(self.axis).into_pixels(),
434 })
435 }
436 
437 fn scroll(&mut self, delta: Pixels, ctx: &mut EventContext) {
438 let scroll_start = self.state.scroll_start();
439 let child_size: Pixels = self
440 .child
441 .size()
442 .expect("child should be laid out before scrolling")
443 .along(self.axis)
444 .into_pixels();
445 
446 let clipped_size = self
447 .size
448 .expect("should be laid out before scrolling")
449 .along(self.axis)
450 .into_pixels();
451 if child_size > clipped_size {
452 let new_scroll_start = (scroll_start - delta)
453 .max(Pixels::zero())
454 .min(child_size - clipped_size);
455 if (scroll_start - new_scroll_start).as_f32().abs() > f32::EPSILON {
456 self.state.scroll_to(new_scroll_start);
457 ctx.notify();
458 }
459 }
460 }
461 
462 fn should_handle_scroll_wheel(&self) -> bool {
463 true
464 }
465}
466 
467#[cfg(test)]
468#[path = "clipped_scrollable_test.rs"]
469mod tests;
470