More text improvements, performance enhancements, input handling, text selection, wl cursors
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
use crate::scene::{Rect, SceneSnapshot, UiSize};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::scene::{PreparedText, Rect, SceneSnapshot, UiSize};
|
||||
use crate::text::TextSystem;
|
||||
use crate::tree::{Edges, Element, ElementId, FlexDirection};
|
||||
use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection};
|
||||
|
||||
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
|
||||
let mut text_system = TextSystem::new();
|
||||
@@ -46,6 +48,7 @@ pub struct HitTarget {
|
||||
pub path: LayoutPath,
|
||||
pub element_id: Option<ElementId>,
|
||||
pub rect: Rect,
|
||||
pub cursor: CursorIcon,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -54,9 +57,17 @@ pub struct LayoutNode {
|
||||
pub element_id: Option<ElementId>,
|
||||
pub rect: Rect,
|
||||
pub pointer_events: bool,
|
||||
pub cursor: CursorIcon,
|
||||
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,
|
||||
@@ -64,7 +75,23 @@ pub struct InteractionTree {
|
||||
|
||||
impl InteractionTree {
|
||||
pub fn hit_test(&self, point: crate::scene::Point) -> Option<HitTarget> {
|
||||
hit_test_node(&self.root, point)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +106,10 @@ pub fn layout_snapshot_with_text_system(
|
||||
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,
|
||||
@@ -91,7 +122,39 @@ pub fn layout_snapshot_with_text_system(
|
||||
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 {
|
||||
@@ -106,12 +169,23 @@ fn layout_element(
|
||||
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,
|
||||
pointer_events: element.style.pointer_events,
|
||||
cursor,
|
||||
prepared_text: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -120,21 +194,34 @@ fn layout_element(
|
||||
}
|
||||
|
||||
if let Some(color) = element.style.background {
|
||||
perf_stats.background_quads += 1;
|
||||
scene.push_quad(rect, color);
|
||||
}
|
||||
|
||||
if let Some(text) = element.text_node() {
|
||||
perf_stats.text_nodes += 1;
|
||||
let content = inset_rect(rect, element.style.padding);
|
||||
if content.size.width > 0.0 && content.size.height > 0.0 {
|
||||
scene.push_text(text_system.prepare_spans(
|
||||
text.spans.clone(),
|
||||
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.clone().with_bounds(content.size),
|
||||
));
|
||||
&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;
|
||||
}
|
||||
}
|
||||
return interaction;
|
||||
}
|
||||
|
||||
perf_stats.container_nodes += 1;
|
||||
|
||||
if element.children.is_empty() {
|
||||
return interaction;
|
||||
}
|
||||
@@ -164,13 +251,25 @@ fn layout_element(
|
||||
if is_flex {
|
||||
0.0
|
||||
} else {
|
||||
intrinsic_main_size(
|
||||
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 {
|
||||
@@ -211,6 +310,7 @@ fn layout_element(
|
||||
interaction.path.child(index),
|
||||
scene,
|
||||
text_system,
|
||||
perf_stats,
|
||||
));
|
||||
cursor += child_main.max(0.0) + element.style.gap;
|
||||
}
|
||||
@@ -218,23 +318,78 @@ fn layout_element(
|
||||
interaction
|
||||
}
|
||||
|
||||
fn hit_test_node(node: &LayoutNode, point: crate::scene::Point) -> Option<HitTarget> {
|
||||
fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option<Vec<HitTarget>> {
|
||||
if !node.rect.contains(point) {
|
||||
return None;
|
||||
}
|
||||
|
||||
for child in node.children.iter().rev() {
|
||||
if let Some(hit) = hit_test_node(child, point) {
|
||||
return Some(hit);
|
||||
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,
|
||||
cursor: node.cursor,
|
||||
});
|
||||
}
|
||||
return Some(hits);
|
||||
}
|
||||
}
|
||||
|
||||
if node.pointer_events {
|
||||
return Some(HitTarget {
|
||||
return Some(vec![HitTarget {
|
||||
path: node.path.clone(),
|
||||
element_id: node.element_id,
|
||||
rect: node.rect,
|
||||
});
|
||||
cursor: node.cursor,
|
||||
}]);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn text_hit_test_node(node: &LayoutNode, point: crate::scene::Point) -> Option<TextHitTarget> {
|
||||
if !node.rect.contains(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,
|
||||
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
|
||||
@@ -253,18 +408,15 @@ fn intrinsic_main_size(
|
||||
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.clone(),
|
||||
text.style.clone(),
|
||||
constraints.0,
|
||||
constraints.1,
|
||||
);
|
||||
let content =
|
||||
text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1);
|
||||
let padding = main_axis_padding(child.style.padding, direction);
|
||||
return main_axis_size(content, direction) + padding;
|
||||
}
|
||||
@@ -274,7 +426,7 @@ fn intrinsic_main_size(
|
||||
FlexDirection::Column => UiSize::new(cross_size.max(0.0), available_main.max(0.0)),
|
||||
};
|
||||
main_axis_size(
|
||||
intrinsic_size(child, available_size, text_system),
|
||||
intrinsic_size(child, available_size, text_system, perf_stats),
|
||||
direction,
|
||||
)
|
||||
}
|
||||
@@ -283,11 +435,13 @@ fn intrinsic_size(
|
||||
element: &Element,
|
||||
available_size: UiSize,
|
||||
text_system: &mut TextSystem,
|
||||
perf_stats: &mut LayoutPerfStats,
|
||||
) -> UiSize {
|
||||
perf_stats.intrinsic_size_calls += 1;
|
||||
if let Some(text) = element.text_node() {
|
||||
let measured = text_system.measure_spans(
|
||||
text.spans.clone(),
|
||||
text.style.clone(),
|
||||
&text.spans,
|
||||
&text.style,
|
||||
Some(available_size.width.max(0.0)),
|
||||
Some(available_size.height.max(0.0)),
|
||||
);
|
||||
@@ -334,6 +488,7 @@ fn intrinsic_size(
|
||||
child.style.height.unwrap_or(content_size.height),
|
||||
),
|
||||
text_system,
|
||||
perf_stats,
|
||||
);
|
||||
width = width.max(child.style.width.unwrap_or(child_size.width));
|
||||
height += child.style.height.unwrap_or(child_size.height);
|
||||
@@ -354,6 +509,7 @@ fn intrinsic_size(
|
||||
child.style.height.unwrap_or(content_size.height),
|
||||
),
|
||||
text_system,
|
||||
perf_stats,
|
||||
);
|
||||
width += child.style.width.unwrap_or(child_size.width);
|
||||
height = height.max(child.style.height.unwrap_or(child_size.height));
|
||||
@@ -372,6 +528,41 @@ fn intrinsic_size(
|
||||
)
|
||||
}
|
||||
|
||||
#[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 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);
|
||||
@@ -625,4 +816,84 @@ mod tests {
|
||||
.expect("point should still hit parent");
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user