1428 lines
46 KiB
Rust
1428 lines
46 KiB
Rust
use std::time::Instant;
|
|
|
|
use crate::ImageFit;
|
|
use crate::scene::{
|
|
PreparedImage, PreparedText, Rect, RoundedRect, SceneSnapshot, ShadowRect, UiSize,
|
|
};
|
|
use crate::text::TextSystem;
|
|
use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style};
|
|
|
|
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
|
|
let mut text_system = TextSystem::new();
|
|
layout_scene_with_text_system(version, logical_size, root, &mut text_system)
|
|
}
|
|
|
|
pub fn layout_scene_with_text_system(
|
|
version: u64,
|
|
logical_size: UiSize,
|
|
root: &Element,
|
|
text_system: &mut TextSystem,
|
|
) -> SceneSnapshot {
|
|
layout_snapshot_with_text_system(version, logical_size, root, text_system).scene
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct LayoutSnapshot {
|
|
pub scene: SceneSnapshot,
|
|
pub interaction_tree: InteractionTree,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
|
pub struct LayoutPath(Vec<u32>);
|
|
|
|
impl LayoutPath {
|
|
pub fn root() -> Self {
|
|
Self(Vec::new())
|
|
}
|
|
|
|
pub fn segments(&self) -> &[u32] {
|
|
&self.0
|
|
}
|
|
|
|
pub(crate) fn child(&self, index: usize) -> Self {
|
|
let mut segments = self.0.clone();
|
|
segments.push(index as u32);
|
|
Self(segments)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct HitTarget {
|
|
pub path: LayoutPath,
|
|
pub element_id: Option<ElementId>,
|
|
pub rect: Rect,
|
|
pub focusable: bool,
|
|
pub cursor: CursorIcon,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct LayoutNode {
|
|
pub path: LayoutPath,
|
|
pub element_id: Option<ElementId>,
|
|
pub rect: Rect,
|
|
pub corner_radius: f32,
|
|
pub pointer_events: bool,
|
|
pub focusable: bool,
|
|
pub cursor: CursorIcon,
|
|
pub prepared_image: Option<PreparedImage>,
|
|
pub prepared_text: Option<PreparedText>,
|
|
pub children: Vec<LayoutNode>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct TextHitTarget {
|
|
pub target: HitTarget,
|
|
pub byte_offset: usize,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct InteractionTree {
|
|
pub root: LayoutNode,
|
|
}
|
|
|
|
impl InteractionTree {
|
|
pub fn hit_test(&self, point: crate::scene::Point) -> Option<HitTarget> {
|
|
self.hit_path(point).into_iter().last()
|
|
}
|
|
|
|
pub fn hit_path(&self, point: crate::scene::Point) -> Vec<HitTarget> {
|
|
let Some(mut path) = hit_path_node(&self.root, point) else {
|
|
return Vec::new();
|
|
};
|
|
path.reverse();
|
|
path
|
|
}
|
|
|
|
pub fn text_hit_test(&self, point: crate::scene::Point) -> Option<TextHitTarget> {
|
|
text_hit_test_node(&self.root, point)
|
|
}
|
|
|
|
pub fn text_for_element(&self, element_id: ElementId) -> Option<&PreparedText> {
|
|
text_for_element_node(&self.root, element_id)
|
|
}
|
|
}
|
|
|
|
pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot {
|
|
let mut text_system = TextSystem::new();
|
|
layout_snapshot_with_text_system(version, logical_size, root, &mut text_system)
|
|
}
|
|
|
|
pub fn layout_snapshot_with_text_system(
|
|
version: u64,
|
|
logical_size: UiSize,
|
|
root: &Element,
|
|
text_system: &mut TextSystem,
|
|
) -> LayoutSnapshot {
|
|
let layout_started = Instant::now();
|
|
let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG);
|
|
let mut perf_stats = LayoutPerfStats::new(perf_enabled);
|
|
text_system.reset_frame_stats();
|
|
let mut scene = SceneSnapshot::new(version, logical_size);
|
|
let interaction_root = layout_element(
|
|
root,
|
|
Rect::new(
|
|
0.0,
|
|
0.0,
|
|
logical_size.width.max(0.0),
|
|
logical_size.height.max(0.0),
|
|
),
|
|
LayoutPath::root(),
|
|
&mut scene,
|
|
text_system,
|
|
&mut perf_stats,
|
|
);
|
|
let text_stats = text_system.take_frame_stats();
|
|
if perf_stats.enabled {
|
|
tracing::debug!(
|
|
target: "ruin_ui::layout_perf",
|
|
scene_version = version,
|
|
width = logical_size.width,
|
|
height = logical_size.height,
|
|
total_ms = layout_started.elapsed().as_secs_f64() * 1_000.0,
|
|
nodes = perf_stats.nodes,
|
|
text_nodes = perf_stats.text_nodes,
|
|
container_nodes = perf_stats.container_nodes,
|
|
background_quads = perf_stats.background_quads,
|
|
intrinsic_calls = perf_stats.intrinsic_calls,
|
|
intrinsic_size_calls = perf_stats.intrinsic_size_calls,
|
|
intrinsic_text_calls = perf_stats.intrinsic_text_calls,
|
|
intrinsic_container_calls = perf_stats.intrinsic_container_calls,
|
|
intrinsic_ms = perf_stats.intrinsic_ms,
|
|
text_prepare_calls = perf_stats.text_prepare_calls,
|
|
text_prepare_ms = perf_stats.text_prepare_ms,
|
|
text_requests = text_stats.requests,
|
|
text_cache_hits = text_stats.cache_hits,
|
|
text_cache_misses = text_stats.cache_misses,
|
|
text_output_glyphs = text_stats.output_glyphs,
|
|
text_family_resolve_ms = text_stats.family_resolve_ms,
|
|
text_buffer_build_ms = text_stats.buffer_build_ms,
|
|
text_glyph_collect_ms = text_stats.glyph_collect_ms,
|
|
text_miss_ms = text_stats.miss_ms,
|
|
scene_items = scene.items.len(),
|
|
"layout snapshot perf"
|
|
);
|
|
}
|
|
LayoutSnapshot {
|
|
scene,
|
|
interaction_tree: InteractionTree {
|
|
root: interaction_root,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn layout_element(
|
|
element: &Element,
|
|
rect: Rect,
|
|
path: LayoutPath,
|
|
scene: &mut SceneSnapshot,
|
|
text_system: &mut TextSystem,
|
|
perf_stats: &mut LayoutPerfStats,
|
|
) -> LayoutNode {
|
|
perf_stats.nodes += 1;
|
|
let cursor = element.style.cursor.unwrap_or_else(|| {
|
|
if element.text_node().is_some() {
|
|
CursorIcon::Text
|
|
} else {
|
|
CursorIcon::Default
|
|
}
|
|
});
|
|
let mut interaction = LayoutNode {
|
|
path,
|
|
element_id: element.id,
|
|
rect,
|
|
corner_radius: uniform_corner_radius(&element.style, rect),
|
|
pointer_events: element.style.pointer_events,
|
|
focusable: element.style.focusable,
|
|
cursor,
|
|
prepared_image: None,
|
|
prepared_text: None,
|
|
children: Vec::new(),
|
|
};
|
|
|
|
if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
|
|
return interaction;
|
|
}
|
|
|
|
push_box_shadows(
|
|
scene,
|
|
rect,
|
|
&element.style,
|
|
crate::BoxShadowKind::Outer,
|
|
perf_stats,
|
|
);
|
|
push_container_fill_and_border(scene, rect, &element.style, perf_stats);
|
|
push_box_shadows(
|
|
scene,
|
|
rect,
|
|
&element.style,
|
|
crate::BoxShadowKind::Inner,
|
|
perf_stats,
|
|
);
|
|
let clip_radius = uniform_corner_radius(&element.style, rect);
|
|
let pushed_clip = if clip_radius > 0.0 {
|
|
scene.push_clip(rect, clip_radius);
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if let Some(text) = element.text_node() {
|
|
perf_stats.text_nodes += 1;
|
|
let content = inset_rect(rect, content_insets(&element.style));
|
|
if content.size.width > 0.0 && content.size.height > 0.0 {
|
|
perf_stats.text_prepare_calls += 1;
|
|
let prepare_started = perf_stats.enabled.then(Instant::now);
|
|
let mut prepared = text_system.prepare_spans(
|
|
&text.spans,
|
|
content.origin,
|
|
&text.style,
|
|
Some(content.size),
|
|
);
|
|
prepared.element_id = element.id;
|
|
scene.push_text(prepared.clone());
|
|
interaction.prepared_text = Some(prepared);
|
|
if let Some(prepare_started) = prepare_started {
|
|
perf_stats.text_prepare_ms += prepare_started.elapsed().as_secs_f64() * 1_000.0;
|
|
}
|
|
}
|
|
if pushed_clip {
|
|
scene.pop_clip();
|
|
}
|
|
return interaction;
|
|
}
|
|
|
|
if let Some(image) = element.image_node() {
|
|
let content = inset_rect(rect, content_insets(&element.style));
|
|
if content.size.width > 0.0 && content.size.height > 0.0 {
|
|
let prepared = prepare_image(image, content, element.id);
|
|
scene.push_image(prepared.clone());
|
|
interaction.prepared_image = Some(prepared);
|
|
}
|
|
if pushed_clip {
|
|
scene.pop_clip();
|
|
}
|
|
return interaction;
|
|
}
|
|
|
|
perf_stats.container_nodes += 1;
|
|
|
|
if element.children.is_empty() {
|
|
return interaction;
|
|
}
|
|
|
|
let content = inset_rect(rect, content_insets(&element.style));
|
|
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;
|
|
}
|
|
|
|
if pushed_clip {
|
|
scene.pop_clip();
|
|
}
|
|
|
|
interaction
|
|
}
|
|
|
|
fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option<Vec<HitTarget>> {
|
|
if !point_hits_node_shape(node, point) {
|
|
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,
|
|
});
|
|
}
|
|
return Some(hits);
|
|
}
|
|
}
|
|
|
|
if node.pointer_events {
|
|
return Some(vec![HitTarget {
|
|
path: node.path.clone(),
|
|
element_id: node.element_id,
|
|
rect: node.rect,
|
|
focusable: node.focusable,
|
|
cursor: node.cursor,
|
|
}]);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn text_hit_test_node(node: &LayoutNode, point: crate::scene::Point) -> Option<TextHitTarget> {
|
|
if !point_hits_node_shape(node, point) {
|
|
return None;
|
|
}
|
|
|
|
for child in node.children.iter().rev() {
|
|
if let Some(hit) = text_hit_test_node(child, point) {
|
|
return Some(hit);
|
|
}
|
|
}
|
|
|
|
if !node.pointer_events {
|
|
return None;
|
|
}
|
|
|
|
let prepared_text = node.prepared_text.as_ref()?;
|
|
if !prepared_text.selectable {
|
|
return None;
|
|
}
|
|
Some(TextHitTarget {
|
|
target: HitTarget {
|
|
path: node.path.clone(),
|
|
element_id: node.element_id,
|
|
rect: node.rect,
|
|
focusable: node.focusable,
|
|
cursor: node.cursor,
|
|
},
|
|
byte_offset: prepared_text.byte_offset_for_position(point),
|
|
})
|
|
}
|
|
|
|
fn text_for_element_node(node: &LayoutNode, element_id: ElementId) -> Option<&PreparedText> {
|
|
if node.element_id == Some(element_id)
|
|
&& let Some(prepared_text) = node.prepared_text.as_ref()
|
|
{
|
|
return Some(prepared_text);
|
|
}
|
|
|
|
for child in &node.children {
|
|
if let Some(prepared_text) = text_for_element_node(child, element_id) {
|
|
return Some(prepared_text);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn point_hits_node_shape(node: &LayoutNode, point: crate::scene::Point) -> bool {
|
|
node.rect.contains(point)
|
|
&& (node.corner_radius <= 0.0
|
|
|| point_in_rounded_rect(point, node.rect, node.corner_radius))
|
|
}
|
|
|
|
fn point_in_rounded_rect(point: crate::scene::Point, rect: Rect, radius: f32) -> bool {
|
|
let radius = radius
|
|
.max(0.0)
|
|
.min(rect.size.width * 0.5)
|
|
.min(rect.size.height * 0.5);
|
|
if radius <= 0.0 {
|
|
return true;
|
|
}
|
|
|
|
let left = rect.origin.x;
|
|
let top = rect.origin.y;
|
|
let right = rect.origin.x + rect.size.width;
|
|
let bottom = rect.origin.y + rect.size.height;
|
|
|
|
if point.x >= left + radius && point.x < right - radius
|
|
|| point.y >= top + radius && point.y < bottom - radius
|
|
{
|
|
return true;
|
|
}
|
|
|
|
let corner_center = if point.x < left + radius {
|
|
if point.y < top + radius {
|
|
crate::scene::Point::new(left + radius, top + radius)
|
|
} else {
|
|
crate::scene::Point::new(left + radius, bottom - radius)
|
|
}
|
|
} else if point.y < top + radius {
|
|
crate::scene::Point::new(right - radius, top + radius)
|
|
} else {
|
|
crate::scene::Point::new(right - radius, bottom - radius)
|
|
};
|
|
|
|
let dx = point.x - corner_center.x;
|
|
let dy = point.y - corner_center.y;
|
|
dx * dx + dy * dy <= radius * radius
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct MeasuredChild {
|
|
cross: f32,
|
|
main: f32,
|
|
is_flex: bool,
|
|
}
|
|
|
|
fn intrinsic_main_size(
|
|
child: &Element,
|
|
direction: FlexDirection,
|
|
cross_size: f32,
|
|
available_main: f32,
|
|
text_system: &mut TextSystem,
|
|
perf_stats: &mut LayoutPerfStats,
|
|
) -> f32 {
|
|
if let Some(text) = child.text_node() {
|
|
let constraints = match direction {
|
|
FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))),
|
|
FlexDirection::Column => (Some(cross_size.max(0.0)), None),
|
|
};
|
|
let content =
|
|
text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1);
|
|
let padding = main_axis_padding(content_insets(&child.style), direction);
|
|
return main_axis_size(content, direction) + padding;
|
|
}
|
|
|
|
if let Some(image) = child.image_node() {
|
|
let resolved = resolve_image_element_size(child, image.resource.size());
|
|
return main_axis_size(resolved, direction);
|
|
}
|
|
|
|
let available_size = match direction {
|
|
FlexDirection::Row => UiSize::new(available_main.max(0.0), cross_size.max(0.0)),
|
|
FlexDirection::Column => UiSize::new(cross_size.max(0.0), available_main.max(0.0)),
|
|
};
|
|
main_axis_size(
|
|
intrinsic_size(child, available_size, text_system, perf_stats),
|
|
direction,
|
|
)
|
|
}
|
|
|
|
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 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)),
|
|
);
|
|
}
|
|
|
|
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 {
|
|
FlexDirection::Column => {
|
|
let mut width: f32 = 0.0;
|
|
let mut height = gap_total;
|
|
for child in &element.children {
|
|
let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
|
|
let child_size = intrinsic_size(
|
|
child,
|
|
UiSize::new(
|
|
child.style.width.unwrap_or(content_size.width),
|
|
child.style.height.unwrap_or(content_size.height),
|
|
),
|
|
text_system,
|
|
perf_stats,
|
|
);
|
|
width = width.max(child.style.width.unwrap_or(child_size.width));
|
|
if !skip_main {
|
|
height += child.style.height.unwrap_or(child_size.height);
|
|
}
|
|
}
|
|
(width, height)
|
|
}
|
|
FlexDirection::Row => {
|
|
let mut width = gap_total;
|
|
let mut height: f32 = 0.0;
|
|
let mut fixed_main = 0.0;
|
|
let mut flex_total = 0.0;
|
|
let mut child_main_sizes = Vec::with_capacity(element.children.len());
|
|
for child in &element.children {
|
|
let is_flex = child.style.flex_grow > 0.0 && child.style.width.is_none();
|
|
if is_flex {
|
|
flex_total += child_flex_weight(child);
|
|
child_main_sizes.push(None);
|
|
continue;
|
|
}
|
|
let child_size = intrinsic_size(
|
|
child,
|
|
UiSize::new(
|
|
child.style.width.unwrap_or(content_size.width),
|
|
child.style.height.unwrap_or(content_size.height),
|
|
),
|
|
text_system,
|
|
perf_stats,
|
|
);
|
|
let child_main = child.style.width.unwrap_or(child_size.width);
|
|
fixed_main += child_main;
|
|
child_main_sizes.push(Some(child_main));
|
|
}
|
|
let remaining_main = (content_size.width - gap_total - fixed_main).max(0.0);
|
|
for (child, measured_main) in element.children.iter().zip(child_main_sizes.iter()) {
|
|
let skip_main = child.style.flex_grow > 0.0 && child.style.width.is_none();
|
|
let child_main = measured_main.unwrap_or_else(|| {
|
|
if flex_total <= 0.0 {
|
|
0.0
|
|
} else {
|
|
remaining_main * (child_flex_weight(child) / flex_total)
|
|
}
|
|
});
|
|
let child_size = intrinsic_size(
|
|
child,
|
|
UiSize::new(
|
|
child_main,
|
|
child.style.height.unwrap_or(content_size.height),
|
|
),
|
|
text_system,
|
|
perf_stats,
|
|
);
|
|
if !skip_main {
|
|
width += child_main;
|
|
}
|
|
height = height.max(child.style.height.unwrap_or(child_size.height));
|
|
}
|
|
(width, height)
|
|
}
|
|
};
|
|
|
|
UiSize::new(
|
|
explicit_width.unwrap_or(intrinsic_content_width + horizontal_insets(insets)),
|
|
explicit_height.unwrap_or(intrinsic_content_height + vertical_insets(insets)),
|
|
)
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct LayoutPerfStats {
|
|
enabled: bool,
|
|
nodes: usize,
|
|
text_nodes: usize,
|
|
container_nodes: usize,
|
|
background_quads: usize,
|
|
intrinsic_calls: usize,
|
|
intrinsic_size_calls: usize,
|
|
intrinsic_text_calls: usize,
|
|
intrinsic_container_calls: usize,
|
|
intrinsic_ms: f64,
|
|
text_prepare_calls: usize,
|
|
text_prepare_ms: f64,
|
|
}
|
|
|
|
impl LayoutPerfStats {
|
|
const fn new(enabled: bool) -> Self {
|
|
Self {
|
|
enabled,
|
|
nodes: 0,
|
|
text_nodes: 0,
|
|
container_nodes: 0,
|
|
background_quads: 0,
|
|
intrinsic_calls: 0,
|
|
intrinsic_size_calls: 0,
|
|
intrinsic_text_calls: 0,
|
|
intrinsic_container_calls: 0,
|
|
intrinsic_ms: 0.0,
|
|
text_prepare_calls: 0,
|
|
text_prepare_ms: 0.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option<ElementId>) -> PreparedImage {
|
|
let source_size = image.resource.size();
|
|
let source_aspect = if source_size.height > 0.0 {
|
|
source_size.width / source_size.height
|
|
} else {
|
|
1.0
|
|
};
|
|
let rect_aspect = if rect.size.height > 0.0 {
|
|
rect.size.width / rect.size.height
|
|
} else {
|
|
source_aspect
|
|
};
|
|
|
|
let (draw_rect, uv_rect) = match image.fit {
|
|
ImageFit::Fill => (rect, (0.0, 0.0, 1.0, 1.0)),
|
|
ImageFit::Contain => {
|
|
let scale = (rect.size.width / source_size.width)
|
|
.min(rect.size.height / source_size.height)
|
|
.max(0.0);
|
|
let width = source_size.width * scale;
|
|
let height = source_size.height * scale;
|
|
let x = rect.origin.x + (rect.size.width - width) * 0.5;
|
|
let y = rect.origin.y + (rect.size.height - height) * 0.5;
|
|
(Rect::new(x, y, width, height), (0.0, 0.0, 1.0, 1.0))
|
|
}
|
|
ImageFit::Cover => {
|
|
if rect_aspect > source_aspect {
|
|
let visible_height = (source_aspect / rect_aspect).clamp(0.0, 1.0);
|
|
let inset = (1.0 - visible_height) * 0.5;
|
|
(rect, (0.0, inset, 1.0, 1.0 - inset))
|
|
} else {
|
|
let visible_width = (rect_aspect / source_aspect).clamp(0.0, 1.0);
|
|
let inset = (1.0 - visible_width) * 0.5;
|
|
(rect, (inset, 0.0, 1.0 - inset, 1.0))
|
|
}
|
|
}
|
|
};
|
|
|
|
PreparedImage {
|
|
element_id,
|
|
resource: image.resource.clone(),
|
|
rect: draw_rect,
|
|
uv_rect,
|
|
}
|
|
}
|
|
|
|
fn resolve_image_element_size(element: &Element, intrinsic: UiSize) -> UiSize {
|
|
match (element.style.width, element.style.height) {
|
|
(Some(width), Some(height)) => UiSize::new(width.max(0.0), height.max(0.0)),
|
|
(Some(width), None) if intrinsic.width > 0.0 => UiSize::new(
|
|
width.max(0.0),
|
|
(width * intrinsic.height / intrinsic.width).max(0.0),
|
|
),
|
|
(None, Some(height)) if intrinsic.height > 0.0 => UiSize::new(
|
|
(height * intrinsic.width / intrinsic.height).max(0.0),
|
|
height.max(0.0),
|
|
),
|
|
_ => intrinsic,
|
|
}
|
|
}
|
|
|
|
fn push_box_shadows(
|
|
scene: &mut SceneSnapshot,
|
|
rect: Rect,
|
|
style: &Style,
|
|
kind: crate::BoxShadowKind,
|
|
perf_stats: &mut LayoutPerfStats,
|
|
) {
|
|
let source_radius = uniform_corner_radius(style, rect);
|
|
for shadow in &style.box_shadows {
|
|
let shadow_rect = Rect::new(
|
|
rect.origin.x + shadow.offset.x - shadow.spread,
|
|
rect.origin.y + shadow.offset.y - shadow.spread,
|
|
rect.size.width + shadow.spread * 2.0,
|
|
rect.size.height + shadow.spread * 2.0,
|
|
);
|
|
if shadow.kind != kind || shadow_rect.size.width <= 0.0 || shadow_rect.size.height <= 0.0 {
|
|
continue;
|
|
}
|
|
|
|
perf_stats.background_quads += 1;
|
|
scene.push_shadow_rect(ShadowRect {
|
|
rect: shadow_rect,
|
|
source_rect: rect,
|
|
color: shadow.color,
|
|
blur: shadow.blur.max(0.0),
|
|
radius: clamp_corner_radius(source_radius + shadow.spread, shadow_rect),
|
|
source_radius,
|
|
kind,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn push_container_fill_and_border(
|
|
scene: &mut SceneSnapshot,
|
|
rect: Rect,
|
|
style: &Style,
|
|
perf_stats: &mut LayoutPerfStats,
|
|
) {
|
|
let radius = uniform_corner_radius(style, rect);
|
|
let border_width = style
|
|
.border
|
|
.map(|border| {
|
|
border
|
|
.width
|
|
.clamp(0.0, rect.size.width * 0.5)
|
|
.min(rect.size.height * 0.5)
|
|
})
|
|
.unwrap_or(0.0);
|
|
let border_color = style.border.map(|border| border.color);
|
|
|
|
if radius > 0.0 && (style.background.is_some() || border_width > 0.0) {
|
|
perf_stats.background_quads += 1;
|
|
scene.push_rounded_rect(RoundedRect {
|
|
rect,
|
|
fill: style.background,
|
|
border_color,
|
|
border_width,
|
|
radius,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if let Some(color) = style.background {
|
|
perf_stats.background_quads += 1;
|
|
scene.push_quad(rect, color);
|
|
}
|
|
|
|
let Some(border) = style.border else {
|
|
return;
|
|
};
|
|
if border_width <= 0.0 {
|
|
return;
|
|
}
|
|
|
|
let top = Rect::new(rect.origin.x, rect.origin.y, rect.size.width, border_width);
|
|
let bottom = Rect::new(
|
|
rect.origin.x,
|
|
rect.origin.y + rect.size.height - border_width,
|
|
rect.size.width,
|
|
border_width,
|
|
);
|
|
let middle_height = (rect.size.height - border_width * 2.0).max(0.0);
|
|
let left = Rect::new(
|
|
rect.origin.x,
|
|
rect.origin.y + border_width,
|
|
border_width,
|
|
middle_height,
|
|
);
|
|
let right = Rect::new(
|
|
rect.origin.x + rect.size.width - border_width,
|
|
rect.origin.y + border_width,
|
|
border_width,
|
|
middle_height,
|
|
);
|
|
|
|
for border_rect in [top, bottom, left, right] {
|
|
if border_rect.size.width <= 0.0 || border_rect.size.height <= 0.0 {
|
|
continue;
|
|
}
|
|
perf_stats.background_quads += 1;
|
|
scene.push_quad(border_rect, border.color);
|
|
}
|
|
}
|
|
|
|
fn content_insets(style: &Style) -> Edges {
|
|
let border = style
|
|
.border
|
|
.map(|border| border.width.max(0.0))
|
|
.unwrap_or(0.0);
|
|
Edges {
|
|
top: style.padding.top + border,
|
|
right: style.padding.right + border,
|
|
bottom: style.padding.bottom + border,
|
|
left: style.padding.left + border,
|
|
}
|
|
}
|
|
|
|
fn uniform_corner_radius(style: &Style, rect: Rect) -> f32 {
|
|
clamp_corner_radius(
|
|
style
|
|
.corner_radius
|
|
.top_left
|
|
.max(style.corner_radius.top_right)
|
|
.max(style.corner_radius.bottom_right)
|
|
.max(style.corner_radius.bottom_left),
|
|
rect,
|
|
)
|
|
}
|
|
|
|
fn clamp_corner_radius(radius: f32, rect: Rect) -> f32 {
|
|
radius
|
|
.max(0.0)
|
|
.min(rect.size.width * 0.5)
|
|
.min(rect.size.height * 0.5)
|
|
}
|
|
|
|
fn horizontal_insets(edges: Edges) -> f32 {
|
|
edges.left + edges.right
|
|
}
|
|
|
|
fn vertical_insets(edges: Edges) -> f32 {
|
|
edges.top + edges.bottom
|
|
}
|
|
|
|
fn inset_rect(rect: Rect, edges: Edges) -> Rect {
|
|
let width = (rect.size.width - edges.left - edges.right).max(0.0);
|
|
let height = (rect.size.height - edges.top - edges.bottom).max(0.0);
|
|
Rect::new(
|
|
rect.origin.x + edges.left,
|
|
rect.origin.y + edges.top,
|
|
width,
|
|
height,
|
|
)
|
|
}
|
|
|
|
fn child_main_size(child: &Element, direction: FlexDirection) -> Option<f32> {
|
|
match direction {
|
|
FlexDirection::Row => child.style.width,
|
|
FlexDirection::Column => child.style.height,
|
|
}
|
|
}
|
|
|
|
fn child_cross_size(child: &Element, direction: FlexDirection) -> Option<f32> {
|
|
match direction {
|
|
FlexDirection::Row => child.style.height,
|
|
FlexDirection::Column => child.style.width,
|
|
}
|
|
}
|
|
|
|
fn child_flex_weight(child: &Element) -> f32 {
|
|
if child.style.flex_grow > 0.0 {
|
|
child.style.flex_grow
|
|
} else {
|
|
1.0
|
|
}
|
|
}
|
|
|
|
fn main_axis_padding(edges: Edges, direction: FlexDirection) -> f32 {
|
|
match direction {
|
|
FlexDirection::Row => edges.left + edges.right,
|
|
FlexDirection::Column => edges.top + edges.bottom,
|
|
}
|
|
}
|
|
|
|
fn main_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
|
|
match direction {
|
|
FlexDirection::Row => size.width,
|
|
FlexDirection::Column => size.height,
|
|
}
|
|
}
|
|
|
|
fn cross_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
|
|
match direction {
|
|
FlexDirection::Row => size.height,
|
|
FlexDirection::Column => size.width,
|
|
}
|
|
}
|
|
|
|
fn main_axis_origin(rect: Rect, direction: FlexDirection) -> f32 {
|
|
match direction {
|
|
FlexDirection::Row => rect.origin.x,
|
|
FlexDirection::Column => rect.origin.y,
|
|
}
|
|
}
|
|
|
|
fn child_rect(
|
|
content: Rect,
|
|
direction: FlexDirection,
|
|
main_origin: f32,
|
|
main_size: f32,
|
|
cross_size: f32,
|
|
) -> Rect {
|
|
match direction {
|
|
FlexDirection::Row => Rect::new(main_origin, content.origin.y, main_size, cross_size),
|
|
FlexDirection::Column => Rect::new(content.origin.x, main_origin, cross_size, main_size),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{layout_scene, layout_snapshot};
|
|
use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
|
|
use crate::text::{TextStyle, TextWrap};
|
|
use crate::tree::{Edges, Element, ElementId};
|
|
|
|
#[test]
|
|
fn row_layout_apportions_fixed_and_flex_children() {
|
|
let root = Element::row()
|
|
.padding(Edges::all(10.0))
|
|
.gap(10.0)
|
|
.children([
|
|
Element::new()
|
|
.width(50.0)
|
|
.background(Color::rgb(0xAA, 0x11, 0x11)),
|
|
Element::new()
|
|
.flex(1.0)
|
|
.background(Color::rgb(0x11, 0xAA, 0x11)),
|
|
Element::new()
|
|
.flex(2.0)
|
|
.background(Color::rgb(0x11, 0x11, 0xAA)),
|
|
]);
|
|
|
|
let scene = layout_scene(1, UiSize::new(300.0, 100.0), &root);
|
|
let quads: Vec<Quad> = scene
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
DisplayItem::Quad(quad) => Some(*quad),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
quads,
|
|
vec![
|
|
Quad::new(
|
|
Rect::new(10.0, 10.0, 50.0, 80.0),
|
|
Color::rgb(0xAA, 0x11, 0x11)
|
|
),
|
|
Quad::new(
|
|
Rect::new(70.0, 10.0, 70.0, 80.0),
|
|
Color::rgb(0x11, 0xAA, 0x11)
|
|
),
|
|
Quad::new(
|
|
Rect::new(150.0, 10.0, 140.0, 80.0),
|
|
Color::rgb(0x11, 0x11, 0xAA)
|
|
),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn column_layout_reflows_text_and_moves_following_children() {
|
|
let root = Element::column()
|
|
.padding(Edges::all(10.0))
|
|
.gap(10.0)
|
|
.children([
|
|
Element::text(
|
|
"RUIN text nodes should reflow inside narrow layout columns instead of acting like overlays.",
|
|
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
|
.with_line_height(20.0)
|
|
.with_wrap(TextWrap::Word),
|
|
)
|
|
.padding(Edges::all(8.0))
|
|
.background(Color::rgb(0x22, 0x2C, 0x46)),
|
|
Element::new()
|
|
.height(40.0)
|
|
.background(Color::rgb(0x44, 0x55, 0x66)),
|
|
]);
|
|
|
|
let scene = layout_scene(1, UiSize::new(160.0, 220.0), &root);
|
|
let text = scene
|
|
.items
|
|
.iter()
|
|
.find_map(|item| match item {
|
|
DisplayItem::Text(text) => Some(text),
|
|
_ => None,
|
|
})
|
|
.expect("layout should emit a text display item");
|
|
let quads: Vec<Quad> = scene
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
DisplayItem::Quad(quad) => Some(*quad),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
|
|
let text_bounds = text.bounds.expect("text layout should provide bounds");
|
|
assert_eq!(text.origin.x, 18.0);
|
|
assert_eq!(text.origin.y, 18.0);
|
|
assert_eq!(text_bounds.width, 124.0);
|
|
assert!(text_bounds.height > 20.0);
|
|
assert_eq!(quads.len(), 2);
|
|
assert!(quads[1].rect.origin.y > text.origin.y + text_bounds.height);
|
|
}
|
|
|
|
#[test]
|
|
fn column_container_with_text_children_gets_intrinsic_height() {
|
|
let root = Element::column().child(
|
|
Element::column()
|
|
.padding(Edges::all(12.0))
|
|
.background(Color::rgb(0x22, 0x33, 0x44))
|
|
.child(Element::paragraph(
|
|
"Paragraph containers should not collapse to zero height anymore.",
|
|
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
|
|
)),
|
|
);
|
|
|
|
let scene = layout_scene(1, UiSize::new(420.0, 240.0), &root);
|
|
let quads: Vec<Quad> = scene
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
DisplayItem::Quad(quad) => Some(*quad),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
|
|
assert!(quads.iter().any(|quad| quad.rect.size.height > 24.0));
|
|
assert!(
|
|
scene
|
|
.items
|
|
.iter()
|
|
.any(|item| matches!(item, DisplayItem::Text(_)))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bordered_container_insets_text_and_emits_border_quads() {
|
|
let border_color = Color::rgb(0xF5, 0xD0, 0x74);
|
|
let root = Element::column().child(
|
|
Element::column()
|
|
.background(Color::rgb(0x22, 0x33, 0x44))
|
|
.border(4.0, border_color)
|
|
.padding(Edges::all(10.0))
|
|
.child(Element::paragraph(
|
|
"Bordered content should be inset past the stroke.",
|
|
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
|
|
)),
|
|
);
|
|
|
|
let scene = layout_scene(1, UiSize::new(320.0, 160.0), &root);
|
|
let text = scene
|
|
.items
|
|
.iter()
|
|
.find_map(|item| match item {
|
|
DisplayItem::Text(text) => Some(text),
|
|
_ => None,
|
|
})
|
|
.expect("bordered container should emit text");
|
|
let border_quads: Vec<Quad> = scene
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
DisplayItem::Quad(quad) if quad.color == border_color => Some(*quad),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
|
|
assert_eq!(text.origin.x, 14.0);
|
|
assert_eq!(text.origin.y, 14.0);
|
|
assert_eq!(border_quads.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn row_container_counts_flex_child_height_in_intrinsic_size() {
|
|
let row_color = Color::rgb(0x12, 0x24, 0x36);
|
|
let root = Element::column().child(
|
|
Element::row()
|
|
.padding(Edges::all(12.0))
|
|
.gap(16.0)
|
|
.background(row_color)
|
|
.children([
|
|
Element::new()
|
|
.width(120.0)
|
|
.height(60.0)
|
|
.background(Color::rgb(0x44, 0x55, 0x66)),
|
|
Element::column().flex(1.0).child(Element::paragraph(
|
|
"A flex child with wrapped text should make the row grow tall enough to contain it.",
|
|
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
|
.with_line_height(24.0)
|
|
.with_wrap(TextWrap::Word),
|
|
)),
|
|
]),
|
|
);
|
|
|
|
let scene = layout_scene(1, UiSize::new(360.0, 320.0), &root);
|
|
let row_quad = scene
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
DisplayItem::Quad(quad) if quad.color == row_color => Some(*quad),
|
|
_ => None,
|
|
})
|
|
.next()
|
|
.expect("row container should emit a background quad");
|
|
let text = scene
|
|
.items
|
|
.iter()
|
|
.find_map(|item| match item {
|
|
DisplayItem::Text(text) => Some(text),
|
|
_ => None,
|
|
})
|
|
.expect("row should emit a text display item");
|
|
let text_bounds = text.bounds.expect("text layout should provide bounds");
|
|
let row_bottom = row_quad.rect.origin.y + row_quad.rect.size.height;
|
|
let text_bottom = text.origin.y + text_bounds.height;
|
|
|
|
assert!(row_quad.rect.size.height > 84.0);
|
|
assert!(row_bottom >= text_bottom);
|
|
assert!(
|
|
scene
|
|
.items
|
|
.iter()
|
|
.any(|item| matches!(item, DisplayItem::Text(_)))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rounded_container_emits_rounded_rect_and_clip_items() {
|
|
let root = Element::column().child(
|
|
Element::column()
|
|
.background(Color::rgb(0x18, 0x20, 0x2F))
|
|
.border(2.0, Color::rgb(0xF5, 0xD0, 0x74))
|
|
.corner_radius(18.0)
|
|
.padding(Edges::all(12.0))
|
|
.child(Element::paragraph(
|
|
"Rounded containers should emit a rounded paint item and clip their children.",
|
|
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
|
|
)),
|
|
);
|
|
|
|
let scene = layout_scene(1, UiSize::new(320.0, 180.0), &root);
|
|
assert!(
|
|
scene
|
|
.items
|
|
.iter()
|
|
.any(|item| matches!(item, DisplayItem::RoundedRect(_)))
|
|
);
|
|
assert!(
|
|
scene
|
|
.items
|
|
.iter()
|
|
.any(|item| matches!(item, DisplayItem::PushClip(_)))
|
|
);
|
|
assert!(
|
|
scene
|
|
.items
|
|
.iter()
|
|
.any(|item| matches!(item, DisplayItem::PopClip))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shadowed_container_emits_shadow_rect_items() {
|
|
let root = Element::column().child(
|
|
Element::column()
|
|
.background(Color::rgb(0x18, 0x20, 0x2F))
|
|
.corner_radius(18.0)
|
|
.shadow(crate::BoxShadow::new(
|
|
crate::Point::new(0.0, 8.0),
|
|
18.0,
|
|
-2.0,
|
|
Color::rgba(0x05, 0x08, 0x0F, 0x90),
|
|
crate::BoxShadowKind::Outer,
|
|
))
|
|
.shadow(crate::BoxShadow::new(
|
|
crate::Point::new(0.0, 4.0),
|
|
8.0,
|
|
0.0,
|
|
Color::rgba(0x00, 0x00, 0x00, 0x70),
|
|
crate::BoxShadowKind::Inner,
|
|
))
|
|
.child(Element::paragraph(
|
|
"Shadowed containers should emit dedicated shadow paint items.",
|
|
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
|
|
)),
|
|
);
|
|
|
|
let scene = layout_scene(1, UiSize::new(320.0, 180.0), &root);
|
|
assert!(
|
|
scene
|
|
.items
|
|
.iter()
|
|
.filter(|item| matches!(item, DisplayItem::ShadowRect(_)))
|
|
.count()
|
|
>= 2
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_tree_hit_test_returns_deepest_pointer_target() {
|
|
let root = Element::column()
|
|
.id(ElementId::new(1))
|
|
.children([Element::new()
|
|
.id(ElementId::new(2))
|
|
.height(80.0)
|
|
.background(Color::rgb(0x22, 0x33, 0x44))
|
|
.child(
|
|
Element::new()
|
|
.id(ElementId::new(3))
|
|
.width(120.0)
|
|
.height(40.0)
|
|
.background(Color::rgb(0x44, 0x55, 0x66)),
|
|
)]);
|
|
|
|
let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
|
|
let hit = snapshot
|
|
.interaction_tree
|
|
.hit_test(Point::new(20.0, 20.0))
|
|
.expect("point should hit nested child");
|
|
assert_eq!(hit.element_id, Some(ElementId::new(3)));
|
|
assert_eq!(hit.path.segments(), &[0, 0]);
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_tree_skips_pointer_disabled_node_and_falls_back_to_parent() {
|
|
let root = Element::column().id(ElementId::new(1)).child(
|
|
Element::new()
|
|
.id(ElementId::new(2))
|
|
.height(80.0)
|
|
.background(Color::rgb(0x22, 0x33, 0x44))
|
|
.child(
|
|
Element::new()
|
|
.id(ElementId::new(3))
|
|
.width(120.0)
|
|
.height(40.0)
|
|
.pointer_events(false)
|
|
.background(Color::rgb(0x44, 0x55, 0x66)),
|
|
),
|
|
);
|
|
|
|
let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
|
|
let hit = snapshot
|
|
.interaction_tree
|
|
.hit_test(Point::new(20.0, 20.0))
|
|
.expect("point should still hit parent");
|
|
assert_eq!(hit.element_id, Some(ElementId::new(2)));
|
|
}
|
|
|
|
#[test]
|
|
fn rounded_corner_hit_test_excludes_clipped_corner_region() {
|
|
let root = Element::column().pointer_events(false).child(
|
|
Element::column()
|
|
.id(ElementId::new(2))
|
|
.width(120.0)
|
|
.height(80.0)
|
|
.corner_radius(20.0)
|
|
.background(Color::rgb(0x22, 0x33, 0x44)),
|
|
);
|
|
|
|
let snapshot = layout_snapshot(1, UiSize::new(160.0, 120.0), &root);
|
|
assert!(
|
|
snapshot
|
|
.interaction_tree
|
|
.hit_test(Point::new(4.0, 4.0))
|
|
.is_none()
|
|
);
|
|
let hit = snapshot
|
|
.interaction_tree
|
|
.hit_test(Point::new(24.0, 24.0))
|
|
.expect("point inside rounded body should hit child");
|
|
assert_eq!(hit.element_id, Some(ElementId::new(2)));
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_tree_hit_path_includes_pointer_ancestors() {
|
|
let root = Element::column().pointer_events(false).child(
|
|
Element::new()
|
|
.id(ElementId::new(2))
|
|
.height(80.0)
|
|
.background(Color::rgb(0x22, 0x33, 0x44))
|
|
.child(
|
|
Element::new()
|
|
.id(ElementId::new(3))
|
|
.width(120.0)
|
|
.height(40.0)
|
|
.background(Color::rgb(0x44, 0x55, 0x66)),
|
|
),
|
|
);
|
|
|
|
let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
|
|
let hit_path = snapshot.interaction_tree.hit_path(Point::new(20.0, 20.0));
|
|
let hit_ids: Vec<Option<ElementId>> =
|
|
hit_path.iter().map(|target| target.element_id).collect();
|
|
assert_eq!(
|
|
hit_ids,
|
|
vec![Some(ElementId::new(2)), Some(ElementId::new(3))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_tree_exposes_prepared_text_for_text_nodes() {
|
|
let text_id = ElementId::new(9);
|
|
let root = Element::column().child(
|
|
Element::paragraph(
|
|
"Selection should be able to map pointer positions back into prepared text.",
|
|
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
|
|
)
|
|
.id(text_id),
|
|
);
|
|
|
|
let snapshot = layout_snapshot(1, UiSize::new(420.0, 240.0), &root);
|
|
let prepared_text = snapshot
|
|
.interaction_tree
|
|
.text_for_element(text_id)
|
|
.expect("text node should expose prepared text");
|
|
let text_hit = snapshot
|
|
.interaction_tree
|
|
.text_hit_test(Point::new(12.0, 12.0))
|
|
.expect("point should hit prepared text");
|
|
|
|
assert_eq!(prepared_text.element_id, Some(text_id));
|
|
assert_eq!(text_hit.target.element_id, Some(text_id));
|
|
assert!(text_hit.byte_offset <= prepared_text.text.len());
|
|
}
|
|
|
|
#[test]
|
|
fn interaction_tree_skips_unselectable_text_nodes() {
|
|
let text_id = ElementId::new(10);
|
|
let root = Element::column().child(
|
|
Element::paragraph(
|
|
"Titles and labels can opt out of text selection.",
|
|
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
|
.with_line_height(24.0)
|
|
.with_selectable(false),
|
|
)
|
|
.id(text_id),
|
|
);
|
|
|
|
let snapshot = layout_snapshot(1, UiSize::new(420.0, 240.0), &root);
|
|
assert!(
|
|
snapshot
|
|
.interaction_tree
|
|
.text_for_element(text_id)
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
snapshot
|
|
.interaction_tree
|
|
.text_hit_test(Point::new(12.0, 12.0))
|
|
.is_none()
|
|
);
|
|
}
|
|
}
|