tessera_ui_basic_components/
scrollable.rs

1//! Scrollable container component for Tessera UI.
2//!
3//! This module provides a scrollable container that enables vertical and/or horizontal scrolling
4//! for overflowing content within a UI layout. It is designed as a fundamental building block
5//! for creating areas where content may exceed the visible bounds, such as lists, panels, or
6//! custom scroll regions.
7//!
8//! Features include configurable scroll directions, smooth animated scrolling, and stateful
9//! management of scroll position and focus. The scrollable area is highly customizable via
10//! [`ScrollableArgs`], and integrates with the Tessera UI state management system.
11//!
12//! Typical use cases include scrollable lists, text areas, image galleries, or any UI region
13//! where content may not fit within the allocated space.
14//!
15//! # Example
16//! See [`scrollable()`] for usage details and code samples.
17mod scrollbar;
18use std::{sync::Arc, time::Instant};
19
20use derive_builder::Builder;
21use parking_lot::RwLock;
22use tessera_ui::{
23    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, Px, PxPosition,
24    tessera,
25};
26
27use crate::{
28    alignment::Alignment,
29    boxed::{BoxedArgsBuilder, boxed},
30    pos_misc::is_position_in_component,
31    scrollable::scrollbar::{ScrollBarArgs, ScrollBarState, scrollbar_h, scrollbar_v},
32};
33
34#[derive(Debug, Builder, Clone)]
35pub struct ScrollableArgs {
36    /// The desired width behavior of the scrollable area
37    /// Defaults to Wrap { min: None, max: None }.
38    #[builder(default = "tessera_ui::DimensionValue::Wrap { min: None, max: None }")]
39    pub width: tessera_ui::DimensionValue,
40    /// The desired height behavior of the scrollable area.
41    /// Defaults to Wrap { min: None, max: None }.
42    #[builder(default = "tessera_ui::DimensionValue::Wrap { min: None, max: None }")]
43    pub height: tessera_ui::DimensionValue,
44    /// Is vertical scrollable?
45    /// Defaults to `true` since most scrollable areas are vertical.
46    #[builder(default = "true")]
47    pub vertical: bool,
48    /// Is horizontal scrollable?
49    /// Defaults to `false` since most scrollable areas are not horizontal.
50    #[builder(default = "false")]
51    pub horizontal: bool,
52    /// Scroll smoothing factor (0.0 = instant, 1.0 = very smooth).
53    /// Defaults to 0.05 for very responsive but still smooth scrolling.
54    #[builder(default = "0.05")]
55    pub scroll_smoothing: f32,
56    /// The behavior of the scrollbar visibility.
57    #[builder(default = "ScrollBarBehavior::AlwaysVisible")]
58    pub scrollbar_behavior: ScrollBarBehavior,
59    /// The color of the scrollbar track.
60    #[builder(default = "Color::new(0.0, 0.0, 0.0, 0.1)")]
61    pub scrollbar_track_color: Color,
62    /// The color of the scrollbar thumb.
63    #[builder(default = "Color::new(0.0, 0.0, 0.0, 0.3)")]
64    pub scrollbar_thumb_color: Color,
65    /// The color of the scrollbar thumb when hovered.
66    #[builder(default = "Color::new(0.0, 0.0, 0.0, 0.5)")]
67    pub scrollbar_thumb_hover_color: Color,
68    /// The layout of the scrollbar relative to the content.
69    #[builder(default = "ScrollBarLayout::Alongside")]
70    pub scrollbar_layout: ScrollBarLayout,
71}
72
73/// Defines the behavior of the scrollbar visibility.
74#[derive(Debug, Clone)]
75pub enum ScrollBarBehavior {
76    /// The scrollbar is always visible.
77    AlwaysVisible,
78    /// The scrollbar is only visible when scrolling.
79    AutoHide,
80    /// No scrollbar at all.
81    Hidden,
82}
83
84/// Defines the layout of the scrollbar relative to the scrollable content.
85#[derive(Debug, Clone)]
86pub enum ScrollBarLayout {
87    /// The scrollbar is placed alongside the content (takes up space in the layout).
88    Alongside,
89    /// The scrollbar is overlaid on top of the content (doesn't take up space).
90    Overlay,
91}
92
93impl Default for ScrollableArgs {
94    fn default() -> Self {
95        ScrollableArgsBuilder::default().build().unwrap()
96    }
97}
98
99/// Holds the state for a `scrollable` component, managing scroll position and interaction.
100///
101/// It tracks the current and target scroll positions, the size of the scrollable content, and focus state.
102///
103/// The scroll position is smoothly interpolated over time to create a fluid scrolling effect.
104#[derive(Default)]
105pub struct ScrollableState {
106    /// The inner state containing scroll position, size
107    inner: Arc<RwLock<ScrollableStateInner>>,
108    /// The state for vertical scrollbar
109    scrollbar_state_v: Arc<RwLock<ScrollBarState>>,
110    /// The state for horizontal scrollbar
111    scrollbar_state_h: Arc<RwLock<ScrollBarState>>,
112}
113
114impl ScrollableState {
115    /// Creates a new `ScrollableState` with default values.
116    pub fn new() -> Self {
117        Self::default()
118    }
119}
120
121#[derive(Clone, Debug)]
122struct ScrollableStateInner {
123    /// The current position of the child component (for rendering)
124    child_position: PxPosition,
125    /// The target position of the child component (scrolling destination)
126    target_position: PxPosition,
127    /// The child component size
128    child_size: ComputedData,
129    /// The visible area size
130    visible_size: ComputedData,
131    /// Last frame time for delta time calculation
132    last_frame_time: Option<Instant>,
133}
134
135impl Default for ScrollableStateInner {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl ScrollableStateInner {
142    /// Creates a new ScrollableState with default values.
143    pub fn new() -> Self {
144        Self {
145            child_position: PxPosition::ZERO,
146            target_position: PxPosition::ZERO,
147            child_size: ComputedData::ZERO,
148            visible_size: ComputedData::ZERO,
149            last_frame_time: None,
150        }
151    }
152
153    /// Updates the scroll position based on time-based interpolation
154    /// Returns true if the position changed (needs redraw)
155    fn update_scroll_position(&mut self, smoothing: f32) -> bool {
156        let current_time = Instant::now();
157
158        // Calculate delta time
159        let delta_time = if let Some(last_time) = self.last_frame_time {
160            current_time.duration_since(last_time).as_secs_f32()
161        } else {
162            0.016 // Assume 60fps for first frame
163        };
164
165        self.last_frame_time = Some(current_time);
166
167        // Calculate the difference between target and current position
168        let diff_x = self.target_position.x.to_f32() - self.child_position.x.to_f32();
169        let diff_y = self.target_position.y.to_f32() - self.child_position.y.to_f32();
170
171        // If we're close enough to target, snap to it
172        if diff_x.abs() < 1.0 && diff_y.abs() < 1.0 {
173            if self.child_position != self.target_position {
174                self.child_position = self.target_position;
175                return true;
176            }
177            return false;
178        }
179
180        // Use simple velocity-based movement for consistent behavior
181        // Higher smoothing = slower movement
182        let mut movement_factor = (1.0 - smoothing) * delta_time * 60.0;
183
184        // CRITICAL FIX: Clamp the movement factor to a maximum of 1.0.
185        // A factor greater than 1.0 causes the interpolation to overshoot the target,
186        // leading to oscillations that grow exponentially, causing the value explosion
187        // and overflow panic seen in the logs. Clamping ensures stability by
188        // preventing the position from moving past the target in a single frame.
189        if movement_factor > 1.0 {
190            movement_factor = 1.0;
191        }
192        let old_position = self.child_position;
193
194        self.child_position = PxPosition {
195            x: Px::saturating_from_f32(self.child_position.x.to_f32() + diff_x * movement_factor),
196            y: Px::saturating_from_f32(self.child_position.y.to_f32() + diff_y * movement_factor),
197        };
198
199        // Return true if position changed significantly
200        old_position != self.child_position
201    }
202
203    /// Sets a new target position for scrolling
204    fn set_target_position(&mut self, target: PxPosition) {
205        self.target_position = target;
206    }
207}
208
209/// A container that makes its content scrollable when it exceeds the container's size.
210///
211/// The `scrollable` component is a fundamental building block for creating areas with
212/// content that may not fit within the allocated space. It supports vertical and/or
213/// horizontal scrolling, which can be configured via `ScrollableArgs`.
214///
215/// The component offers two scrollbar layout options:
216/// - `Alongside`: Scrollbars take up space in the layout alongside the content
217/// - `Overlay`: Scrollbars are overlaid on top of the content without taking up space
218///
219/// State management is handled by `ScrollableState`, which must be provided to persist
220/// the scroll position across recompositions. The scrolling behavior is animated with
221/// a configurable smoothing factor for a better user experience.
222///
223/// # Example
224///
225/// ```
226/// use std::sync::Arc;
227/// use parking_lot::RwLock;
228/// use tessera_ui::{DimensionValue, Dp};
229/// use tessera_ui_basic_components::{
230///     column::{column, ColumnArgs},
231///     scrollable::{scrollable, ScrollableArgs, ScrollableState, ScrollBarLayout},
232///     text::text,
233/// };
234///
235/// // In a real app, you would manage the state.
236/// let scrollable_state = Arc::new(ScrollableState::new());
237///
238/// // Example with alongside scrollbars (default)
239/// scrollable(
240///     ScrollableArgs {
241///         height: DimensionValue::Fixed(Dp(100.0).into()),
242///         scrollbar_layout: ScrollBarLayout::Alongside,
243///         ..Default::default()
244///     },
245///     scrollable_state.clone(),
246///     || {
247///         column(ColumnArgs::default(), |scope| {
248///             scope.child(|| text("Item 1".to_string()));
249///             scope.child(|| text("Item 2".to_string()));
250///             scope.child(|| text("Item 3".to_string()));
251///             scope.child(|| text("Item 4".to_string()));
252///             scope.child(|| text("Item 5".to_string()));
253///             scope.child(|| text("Item 6".to_string()));
254///             scope.child(|| text("Item 7".to_string()));
255///             scope.child(|| text("Item 8".to_string()));
256///             scope.child(|| text("Item 9".to_string()));
257///             scope.child(|| text("Item 10".to_string()));
258///         });
259///     },
260/// );
261///
262/// // Example with overlay scrollbars
263/// scrollable(
264///     ScrollableArgs {
265///         height: DimensionValue::Fixed(Dp(100.0).into()),
266///         scrollbar_layout: ScrollBarLayout::Overlay,
267///         ..Default::default()
268///     },
269///     scrollable_state,
270///     || {
271///         column(ColumnArgs::default(), |scope| {
272///             scope.child(|| text("Item 1".to_string()));
273///             scope.child(|| text("Item 2".to_string()));
274///             scope.child(|| text("Item 3".to_string()));
275///             scope.child(|| text("Item 4".to_string()));
276///             scope.child(|| text("Item 5".to_string()));
277///             scope.child(|| text("Item 6".to_string()));
278///             scope.child(|| text("Item 7".to_string()));
279///             scope.child(|| text("Item 8".to_string()));
280///             scope.child(|| text("Item 9".to_string()));
281///             scope.child(|| text("Item 10".to_string()));
282///         });
283///     },
284/// );
285/// ```
286///
287/// # Panics
288///
289/// This component will panic if it does not have exactly one child.
290///
291/// # Arguments
292///
293/// * `args`: An instance of `ScrollableArgs` or `ScrollableArgsBuilder` to configure the
294///   scrollable area's behavior, such as dimensions and scroll directions.
295/// * `state`: An `Arc<RwLock<ScrollableState>>` to hold and manage the component's state.
296/// * `child`: A closure that defines the content to be placed inside the scrollable container.
297///   This closure is executed once to build the component tree.
298#[tessera]
299pub fn scrollable(
300    args: impl Into<ScrollableArgs>,
301    state: Arc<ScrollableState>,
302    child: impl FnOnce() + Send + Sync + 'static,
303) {
304    let args: ScrollableArgs = args.into();
305
306    // Create separate ScrollBarArgs for vertical and horizontal scrollbars
307    let scrollbar_args_v = ScrollBarArgs {
308        total: state.inner.read().child_size.height,
309        visible: state.inner.read().visible_size.height,
310        offset: state.inner.read().child_position.y,
311        thickness: Dp(8.0), // Default scrollbar thickness
312        state: state.inner.clone(),
313        scrollbar_behavior: args.scrollbar_behavior.clone(),
314        track_color: args.scrollbar_track_color,
315        thumb_color: args.scrollbar_thumb_color,
316        thumb_hover_color: args.scrollbar_thumb_hover_color,
317    };
318
319    let scrollbar_args_h = ScrollBarArgs {
320        total: state.inner.read().child_size.width,
321        visible: state.inner.read().visible_size.width,
322        offset: state.inner.read().child_position.x,
323        thickness: Dp(8.0), // Default scrollbar thickness
324        state: state.inner.clone(),
325        scrollbar_behavior: args.scrollbar_behavior.clone(),
326        track_color: args.scrollbar_track_color,
327        thumb_color: args.scrollbar_thumb_color,
328        thumb_hover_color: args.scrollbar_thumb_hover_color,
329    };
330
331    match args.scrollbar_layout {
332        ScrollBarLayout::Alongside => {
333            scrollable_with_alongside_scrollbar(
334                state,
335                args,
336                scrollbar_args_v,
337                scrollbar_args_h,
338                child,
339            );
340        }
341        ScrollBarLayout::Overlay => {
342            scrollable_with_overlay_scrollbar(
343                state,
344                args,
345                scrollbar_args_v,
346                scrollbar_args_h,
347                child,
348            );
349        }
350    }
351}
352
353#[tessera]
354fn scrollable_with_alongside_scrollbar(
355    state: Arc<ScrollableState>,
356    args: ScrollableArgs,
357    scrollbar_args_v: ScrollBarArgs,
358    scrollbar_args_h: ScrollBarArgs,
359    child: impl FnOnce() + Send + Sync + 'static,
360) {
361    scrollable_inner(
362        args.clone(),
363        state.inner.clone(),
364        state.scrollbar_state_v.clone(),
365        state.scrollbar_state_h.clone(),
366        child,
367    );
368
369    if args.vertical {
370        scrollbar_v(scrollbar_args_v, state.scrollbar_state_v.clone());
371    }
372
373    if args.horizontal {
374        scrollbar_h(scrollbar_args_h, state.scrollbar_state_h.clone());
375    }
376
377    measure(Box::new(move |input| {
378        // Record the final size
379        let mut final_size = ComputedData::ZERO;
380        // Merge arg constraints with parent constraints
381        let self_constraint = Constraint {
382            width: args.width,
383            height: args.height,
384        };
385        let mut content_contraint = self_constraint.merge(input.parent_constraint);
386        // measure the scrollbar
387        if args.vertical {
388            let scrollbar_node_id = input.children_ids[1];
389            let size = input.measure_child(scrollbar_node_id, input.parent_constraint)?;
390            // substract the scrollbar size from the content constraint
391            content_contraint.width -= size.width;
392            // update the size
393            final_size.width += size.width;
394        }
395        if args.horizontal {
396            let scrollbar_node_id = if args.vertical {
397                input.children_ids[2]
398            } else {
399                input.children_ids[1]
400            };
401            let size = input.measure_child(scrollbar_node_id, input.parent_constraint)?;
402            content_contraint.height -= size.height;
403            // update the size
404            final_size.height += size.height;
405        }
406        // Measure the content
407        let content_node_id = input.children_ids[0];
408        let content_measurement = input.measure_child(content_node_id, &content_contraint)?;
409        // update the size
410        final_size.width += content_measurement.width;
411        final_size.height += content_measurement.height;
412        // Place childrens
413        // place the content at [0, 0]
414        input.place_child(content_node_id, PxPosition::ZERO);
415        // place the scrollbar at the end
416        if args.vertical {
417            input.place_child(
418                input.children_ids[1],
419                PxPosition::new(content_measurement.width, Px::ZERO),
420            );
421        }
422        if args.horizontal {
423            let scrollbar_node_id = if args.vertical {
424                input.children_ids[2]
425            } else {
426                input.children_ids[1]
427            };
428            input.place_child(
429                scrollbar_node_id,
430                PxPosition::new(Px::ZERO, content_measurement.height),
431            );
432        }
433        // Return the computed data
434        Ok(final_size)
435    }));
436}
437
438#[tessera]
439fn scrollable_with_overlay_scrollbar(
440    state: Arc<ScrollableState>,
441    args: ScrollableArgs,
442    scrollbar_args_v: ScrollBarArgs,
443    scrollbar_args_h: ScrollBarArgs,
444    child: impl FnOnce() + Send + Sync + 'static,
445) {
446    boxed(
447        BoxedArgsBuilder::default()
448            .width(args.width)
449            .height(args.height)
450            .alignment(Alignment::BottomEnd)
451            .build()
452            .unwrap(),
453        |scope| {
454            scope.child({
455                let state = state.clone();
456                let args = args.clone();
457                move || {
458                    scrollable_inner(
459                        args,
460                        state.inner.clone(),
461                        state.scrollbar_state_v.clone(),
462                        state.scrollbar_state_h.clone(),
463                        child,
464                    );
465                }
466            });
467            scope.child({
468                let scrollbar_args_v = scrollbar_args_v.clone();
469                let args = args.clone();
470                let state = state.clone();
471                move || {
472                    if args.vertical {
473                        scrollbar_v(scrollbar_args_v, state.scrollbar_state_v.clone());
474                    }
475                }
476            });
477            scope.child({
478                let scrollbar_args_h = scrollbar_args_h.clone();
479                let args = args.clone();
480                let state = state.clone();
481                move || {
482                    if args.horizontal {
483                        scrollbar_h(scrollbar_args_h, state.scrollbar_state_h.clone());
484                    }
485                }
486            });
487        },
488    );
489}
490
491// Helpers to resolve DimensionValue into concrete Px sizes.
492// This reduces duplication in the measurement code and lowers cyclomatic complexity.
493fn clamp_wrap(min: Option<Px>, max: Option<Px>, measure: Px) -> Px {
494    min.unwrap_or(Px(0))
495        .max(measure)
496        .min(max.unwrap_or(Px::MAX))
497}
498
499fn fill_value(min: Option<Px>, max: Option<Px>, measure: Px) -> Px {
500    max.expect("Seems that you are trying to fill an infinite dimension, which is not allowed")
501        .max(measure)
502        .max(min.unwrap_or(Px(0)))
503}
504
505fn resolve_dimension(dim: DimensionValue, measure: Px) -> Px {
506    match dim {
507        DimensionValue::Fixed(v) => v,
508        DimensionValue::Wrap { min, max } => clamp_wrap(min, max, measure),
509        DimensionValue::Fill { min, max } => fill_value(min, max, measure),
510    }
511}
512
513#[tessera]
514fn scrollable_inner(
515    args: impl Into<ScrollableArgs>,
516    state: Arc<RwLock<ScrollableStateInner>>,
517    scrollbar_state_v: Arc<RwLock<ScrollBarState>>,
518    scrollbar_state_h: Arc<RwLock<ScrollBarState>>,
519    child: impl FnOnce(),
520) {
521    let args: ScrollableArgs = args.into();
522    {
523        let state = state.clone();
524        measure(Box::new(move |input| {
525            // Enable clip
526            input.enable_clipping();
527            // Merge constraints with parent constraints
528            let arg_constraint = Constraint {
529                width: args.width,
530                height: args.height,
531            };
532            let merged_constraint = input.parent_constraint.merge(&arg_constraint);
533            // Now calculate the constraints to child
534            let mut child_constraint = merged_constraint;
535            // If vertical scrollable, set height to wrap
536            if args.vertical {
537                child_constraint.height = tessera_ui::DimensionValue::Wrap {
538                    min: None,
539                    max: None,
540                };
541            }
542            // If horizontal scrollable, set width to wrap
543            if args.horizontal {
544                child_constraint.width = tessera_ui::DimensionValue::Wrap {
545                    min: None,
546                    max: None,
547                };
548            }
549            // Measure the child with child constraint
550            let child_node_id = input.children_ids[0]; // Scrollable should have exactly one child
551            let child_measurement = input.measure_child(child_node_id, &child_constraint)?;
552            // Update the child position and size in the state
553            state.write().child_size = child_measurement;
554
555            // Update scroll position based on time and get current position for rendering
556            let current_child_position = {
557                let mut state_guard = state.write();
558                state_guard.update_scroll_position(args.scroll_smoothing);
559                state_guard.child_position
560            };
561
562            // Place child at current interpolated position
563            input.place_child(child_node_id, current_child_position);
564
565            // Calculate the size of the scrollable area using helpers to reduce inline branching
566            let width = resolve_dimension(merged_constraint.width, child_measurement.width);
567            let height = resolve_dimension(merged_constraint.height, child_measurement.height);
568
569            // Pack the size into ComputedData
570            let computed_data = ComputedData { width, height };
571            // Update the visible size in the state
572            state.write().visible_size = computed_data;
573            // Return the size of the scrollable area
574            Ok(computed_data)
575        }));
576    }
577
578    // Handle scroll input and position updates
579    input_handler(Box::new(move |input| {
580        let size = input.computed_data;
581        let cursor_pos_option = input.cursor_position_rel;
582        let is_cursor_in_component = cursor_pos_option
583            .map(|pos| is_position_in_component(size, pos))
584            .unwrap_or(false);
585
586        if is_cursor_in_component {
587            // Handle scroll events
588            for event in input
589                .cursor_events
590                .iter()
591                .filter_map(|event| match &event.content {
592                    CursorEventContent::Scroll(event) => Some(event),
593                    _ => None,
594                })
595            {
596                let mut state_guard = state.write();
597
598                // Use scroll delta directly (speed already handled in cursor.rs)
599                let scroll_delta_x = event.delta_x;
600                let scroll_delta_y = event.delta_y;
601
602                // Calculate new target position using saturating arithmetic
603                let current_target = state_guard.target_position;
604                let new_target = current_target.saturating_offset(
605                    Px::saturating_from_f32(scroll_delta_x),
606                    Px::saturating_from_f32(scroll_delta_y),
607                );
608
609                // Apply bounds constraints immediately before setting target
610                let child_size = state_guard.child_size;
611                let constrained_target = constrain_position(
612                    new_target,
613                    &child_size,
614                    &input.computed_data,
615                    args.vertical,
616                    args.horizontal,
617                );
618
619                // Set constrained target position
620                state_guard.set_target_position(constrained_target);
621
622                // Update scroll activity for AutoHide behavior
623                if matches!(args.scrollbar_behavior, ScrollBarBehavior::AutoHide) {
624                    // Update vertical scrollbar state if vertical scrolling is enabled
625                    if args.vertical {
626                        let mut scrollbar_state = scrollbar_state_v.write();
627                        scrollbar_state.last_scroll_activity = Some(std::time::Instant::now());
628                        scrollbar_state.should_be_visible = true;
629                    }
630                    // Update horizontal scrollbar state if horizontal scrolling is enabled
631                    if args.horizontal {
632                        let mut scrollbar_state = scrollbar_state_h.write();
633                        scrollbar_state.last_scroll_activity = Some(std::time::Instant::now());
634                        scrollbar_state.should_be_visible = true;
635                    }
636                }
637            }
638
639            // Apply bound constraints to the child position
640            // To make sure we constrain the target position at least once per frame
641            let target = state.read().target_position;
642            let child_size = state.read().child_size;
643            let constrained_position = constrain_position(
644                target,
645                &child_size,
646                &input.computed_data,
647                args.vertical,
648                args.horizontal,
649            );
650            state.write().set_target_position(constrained_position);
651
652            // Block cursor events to prevent propagation
653            input.cursor_events.clear();
654        }
655
656        // Update scroll position based on time (only once per frame, after handling events)
657        state.write().update_scroll_position(args.scroll_smoothing);
658    }));
659
660    // Add child component
661    child();
662}
663
664/// Constrains a position to stay within the scrollable bounds.
665///
666/// Split per-axis logic into a helper to simplify reasoning and reduce cyclomatic complexity.
667fn constrain_axis(pos: Px, child_len: Px, container_len: Px) -> Px {
668    if pos > Px::ZERO {
669        Px::ZERO
670    } else if pos.saturating_add(child_len) < container_len {
671        container_len.saturating_sub(child_len)
672    } else {
673        pos
674    }
675}
676
677fn constrain_position(
678    position: PxPosition,
679    child_size: &ComputedData,
680    container_size: &ComputedData,
681    vertical_scrollable: bool,
682    horizontal_scrollable: bool,
683) -> PxPosition {
684    let x = if horizontal_scrollable {
685        constrain_axis(position.x, child_size.width, container_size.width)
686    } else {
687        Px::ZERO
688    };
689
690    let y = if vertical_scrollable {
691        constrain_axis(position.y, child_size.height, container_size.height)
692    } else {
693        Px::ZERO
694    };
695
696    PxPosition { x, y }
697}