Scroll box example

This commit is contained in:
2026-03-21 04:05:38 -04:00
parent a70a08297e
commit ac9be932e7
6 changed files with 1507 additions and 278 deletions

View File

@@ -7,11 +7,12 @@ pub enum PointerButton {
Middle,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PointerEventKind {
Move,
Down { button: PointerButton },
Up { button: PointerButton },
Scroll { delta: Point },
LeaveWindow,
}
@@ -32,13 +33,14 @@ impl PointerEvent {
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum RoutedPointerEventKind {
Enter,
Leave,
Move,
Down { button: PointerButton },
Up { button: PointerButton },
Scroll { delta: Point },
}
#[derive(Clone, Debug, PartialEq)]
@@ -142,6 +144,16 @@ impl PointerRouter {
});
}
}
PointerEventKind::Scroll { delta } => {
if let Some(target) = hit_target {
routed.push(RoutedPointerEvent {
kind: RoutedPointerEventKind::Scroll { delta },
target,
pointer_id: event.pointer_id,
position: event.position,
});
}
}
PointerEventKind::LeaveWindow => {}
}
@@ -165,9 +177,11 @@ mod tests {
element_id: None,
rect: Rect::new(0.0, 0.0, 200.0, 120.0),
corner_radius: 0.0,
clip_rect: None,
pointer_events: false,
focusable: false,
cursor: CursorIcon::Default,
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: vec![
@@ -176,9 +190,11 @@ mod tests {
element_id: Some(ElementId::new(1)),
rect: Rect::new(0.0, 0.0, 120.0, 120.0),
corner_radius: 0.0,
clip_rect: None,
pointer_events: true,
focusable: false,
cursor: CursorIcon::Default,
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
@@ -188,9 +204,11 @@ mod tests {
element_id: Some(ElementId::new(2)),
rect: Rect::new(80.0, 0.0, 120.0, 120.0),
corner_radius: 0.0,
clip_rect: None,
pointer_events: true,
focusable: false,
cursor: CursorIcon::Default,
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
@@ -207,9 +225,11 @@ mod tests {
element_id: None,
rect: Rect::new(0.0, 0.0, 200.0, 120.0),
corner_radius: 0.0,
clip_rect: None,
pointer_events: false,
focusable: false,
cursor: CursorIcon::Default,
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: vec![LayoutNode {
@@ -217,9 +237,11 @@ mod tests {
element_id: Some(ElementId::new(1)),
rect: Rect::new(0.0, 0.0, 160.0, 120.0),
corner_radius: 0.0,
clip_rect: None,
pointer_events: true,
focusable: false,
cursor: CursorIcon::Default,
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: vec![LayoutNode {
@@ -227,9 +249,11 @@ mod tests {
element_id: Some(ElementId::new(2)),
rect: Rect::new(16.0, 16.0, 80.0, 40.0),
corner_radius: 0.0,
clip_rect: None,
pointer_events: true,
focusable: false,
cursor: CursorIcon::Default,
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
@@ -290,6 +314,35 @@ mod tests {
);
}
#[test]
fn router_routes_scroll_to_deepest_hover_target() {
let mut router = PointerRouter::new();
let tree = nested_interaction_tree();
let _ = router.route(
&tree,
PointerEvent::new(0, Point::new(24.0, 24.0), PointerEventKind::Move),
);
let routed = router.route(
&tree,
PointerEvent::new(
0,
Point::new(24.0, 24.0),
PointerEventKind::Scroll {
delta: Point::new(0.0, 18.0),
},
),
);
assert!(routed.iter().any(|event| {
event.kind
== RoutedPointerEventKind::Scroll {
delta: Point::new(0.0, 18.0),
}
&& event.target.element_id == Some(ElementId::new(2))
}));
}
#[test]
fn router_tracks_hovered_ancestors_for_nested_hits() {
let mut router = PointerRouter::new();

View File

@@ -61,14 +61,26 @@ pub struct LayoutNode {
pub element_id: Option<ElementId>,
pub rect: Rect,
pub corner_radius: f32,
pub clip_rect: Option<Rect>,
pub pointer_events: bool,
pub focusable: bool,
pub cursor: CursorIcon,
pub scroll_metrics: Option<ScrollMetrics>,
pub prepared_image: Option<PreparedImage>,
pub prepared_text: Option<PreparedText>,
pub children: Vec<LayoutNode>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScrollMetrics {
pub viewport_rect: Rect,
pub content_height: f32,
pub offset_y: f32,
pub max_offset_y: f32,
pub scrollbar_track: Option<Rect>,
pub scrollbar_thumb: Option<Rect>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextHitTarget {
pub target: HitTarget,
@@ -100,6 +112,10 @@ impl InteractionTree {
pub fn text_for_element(&self, element_id: ElementId) -> Option<&PreparedText> {
text_for_element_node(&self.root, element_id)
}
pub fn scroll_metrics_for_element(&self, element_id: ElementId) -> Option<&ScrollMetrics> {
scroll_metrics_for_element_node(&self.root, element_id)
}
}
pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot {
@@ -191,9 +207,11 @@ fn layout_element(
element_id: element.id,
rect,
corner_radius: uniform_corner_radius(&element.style, rect),
clip_rect: None,
pointer_events: element.style.pointer_events,
focusable: element.style.focusable,
cursor,
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
@@ -264,6 +282,98 @@ fn layout_element(
return interaction;
}
if let Some(scroll_box) = element.scroll_box_node() {
perf_stats.container_nodes += 1;
let content = inset_rect(rect, content_insets(&element.style));
if content.size.width > 0.0 && content.size.height > 0.0 {
let gutter_width = scroll_box
.scrollbar
.gutter_width
.max(0.0)
.min(content.size.width);
let viewport_rect = Rect::new(
content.origin.x,
content.origin.y,
(content.size.width - gutter_width).max(0.0),
content.size.height,
);
interaction.clip_rect = Some(viewport_rect);
let content_size = intrinsic_container_content_size(
element,
UiSize::new(
viewport_rect.size.width.max(0.0),
viewport_rect.size.height.max(0.0),
),
text_system,
perf_stats,
);
let provisional_content_height = content_size.height.max(viewport_rect.size.height);
let mut offset_y = scroll_box.offset_y.max(0.0);
if viewport_rect.size.width > 0.0 && viewport_rect.size.height > 0.0 {
scene.push_clip(viewport_rect, 0.0);
interaction.children = layout_container_children(
element,
Rect::new(
viewport_rect.origin.x,
viewport_rect.origin.y - offset_y,
viewport_rect.size.width,
provisional_content_height,
),
&interaction.path,
scene,
text_system,
perf_stats,
);
scene.pop_clip();
}
let actual_content_height = interaction
.children
.iter()
.filter_map(max_layout_node_bottom)
.fold(viewport_rect.origin.y - offset_y, f32::max)
- (viewport_rect.origin.y - offset_y);
let content_height = provisional_content_height
.max(actual_content_height.ceil())
.max(viewport_rect.size.height);
let max_offset_y = (content_height - viewport_rect.size.height).max(0.0);
offset_y = offset_y.min(max_offset_y);
interaction.scroll_metrics = Some(ScrollMetrics {
viewport_rect,
content_height,
offset_y,
max_offset_y,
scrollbar_track: None,
scrollbar_thumb: None,
});
let (scrollbar_track, scrollbar_thumb) = scrollbar_geometry(
content,
scroll_box.scrollbar,
content_height,
viewport_rect.size.height,
offset_y,
);
if let Some(scroll_metrics) = interaction.scroll_metrics.as_mut() {
scroll_metrics.scrollbar_track = scrollbar_track;
scroll_metrics.scrollbar_thumb = scrollbar_thumb;
}
paint_scrollbar(
scene,
scroll_box.scrollbar,
scrollbar_track,
scrollbar_thumb,
perf_stats,
);
}
if pushed_clip {
scene.pop_clip();
}
return interaction;
}
perf_stats.container_nodes += 1;
if element.children.is_empty() {
@@ -274,90 +384,14 @@ fn layout_element(
if content.size.width <= 0.0 || content.size.height <= 0.0 {
return interaction;
}
let gap_count = element.children.len().saturating_sub(1) as f32;
let total_gap = element.style.gap * gap_count;
let available_main = main_axis_size(content.size, element.style.direction).max(0.0) - total_gap;
let available_main = available_main.max(0.0);
let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0);
let mut measured_children = Vec::with_capacity(element.children.len());
let mut fixed_total = 0.0;
let mut flex_total = 0.0;
for child in &element.children {
let cross = child_cross_size(child, element.style.direction)
.unwrap_or(available_cross)
.clamp(0.0, available_cross);
let explicit_main =
child_main_size(child, element.style.direction).map(|main| main.max(0.0));
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
let measured_main = explicit_main.unwrap_or_else(|| {
if is_flex {
0.0
} else {
perf_stats.intrinsic_calls += 1;
if child.text_node().is_some() {
perf_stats.intrinsic_text_calls += 1;
} else {
perf_stats.intrinsic_container_calls += 1;
}
let intrinsic_started = perf_stats.enabled.then(Instant::now);
let intrinsic = intrinsic_main_size(
child,
element.style.direction,
cross,
available_main,
text_system,
perf_stats,
);
if let Some(intrinsic_started) = intrinsic_started {
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
}
intrinsic
}
});
if is_flex {
flex_total += child_flex_weight(child);
} else {
fixed_total += measured_main;
}
measured_children.push(MeasuredChild {
cross,
main: measured_main,
is_flex,
});
}
let remaining_main = (available_main - fixed_total).max(0.0);
let mut cursor = main_axis_origin(content, element.style.direction);
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex {
if flex_total <= 0.0 {
0.0
} else {
remaining_main * (child_flex_weight(child) / flex_total)
}
} else {
measured.main
};
let child_rect = child_rect(
content,
element.style.direction,
cursor,
child_main.max(0.0),
measured.cross,
);
interaction.children.push(layout_element(
child,
child_rect,
interaction.path.child(index),
scene,
text_system,
perf_stats,
));
cursor += child_main.max(0.0) + element.style.gap;
}
interaction.children = layout_container_children(
element,
content,
&interaction.path,
scene,
text_system,
perf_stats,
);
if pushed_clip {
scene.pop_clip();
@@ -371,18 +405,23 @@ fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option<Vec<Hi
return None;
}
for child in node.children.iter().rev() {
if let Some(mut hits) = hit_path_node(child, point) {
if node.pointer_events {
hits.push(HitTarget {
path: node.path.clone(),
element_id: node.element_id,
rect: node.rect,
focusable: node.focusable,
cursor: node.cursor,
});
if node
.clip_rect
.is_none_or(|clip_rect| clip_rect.contains(point))
{
for child in node.children.iter().rev() {
if let Some(mut hits) = hit_path_node(child, point) {
if node.pointer_events {
hits.push(HitTarget {
path: node.path.clone(),
element_id: node.element_id,
rect: node.rect,
focusable: node.focusable,
cursor: node.cursor,
});
}
return Some(hits);
}
return Some(hits);
}
}
@@ -404,9 +443,14 @@ fn text_hit_test_node(node: &LayoutNode, point: crate::scene::Point) -> Option<T
return None;
}
for child in node.children.iter().rev() {
if let Some(hit) = text_hit_test_node(child, point) {
return Some(hit);
if node
.clip_rect
.is_none_or(|clip_rect| clip_rect.contains(point))
{
for child in node.children.iter().rev() {
if let Some(hit) = text_hit_test_node(child, point) {
return Some(hit);
}
}
}
@@ -446,6 +490,25 @@ fn text_for_element_node(node: &LayoutNode, element_id: ElementId) -> Option<&Pr
None
}
fn scroll_metrics_for_element_node(
node: &LayoutNode,
element_id: ElementId,
) -> Option<&ScrollMetrics> {
if node.element_id == Some(element_id)
&& let Some(scroll_metrics) = node.scroll_metrics.as_ref()
{
return Some(scroll_metrics);
}
for child in &node.children {
if let Some(scroll_metrics) = scroll_metrics_for_element_node(child, element_id) {
return Some(scroll_metrics);
}
}
None
}
fn point_hits_node_shape(node: &LayoutNode, point: crate::scene::Point) -> bool {
node.rect.contains(point)
&& (node.corner_radius <= 0.0
@@ -496,6 +559,104 @@ struct MeasuredChild {
is_flex: bool,
}
fn layout_container_children(
element: &Element,
content: Rect,
path: &LayoutPath,
scene: &mut SceneSnapshot,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
) -> Vec<LayoutNode> {
if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 {
return Vec::new();
}
let gap_count = element.children.len().saturating_sub(1) as f32;
let total_gap = element.style.gap * gap_count;
let available_main =
(main_axis_size(content.size, element.style.direction).max(0.0) - total_gap).max(0.0);
let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0);
let mut measured_children = Vec::with_capacity(element.children.len());
let mut fixed_total = 0.0;
let mut flex_total = 0.0;
for child in &element.children {
let cross = child_cross_size(child, element.style.direction)
.unwrap_or(available_cross)
.clamp(0.0, available_cross);
let explicit_main =
child_main_size(child, element.style.direction).map(|main| main.max(0.0));
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
let measured_main = explicit_main.unwrap_or_else(|| {
if is_flex {
0.0
} else {
perf_stats.intrinsic_calls += 1;
if child.text_node().is_some() {
perf_stats.intrinsic_text_calls += 1;
} else {
perf_stats.intrinsic_container_calls += 1;
}
let intrinsic_started = perf_stats.enabled.then(Instant::now);
let intrinsic = intrinsic_main_size(
child,
element.style.direction,
cross,
available_main,
text_system,
perf_stats,
);
if let Some(intrinsic_started) = intrinsic_started {
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
}
intrinsic
}
});
if is_flex {
flex_total += child_flex_weight(child);
} else {
fixed_total += measured_main;
}
measured_children.push(MeasuredChild {
cross,
main: measured_main,
is_flex,
});
}
let remaining_main = (available_main - fixed_total).max(0.0);
let mut cursor = main_axis_origin(content, element.style.direction);
let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex {
if flex_total <= 0.0 {
0.0
} else {
remaining_main * (child_flex_weight(child) / flex_total)
}
} else {
measured.main
};
let child_rect = child_rect(
content,
element.style.direction,
cursor,
child_main.max(0.0),
measured.cross,
);
children.push(layout_element(
child,
child_rect,
path.child(index),
scene,
text_system,
perf_stats,
));
cursor += child_main.max(0.0) + element.style.gap;
}
children
}
fn intrinsic_main_size(
child: &Element,
direction: FlexDirection,
@@ -530,58 +691,18 @@ fn intrinsic_main_size(
)
}
fn intrinsic_size(
fn intrinsic_container_content_size(
element: &Element,
available_size: UiSize,
content_size: UiSize,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
) -> UiSize {
perf_stats.intrinsic_size_calls += 1;
let insets = content_insets(&element.style);
if let Some(text) = element.text_node() {
let measured = text_system.measure_spans(
&text.spans,
&text.style,
Some(available_size.width.max(0.0)),
Some(available_size.height.max(0.0)),
);
return UiSize::new(
element
.style
.width
.unwrap_or(measured.width + horizontal_insets(insets)),
element
.style
.height
.unwrap_or(measured.height + vertical_insets(insets)),
);
}
if let Some(image) = element.image_node() {
let resolved = resolve_image_element_size(element, image.resource.size());
return UiSize::new(
resolved.width + horizontal_insets(insets),
resolved.height + vertical_insets(insets),
);
}
let explicit_width = element.style.width;
let explicit_height = element.style.height;
let content_width =
explicit_width.unwrap_or(available_size.width).max(0.0) - horizontal_insets(insets);
let content_height =
explicit_height.unwrap_or(available_size.height).max(0.0) - vertical_insets(insets);
let content_size = UiSize::new(content_width.max(0.0), content_height.max(0.0));
if element.children.is_empty() {
return UiSize::new(
explicit_width.unwrap_or(horizontal_insets(insets)),
explicit_height.unwrap_or(vertical_insets(insets)),
);
return UiSize::new(0.0, 0.0);
}
let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32;
let (intrinsic_content_width, intrinsic_content_height) = match element.style.direction {
match element.style.direction {
FlexDirection::Column => {
let mut width: f32 = 0.0;
let mut height = gap_total;
@@ -601,7 +722,7 @@ fn intrinsic_size(
height += child.style.height.unwrap_or(child_size.height);
}
}
(width, height)
UiSize::new(width, height)
}
FlexDirection::Row => {
let mut width = gap_total;
@@ -653,13 +774,74 @@ fn intrinsic_size(
}
height = height.max(child.style.height.unwrap_or(child_size.height));
}
(width, height)
UiSize::new(width, height)
}
};
}
}
fn intrinsic_size(
element: &Element,
available_size: UiSize,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
) -> UiSize {
perf_stats.intrinsic_size_calls += 1;
let insets = content_insets(&element.style);
if let Some(text) = element.text_node() {
let measured = text_system.measure_spans(
&text.spans,
&text.style,
Some(available_size.width.max(0.0)),
Some(available_size.height.max(0.0)),
);
return UiSize::new(
element
.style
.width
.unwrap_or(measured.width + horizontal_insets(insets)),
element
.style
.height
.unwrap_or(measured.height + vertical_insets(insets)),
);
}
if let Some(image) = element.image_node() {
let resolved = resolve_image_element_size(element, image.resource.size());
return UiSize::new(
resolved.width + horizontal_insets(insets),
resolved.height + vertical_insets(insets),
);
}
let explicit_width = element.style.width;
let explicit_height = element.style.height;
let scroll_gutter = element
.scroll_box_node()
.map_or(0.0, |scroll_box| scroll_box.scrollbar.gutter_width.max(0.0));
let content_width =
explicit_width.unwrap_or(available_size.width).max(0.0) - horizontal_insets(insets);
let content_height =
explicit_height.unwrap_or(available_size.height).max(0.0) - vertical_insets(insets);
let content_size = UiSize::new(
(content_width - scroll_gutter).max(0.0),
content_height.max(0.0),
);
if element.children.is_empty() {
return UiSize::new(
explicit_width.unwrap_or(horizontal_insets(insets) + scroll_gutter),
explicit_height.unwrap_or(vertical_insets(insets)),
);
}
let intrinsic_content =
intrinsic_container_content_size(element, content_size, text_system, perf_stats);
UiSize::new(
explicit_width.unwrap_or(intrinsic_content_width + horizontal_insets(insets)),
explicit_height.unwrap_or(intrinsic_content_height + vertical_insets(insets)),
explicit_width
.unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter),
explicit_height.unwrap_or(intrinsic_content.height + vertical_insets(insets)),
)
}
@@ -759,6 +941,116 @@ fn resolve_image_element_size(element: &Element, intrinsic: UiSize) -> UiSize {
}
}
fn scrollbar_geometry(
content_rect: Rect,
scrollbar: crate::ScrollbarStyle,
content_height: f32,
viewport_height: f32,
offset_y: f32,
) -> (Option<Rect>, Option<Rect>) {
let gutter_width = scrollbar.gutter_width.max(0.0).min(content_rect.size.width);
if gutter_width <= 0.0 || content_rect.size.height <= 0.0 {
return (None, None);
}
let gutter_rect = Rect::new(
content_rect.origin.x + content_rect.size.width - gutter_width,
content_rect.origin.y,
gutter_width,
content_rect.size.height,
);
let track_rect = inset_rect(
gutter_rect,
Edges {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: (gutter_width * 0.16).clamp(1.0, gutter_width * 0.5),
},
);
if track_rect.size.width <= 0.0 || track_rect.size.height <= 0.0 {
return (None, None);
}
let max_offset_y = (content_height - viewport_height).max(0.0);
if max_offset_y <= 0.0 {
return (Some(track_rect), None);
}
let thumb_height = ((viewport_height / content_height.max(viewport_height))
* track_rect.size.height)
.max(scrollbar.min_thumb_size.max(0.0))
.min(track_rect.size.height);
let thumb_range = (track_rect.size.height - thumb_height).max(0.0);
let thumb_origin_y = track_rect.origin.y + thumb_range * (offset_y / max_offset_y.max(1.0));
let thumb_rect = Rect::new(
track_rect.origin.x,
thumb_origin_y,
track_rect.size.width,
thumb_height,
);
(Some(track_rect), Some(thumb_rect))
}
fn paint_scrollbar(
scene: &mut SceneSnapshot,
scrollbar: crate::ScrollbarStyle,
track_rect: Option<Rect>,
thumb_rect: Option<Rect>,
perf_stats: &mut LayoutPerfStats,
) {
let radius_for = |rect: Rect| {
scrollbar
.corner_radius
.max(0.0)
.min(rect.size.width * 0.5)
.min(rect.size.height * 0.5)
};
if let Some(track_rect) = track_rect {
perf_stats.background_quads += 1;
scene.push_rounded_rect(RoundedRect {
rect: track_rect,
fill: Some(scrollbar.track_color),
border_color: None,
border_width: 0.0,
radius: radius_for(track_rect),
});
}
if let Some(thumb_rect) = thumb_rect {
perf_stats.background_quads += 1;
scene.push_rounded_rect(RoundedRect {
rect: thumb_rect,
fill: Some(scrollbar.thumb_color),
border_color: None,
border_width: 0.0,
radius: radius_for(thumb_rect),
});
}
}
fn max_layout_node_bottom(node: &LayoutNode) -> Option<f32> {
let mut max_bottom =
(node.rect.size.height > 0.0).then_some(node.rect.origin.y + node.rect.size.height);
if let Some(prepared_text) = node.prepared_text.as_ref()
&& let Some(bounds) = prepared_text.bounds
{
let text_bottom = prepared_text.origin.y + bounds.height;
max_bottom = Some(max_bottom.map_or(text_bottom, |current| current.max(text_bottom)));
}
if let Some(prepared_image) = node.prepared_image.as_ref() {
let image_bottom = prepared_image.rect.origin.y + prepared_image.rect.size.height;
max_bottom = Some(max_bottom.map_or(image_bottom, |current| current.max(image_bottom)));
}
for child in &node.children {
if let Some(child_bottom) = max_layout_node_bottom(child) {
max_bottom = Some(max_bottom.map_or(child_bottom, |current| current.max(child_bottom)));
}
}
max_bottom
}
fn push_box_shadows(
scene: &mut SceneSnapshot,
rect: Rect,
@@ -1270,6 +1562,42 @@ mod tests {
);
}
#[test]
fn scroll_box_exposes_scroll_metrics_and_reserves_scrollbar_gutter() {
let scrollbox_id = ElementId::new(9);
let root = Element::column().child(
Element::scroll_box(48.0)
.id(scrollbox_id)
.width(180.0)
.height(96.0)
.padding(Edges::all(8.0))
.children([
Element::paragraph(
"Scroll boxes should clip oversized content and reserve space for a scrollbar.",
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0),
),
Element::paragraph(
"This extra paragraph forces overflow so the layout exposes max offset and scrollbar geometry.",
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0),
),
Element::paragraph(
"Keyboard and wheel handling use these metrics to clamp scrolling in the demo.",
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0),
),
]),
);
let snapshot = layout_snapshot(1, UiSize::new(240.0, 220.0), &root);
let scroll_metrics = snapshot
.interaction_tree
.scroll_metrics_for_element(scrollbox_id)
.expect("scroll box should expose scroll metrics");
assert!(scroll_metrics.max_offset_y > 0.0);
assert!(scroll_metrics.viewport_rect.size.width < 180.0);
assert!(scroll_metrics.scrollbar_track.is_some());
assert!(scroll_metrics.scrollbar_thumb.is_some());
}
#[test]
fn interaction_tree_hit_test_returns_deepest_pointer_target() {
let root = Element::column()

View File

@@ -28,8 +28,8 @@ pub use interaction::{
};
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
pub use layout::{
HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, TextHitTarget,
layout_snapshot, layout_snapshot_with_text_system,
HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
TextHitTarget, layout_snapshot, layout_snapshot_with_text_system,
};
pub use layout::{layout_scene, layout_scene_with_text_system};
pub use platform::{
@@ -47,7 +47,7 @@ pub use text::{
};
pub use tree::{
Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element, ElementId,
FlexDirection, Style,
FlexDirection, ScrollbarStyle, Style,
};
pub use window::{
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,

View File

@@ -134,6 +134,47 @@ impl BoxShadow {
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScrollbarStyle {
pub gutter_width: f32,
pub track_color: Color,
pub thumb_color: Color,
pub corner_radius: f32,
pub min_thumb_size: f32,
}
impl ScrollbarStyle {
pub const fn new(gutter_width: f32, track_color: Color, thumb_color: Color) -> Self {
Self {
gutter_width,
track_color,
thumb_color,
corner_radius: 999.0,
min_thumb_size: 28.0,
}
}
pub const fn with_corner_radius(mut self, corner_radius: f32) -> Self {
self.corner_radius = corner_radius;
self
}
pub const fn with_min_thumb_size(mut self, min_thumb_size: f32) -> Self {
self.min_thumb_size = min_thumb_size;
self
}
}
impl Default for ScrollbarStyle {
fn default() -> Self {
Self::new(
14.0,
Color::rgba(0xFF, 0xFF, 0xFF, 0x12),
Color::rgba(0xD8, 0xDE, 0xEA, 0x88),
)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Style {
pub direction: FlexDirection,
@@ -174,6 +215,7 @@ impl Default for Style {
#[derive(Clone, Debug, PartialEq)]
enum ElementContent {
Container,
ScrollBox(ScrollBoxNode),
Image(ImageNode),
Text(TextNode),
}
@@ -190,6 +232,12 @@ pub(crate) struct ImageNode {
pub fit: ImageFit,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ScrollBoxNode {
pub offset_y: f32,
pub scrollbar: ScrollbarStyle,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Element {
pub id: Option<ElementId>,
@@ -240,6 +288,21 @@ impl Element {
}
}
pub fn scroll_box(offset_y: f32) -> Self {
Self {
id: None,
style: Style {
focusable: true,
..Style::default()
},
children: Vec::new(),
content: ElementContent::ScrollBox(ScrollBoxNode {
offset_y: offset_y.max(0.0),
scrollbar: ScrollbarStyle::default(),
}),
}
}
pub fn rich_paragraph(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self {
Self::spans(spans, style.with_wrap(TextWrap::Word))
}
@@ -330,6 +393,22 @@ impl Element {
self
}
pub fn scroll_offset(mut self, offset_y: f32) -> Self {
let ElementContent::ScrollBox(scroll_box) = &mut self.content else {
panic!("only scroll box elements can set scroll_offset");
};
scroll_box.offset_y = offset_y.max(0.0);
self
}
pub fn scrollbar_style(mut self, scrollbar: ScrollbarStyle) -> Self {
let ElementContent::ScrollBox(scroll_box) = &mut self.content else {
panic!("only scroll box elements can set scrollbar_style");
};
scroll_box.scrollbar = scrollbar;
self
}
pub fn child(mut self, child: Element) -> Self {
self.assert_container();
self.children.push(child);
@@ -345,20 +424,34 @@ impl Element {
pub(crate) fn text_node(&self) -> Option<&TextNode> {
match &self.content {
ElementContent::Text(text) => Some(text),
ElementContent::Container | ElementContent::Image(_) => None,
ElementContent::Container | ElementContent::ScrollBox(_) | ElementContent::Image(_) => {
None
}
}
}
pub(crate) fn image_node(&self) -> Option<&ImageNode> {
match &self.content {
ElementContent::Image(image) => Some(image),
ElementContent::Container | ElementContent::Text(_) => None,
ElementContent::Container | ElementContent::ScrollBox(_) | ElementContent::Text(_) => {
None
}
}
}
pub(crate) fn scroll_box_node(&self) -> Option<&ScrollBoxNode> {
match &self.content {
ElementContent::ScrollBox(scroll_box) => Some(scroll_box),
ElementContent::Container | ElementContent::Image(_) | ElementContent::Text(_) => None,
}
}
fn assert_container(&self) {
assert!(
matches!(self.content, ElementContent::Container),
matches!(
self.content,
ElementContent::Container | ElementContent::ScrollBox(_)
),
"non-container elements cannot contain children"
);
}