More text improvements, performance enhancements, input handling, text selection, wl cursors
This commit is contained in:
@@ -50,7 +50,7 @@ pub struct RoutedPointerEvent {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PointerRouter {
|
||||
hovered: Option<HitTarget>,
|
||||
hovered: Vec<HitTarget>,
|
||||
pressed: Option<HitTarget>,
|
||||
}
|
||||
|
||||
@@ -60,7 +60,11 @@ impl PointerRouter {
|
||||
}
|
||||
|
||||
pub fn hovered_target(&self) -> Option<&HitTarget> {
|
||||
self.hovered.as_ref()
|
||||
self.hovered.last()
|
||||
}
|
||||
|
||||
pub fn hovered_targets(&self) -> &[HitTarget] {
|
||||
&self.hovered
|
||||
}
|
||||
|
||||
pub fn pressed_target(&self) -> Option<&HitTarget> {
|
||||
@@ -72,14 +76,21 @@ impl PointerRouter {
|
||||
interaction_tree: &InteractionTree,
|
||||
event: PointerEvent,
|
||||
) -> Vec<RoutedPointerEvent> {
|
||||
let hit_target = match event.kind {
|
||||
PointerEventKind::LeaveWindow => None,
|
||||
_ => interaction_tree.hit_test(event.position),
|
||||
let hovered_targets = match event.kind {
|
||||
PointerEventKind::LeaveWindow => Vec::new(),
|
||||
_ => interaction_tree.hit_path(event.position),
|
||||
};
|
||||
let hit_target = hovered_targets.last().cloned();
|
||||
|
||||
let mut routed = Vec::new();
|
||||
if self.hovered != hit_target {
|
||||
if let Some(previous) = self.hovered.take() {
|
||||
let shared_depth = self
|
||||
.hovered
|
||||
.iter()
|
||||
.zip(hovered_targets.iter())
|
||||
.take_while(|(previous, next)| previous == next)
|
||||
.count();
|
||||
if shared_depth != self.hovered.len() || shared_depth != hovered_targets.len() {
|
||||
for previous in self.hovered[shared_depth..].iter().rev().cloned() {
|
||||
routed.push(RoutedPointerEvent {
|
||||
kind: RoutedPointerEventKind::Leave,
|
||||
target: previous,
|
||||
@@ -87,20 +98,20 @@ impl PointerRouter {
|
||||
position: event.position,
|
||||
});
|
||||
}
|
||||
if let Some(target) = hit_target.clone() {
|
||||
for target in hovered_targets[shared_depth..].iter().cloned() {
|
||||
routed.push(RoutedPointerEvent {
|
||||
kind: RoutedPointerEventKind::Enter,
|
||||
target: target.clone(),
|
||||
target,
|
||||
pointer_id: event.pointer_id,
|
||||
position: event.position,
|
||||
});
|
||||
self.hovered = Some(target);
|
||||
}
|
||||
self.hovered = hovered_targets;
|
||||
}
|
||||
|
||||
match event.kind {
|
||||
PointerEventKind::Move => {
|
||||
if let Some(target) = hit_target {
|
||||
if let Some(target) = hit_target.clone() {
|
||||
routed.push(RoutedPointerEvent {
|
||||
kind: RoutedPointerEventKind::Move,
|
||||
target,
|
||||
@@ -144,7 +155,7 @@ mod tests {
|
||||
};
|
||||
use crate::layout::{InteractionTree, LayoutNode, LayoutPath};
|
||||
use crate::scene::{Point, Rect};
|
||||
use crate::tree::ElementId;
|
||||
use crate::tree::{CursorIcon, ElementId};
|
||||
|
||||
fn interaction_tree() -> InteractionTree {
|
||||
InteractionTree {
|
||||
@@ -153,12 +164,16 @@ mod tests {
|
||||
element_id: None,
|
||||
rect: Rect::new(0.0, 0.0, 200.0, 120.0),
|
||||
pointer_events: false,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
children: vec![
|
||||
LayoutNode {
|
||||
path: LayoutPath::root().child(0),
|
||||
element_id: Some(ElementId::new(1)),
|
||||
rect: Rect::new(0.0, 0.0, 120.0, 120.0),
|
||||
pointer_events: true,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
children: Vec::new(),
|
||||
},
|
||||
LayoutNode {
|
||||
@@ -166,6 +181,8 @@ mod tests {
|
||||
element_id: Some(ElementId::new(2)),
|
||||
rect: Rect::new(80.0, 0.0, 120.0, 120.0),
|
||||
pointer_events: true,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
children: Vec::new(),
|
||||
},
|
||||
],
|
||||
@@ -173,6 +190,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn nested_interaction_tree() -> InteractionTree {
|
||||
InteractionTree {
|
||||
root: LayoutNode {
|
||||
path: LayoutPath::root(),
|
||||
element_id: None,
|
||||
rect: Rect::new(0.0, 0.0, 200.0, 120.0),
|
||||
pointer_events: false,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
children: vec![LayoutNode {
|
||||
path: LayoutPath::root().child(0),
|
||||
element_id: Some(ElementId::new(1)),
|
||||
rect: Rect::new(0.0, 0.0, 160.0, 120.0),
|
||||
pointer_events: true,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
children: vec![LayoutNode {
|
||||
path: LayoutPath::root().child(0).child(0),
|
||||
element_id: Some(ElementId::new(2)),
|
||||
rect: Rect::new(16.0, 16.0, 80.0, 40.0),
|
||||
pointer_events: true,
|
||||
cursor: CursorIcon::Default,
|
||||
prepared_text: None,
|
||||
children: Vec::new(),
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_emits_enter_and_move_for_new_hover_target() {
|
||||
let mut router = PointerRouter::new();
|
||||
@@ -223,4 +270,66 @@ mod tests {
|
||||
Some(ElementId::new(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_tracks_hovered_ancestors_for_nested_hits() {
|
||||
let mut router = PointerRouter::new();
|
||||
let routed = router.route(
|
||||
&nested_interaction_tree(),
|
||||
PointerEvent::new(0, Point::new(24.0, 24.0), PointerEventKind::Move),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
routed
|
||||
.iter()
|
||||
.filter(|event| event.kind == RoutedPointerEventKind::Enter)
|
||||
.map(|event| event.target.element_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some(ElementId::new(1)), Some(ElementId::new(2))]
|
||||
);
|
||||
assert_eq!(
|
||||
router
|
||||
.hovered_targets()
|
||||
.iter()
|
||||
.map(|target| target.element_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some(ElementId::new(1)), Some(ElementId::new(2))]
|
||||
);
|
||||
assert_eq!(
|
||||
router.hovered_target().unwrap().element_id,
|
||||
Some(ElementId::new(2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn router_leaves_only_nested_child_when_pointer_moves_to_parent() {
|
||||
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(140.0, 24.0), PointerEventKind::Move),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
routed
|
||||
.iter()
|
||||
.filter(|event| event.kind == RoutedPointerEventKind::Leave)
|
||||
.map(|event| event.target.element_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some(ElementId::new(2))]
|
||||
);
|
||||
assert_eq!(
|
||||
router
|
||||
.hovered_targets()
|
||||
.iter()
|
||||
.map(|target| target.element_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some(ElementId::new(1))]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,21 +24,24 @@ pub use interaction::{
|
||||
RoutedPointerEventKind,
|
||||
};
|
||||
pub use layout::{
|
||||
HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, layout_snapshot,
|
||||
layout_snapshot_with_text_system,
|
||||
HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, TextHitTarget,
|
||||
layout_snapshot, layout_snapshot_with_text_system,
|
||||
};
|
||||
pub use layout::{layout_scene, layout_scene_with_text_system};
|
||||
pub use platform::{PlatformClosed, PlatformEvent, PlatformProxy, PlatformRuntime, start_headless};
|
||||
pub use platform::{
|
||||
PlatformClosed, PlatformEndpoint, PlatformEvent, PlatformProxy, PlatformRequest,
|
||||
PlatformRuntime, start_headless,
|
||||
};
|
||||
pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
|
||||
pub use scene::{
|
||||
Color, DisplayItem, GlyphInstance, Point, PreparedText, Quad, Rect, SceneSnapshot, Translation,
|
||||
UiSize,
|
||||
Color, DisplayItem, GlyphInstance, Point, PreparedText, PreparedTextLine, Quad, Rect,
|
||||
SceneSnapshot, Translation, UiSize,
|
||||
};
|
||||
pub use text::{
|
||||
TextAlign, TextFontFamily, TextSpan, TextSpanSlant, TextSpanWeight, TextStyle, TextSystem,
|
||||
TextWrap,
|
||||
TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight,
|
||||
TextStyle, TextSystem, TextWrap,
|
||||
};
|
||||
pub use tree::{Edges, Element, ElementId, FlexDirection, Style};
|
||||
pub use tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, Style};
|
||||
pub use window::{
|
||||
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,
|
||||
};
|
||||
|
||||
@@ -14,11 +14,12 @@ use tracing::{debug, info};
|
||||
use crate::interaction::PointerEvent;
|
||||
use crate::scene::{SceneSnapshot, UiSize};
|
||||
use crate::trace_targets;
|
||||
use crate::tree::CursorIcon;
|
||||
use crate::window::{WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PlatformProxy {
|
||||
command_tx: mpsc::UnboundedSender<PlatformCommand>,
|
||||
command_tx: mpsc::UnboundedSender<PlatformRequest>,
|
||||
next_window_id: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
@@ -33,6 +34,11 @@ pub struct PlatformRuntime {
|
||||
_worker: WorkerHandle,
|
||||
}
|
||||
|
||||
pub struct PlatformEndpoint {
|
||||
pub commands: mpsc::Receiver<PlatformRequest>,
|
||||
pub events: mpsc::UnboundedSender<PlatformEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PlatformEvent {
|
||||
Opened {
|
||||
@@ -75,7 +81,7 @@ impl fmt::Display for PlatformClosed {
|
||||
impl std::error::Error for PlatformClosed {}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum PlatformCommand {
|
||||
pub enum PlatformRequest {
|
||||
CreateWindow {
|
||||
window_id: WindowId,
|
||||
spec: WindowSpec,
|
||||
@@ -88,9 +94,21 @@ enum PlatformCommand {
|
||||
window_id: WindowId,
|
||||
scene: SceneSnapshot,
|
||||
},
|
||||
SetPrimarySelectionText {
|
||||
window_id: WindowId,
|
||||
text: String,
|
||||
},
|
||||
SetCursorIcon {
|
||||
window_id: WindowId,
|
||||
cursor: CursorIcon,
|
||||
},
|
||||
EmitCloseRequested {
|
||||
window_id: WindowId,
|
||||
},
|
||||
EmitPointerEvent {
|
||||
window_id: WindowId,
|
||||
event: PointerEvent,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
@@ -135,6 +153,25 @@ impl PlatformRuntime {
|
||||
start_headless()
|
||||
}
|
||||
|
||||
pub fn custom(start: impl FnOnce(PlatformEndpoint) -> WorkerHandle) -> Self {
|
||||
let (command_tx, command_rx) = mpsc::unbounded_channel::<PlatformRequest>();
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel::<PlatformEvent>();
|
||||
let proxy = PlatformProxy {
|
||||
command_tx,
|
||||
next_window_id: Arc::new(AtomicU64::new(1)),
|
||||
};
|
||||
let worker = start(PlatformEndpoint {
|
||||
commands: command_rx,
|
||||
events: event_tx,
|
||||
});
|
||||
|
||||
Self {
|
||||
proxy,
|
||||
events: event_rx,
|
||||
_worker: worker,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn proxy(&self) -> PlatformProxy {
|
||||
self.proxy.clone()
|
||||
}
|
||||
@@ -142,12 +179,20 @@ impl PlatformRuntime {
|
||||
pub async fn next_event(&mut self) -> Option<PlatformEvent> {
|
||||
self.events.recv().await
|
||||
}
|
||||
|
||||
pub fn take_pending_events(&mut self) -> Vec<PlatformEvent> {
|
||||
let mut events = Vec::new();
|
||||
while let Ok(event) = self.events.try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformProxy {
|
||||
pub fn create_window(&self, spec: WindowSpec) -> Result<WindowId, PlatformClosed> {
|
||||
let window_id = WindowId::from_raw(self.next_window_id.fetch_add(1, Ordering::Relaxed));
|
||||
self.send(PlatformCommand::CreateWindow { window_id, spec })?;
|
||||
self.send(PlatformRequest::CreateWindow { window_id, spec })?;
|
||||
Ok(window_id)
|
||||
}
|
||||
|
||||
@@ -156,7 +201,7 @@ impl PlatformProxy {
|
||||
window_id: WindowId,
|
||||
update: WindowUpdate,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::UpdateWindow { window_id, update })
|
||||
self.send(PlatformRequest::UpdateWindow { window_id, update })
|
||||
}
|
||||
|
||||
pub fn replace_scene(
|
||||
@@ -164,18 +209,45 @@ impl PlatformProxy {
|
||||
window_id: WindowId,
|
||||
scene: SceneSnapshot,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::ReplaceScene { window_id, scene })
|
||||
self.send(PlatformRequest::ReplaceScene { window_id, scene })
|
||||
}
|
||||
|
||||
pub fn set_primary_selection_text(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
text: impl Into<String>,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformRequest::SetPrimarySelectionText {
|
||||
window_id,
|
||||
text: text.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_cursor_icon(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
cursor: CursorIcon,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformRequest::SetCursorIcon { window_id, cursor })
|
||||
}
|
||||
|
||||
pub fn emit_close_requested(&self, window_id: WindowId) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::EmitCloseRequested { window_id })
|
||||
self.send(PlatformRequest::EmitCloseRequested { window_id })
|
||||
}
|
||||
|
||||
pub fn emit_pointer_event(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
event: PointerEvent,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformRequest::EmitPointerEvent { window_id, event })
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) -> Result<(), PlatformClosed> {
|
||||
self.send(PlatformCommand::Shutdown)
|
||||
self.send(PlatformRequest::Shutdown)
|
||||
}
|
||||
|
||||
fn send(&self, command: PlatformCommand) -> Result<(), PlatformClosed> {
|
||||
fn send(&self, command: PlatformRequest) -> Result<(), PlatformClosed> {
|
||||
self.command_tx.send(command).map_err(|_| PlatformClosed)
|
||||
}
|
||||
}
|
||||
@@ -186,69 +258,64 @@ impl PlatformProxy {
|
||||
/// scene for that window even if the scene version itself did not change. Visibility restoration is
|
||||
/// treated as a presentation-affecting state change.
|
||||
pub fn start_headless() -> PlatformRuntime {
|
||||
let (command_tx, mut command_rx) = mpsc::unbounded_channel::<PlatformCommand>();
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel::<PlatformEvent>();
|
||||
PlatformRuntime::custom(|mut endpoint| {
|
||||
spawn_worker(
|
||||
move || {
|
||||
let state = Rc::new(RefCell::new(HeadlessState {
|
||||
events: endpoint.events.clone(),
|
||||
windows: BTreeMap::new(),
|
||||
}));
|
||||
|
||||
let worker = spawn_worker(
|
||||
move || {
|
||||
let state = Rc::new(RefCell::new(HeadlessState {
|
||||
events: event_tx.clone(),
|
||||
windows: BTreeMap::new(),
|
||||
}));
|
||||
queue_future(async move {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_started",
|
||||
backend = "headless",
|
||||
"starting headless platform worker"
|
||||
);
|
||||
|
||||
queue_future(async move {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_started",
|
||||
backend = "headless",
|
||||
"starting headless platform worker"
|
||||
);
|
||||
|
||||
while let Some(command) = command_rx.recv().await {
|
||||
match command {
|
||||
PlatformCommand::CreateWindow { window_id, spec } => {
|
||||
handle_create_window(&state, window_id, spec);
|
||||
}
|
||||
PlatformCommand::UpdateWindow { window_id, update } => {
|
||||
handle_update_window(&state, window_id, update);
|
||||
}
|
||||
PlatformCommand::ReplaceScene { window_id, scene } => {
|
||||
handle_replace_scene(&state, window_id, scene);
|
||||
}
|
||||
PlatformCommand::EmitCloseRequested { window_id } => {
|
||||
let sender = state.borrow().events.clone();
|
||||
let _ = sender.send(PlatformEvent::CloseRequested { window_id });
|
||||
}
|
||||
PlatformCommand::Shutdown => {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_shutdown_requested",
|
||||
"shutting down headless platform worker"
|
||||
);
|
||||
break;
|
||||
while let Some(command) = endpoint.commands.recv().await {
|
||||
match command {
|
||||
PlatformRequest::CreateWindow { window_id, spec } => {
|
||||
handle_create_window(&state, window_id, spec);
|
||||
}
|
||||
PlatformRequest::UpdateWindow { window_id, update } => {
|
||||
handle_update_window(&state, window_id, update);
|
||||
}
|
||||
PlatformRequest::ReplaceScene { window_id, scene } => {
|
||||
handle_replace_scene(&state, window_id, scene);
|
||||
}
|
||||
PlatformRequest::SetPrimarySelectionText { .. } => {}
|
||||
PlatformRequest::SetCursorIcon { .. } => {}
|
||||
PlatformRequest::EmitCloseRequested { window_id } => {
|
||||
let sender = state.borrow().events.clone();
|
||||
let _ = sender.send(PlatformEvent::CloseRequested { window_id });
|
||||
}
|
||||
PlatformRequest::EmitPointerEvent { window_id, event } => {
|
||||
handle_emit_pointer_event(&state, window_id, event);
|
||||
}
|
||||
PlatformRequest::Shutdown => {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_shutdown_requested",
|
||||
"shutting down headless platform worker"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|| {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_exited",
|
||||
backend = "headless",
|
||||
"headless platform worker exited"
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PlatformRuntime {
|
||||
proxy: PlatformProxy {
|
||||
command_tx,
|
||||
next_window_id: Arc::new(AtomicU64::new(1)),
|
||||
},
|
||||
events: event_rx,
|
||||
_worker: worker,
|
||||
}
|
||||
});
|
||||
},
|
||||
|| {
|
||||
debug!(
|
||||
target: trace_targets::PLATFORM,
|
||||
event = "worker_exited",
|
||||
backend = "headless",
|
||||
"headless platform worker exited"
|
||||
);
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_create_window(state: &Rc<RefCell<HeadlessState>>, window_id: WindowId, spec: WindowSpec) {
|
||||
@@ -450,6 +517,20 @@ fn set_visibility(state: &Rc<RefCell<HeadlessState>>, window_id: WindowId, visib
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_emit_pointer_event(
|
||||
state: &Rc<RefCell<HeadlessState>>,
|
||||
window_id: WindowId,
|
||||
event: PointerEvent,
|
||||
) {
|
||||
let should_emit = matches!(
|
||||
state.borrow().windows.get(&window_id),
|
||||
Some(window) if window.lifecycle == WindowLifecycle::OpenVisible
|
||||
);
|
||||
if should_emit {
|
||||
emit_event(state, PlatformEvent::Pointer { window_id, event });
|
||||
}
|
||||
}
|
||||
|
||||
fn present_latest_scene(state: &Rc<RefCell<HeadlessState>>, window_id: WindowId) {
|
||||
let presentation = {
|
||||
let mut state_ref = state.borrow_mut();
|
||||
|
||||
@@ -4,6 +4,7 @@ use ruin_reactivity::{EffectHandle, effect};
|
||||
|
||||
use crate::platform::{PlatformClosed, PlatformEvent, PlatformProxy, PlatformRuntime};
|
||||
use crate::scene::SceneSnapshot;
|
||||
use crate::tree::CursorIcon;
|
||||
use crate::window::{WindowId, WindowSpec, WindowUpdate};
|
||||
|
||||
/// High-level UI-side owner of platform event consumption.
|
||||
@@ -39,11 +40,13 @@ impl std::fmt::Display for EventStreamClosed {
|
||||
impl std::error::Error for EventStreamClosed {}
|
||||
|
||||
impl UiRuntime {
|
||||
pub fn from_platform(platform: PlatformRuntime) -> Self {
|
||||
Self { platform }
|
||||
}
|
||||
|
||||
/// Creates a UI runtime backed by the headless prototype backend.
|
||||
pub fn headless() -> Self {
|
||||
Self {
|
||||
platform: PlatformRuntime::headless(),
|
||||
}
|
||||
Self::from_platform(PlatformRuntime::headless())
|
||||
}
|
||||
|
||||
/// Returns a cloneable proxy for low-level platform commands.
|
||||
@@ -65,6 +68,11 @@ impl UiRuntime {
|
||||
self.platform.next_event().await
|
||||
}
|
||||
|
||||
/// Drains any platform events that are already queued locally.
|
||||
pub fn take_pending_events(&mut self) -> Vec<PlatformEvent> {
|
||||
self.platform.take_pending_events()
|
||||
}
|
||||
|
||||
/// Waits until an event matches `predicate`.
|
||||
pub async fn wait_for_event_matching(
|
||||
&mut self,
|
||||
@@ -100,11 +108,32 @@ impl WindowController {
|
||||
self.proxy.replace_scene(self.id, scene)
|
||||
}
|
||||
|
||||
/// Copies plain text to the platform primary-selection buffer for this window.
|
||||
pub fn set_primary_selection_text(
|
||||
&self,
|
||||
text: impl Into<String>,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.proxy.set_primary_selection_text(self.id, text)
|
||||
}
|
||||
|
||||
/// Updates the platform cursor icon for this window.
|
||||
pub fn set_cursor_icon(&self, cursor: CursorIcon) -> Result<(), PlatformClosed> {
|
||||
self.proxy.set_cursor_icon(self.id, cursor)
|
||||
}
|
||||
|
||||
/// Emits a close-request event for this window.
|
||||
pub fn emit_close_requested(&self) -> Result<(), PlatformClosed> {
|
||||
self.proxy.emit_close_requested(self.id)
|
||||
}
|
||||
|
||||
/// Delivers a pointer event for this window through the platform event stream.
|
||||
pub fn emit_pointer_event(
|
||||
&self,
|
||||
event: crate::interaction::PointerEvent,
|
||||
) -> Result<(), PlatformClosed> {
|
||||
self.proxy.emit_pointer_event(self.id, event)
|
||||
}
|
||||
|
||||
/// Attaches a reactive effect that rebuilds and replaces the window scene whenever dependent UI
|
||||
/// state changes.
|
||||
pub fn attach_scene_effect(
|
||||
@@ -127,6 +156,7 @@ mod tests {
|
||||
use crate::platform::PlatformEvent;
|
||||
use crate::scene::{Color, Point, PreparedText, Rect, SceneSnapshot, UiSize};
|
||||
use crate::window::{WindowSpec, WindowUpdate};
|
||||
use crate::{PointerEvent, PointerEventKind};
|
||||
use ruin_runtime::{current_thread_handle, queue_future, run};
|
||||
use std::future::Future;
|
||||
|
||||
@@ -235,4 +265,49 @@ mod tests {
|
||||
ui.shutdown().expect("shutdown should queue");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_controller_emits_pointer_events_through_runtime() {
|
||||
run_async_test(async move {
|
||||
let mut ui = UiRuntime::headless();
|
||||
let window = ui
|
||||
.create_window(WindowSpec::new("pointer-events").visible(true))
|
||||
.expect("window should be created");
|
||||
|
||||
let _ = ui
|
||||
.wait_for_event_matching(|event| {
|
||||
matches!(event, PlatformEvent::Opened { window_id } if *window_id == window.id())
|
||||
})
|
||||
.await
|
||||
.expect("window should open before pointer delivery");
|
||||
|
||||
window
|
||||
.emit_pointer_event(PointerEvent::new(
|
||||
0,
|
||||
Point::new(24.0, 32.0),
|
||||
PointerEventKind::Move,
|
||||
))
|
||||
.expect("pointer event should queue");
|
||||
|
||||
let event = ui
|
||||
.wait_for_event_matching(|event| {
|
||||
matches!(
|
||||
event,
|
||||
PlatformEvent::Pointer { window_id, .. } if *window_id == window.id()
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("pointer event should be delivered");
|
||||
|
||||
assert_eq!(
|
||||
event,
|
||||
PlatformEvent::Pointer {
|
||||
window_id: window.id(),
|
||||
event: PointerEvent::new(0, Point::new(24.0, 32.0), PointerEventKind::Move),
|
||||
}
|
||||
);
|
||||
|
||||
ui.shutdown().expect("shutdown should queue");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! Renderer-oriented scene snapshot types.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use cosmic_text::CacheKey;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::text::TextSelectionStyle;
|
||||
use crate::trace_targets;
|
||||
use crate::tree::ElementId;
|
||||
|
||||
pub type SceneVersion = u64;
|
||||
|
||||
@@ -53,7 +57,7 @@ impl Rect {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct Color {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
@@ -97,21 +101,35 @@ impl Quad {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GlyphInstance {
|
||||
pub glyph: String,
|
||||
pub position: Point,
|
||||
pub advance: f32,
|
||||
pub color: Color,
|
||||
pub cache_key: Option<CacheKey>,
|
||||
pub text_start: usize,
|
||||
pub text_end: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PreparedTextLine {
|
||||
pub rect: Rect,
|
||||
pub text_start: usize,
|
||||
pub text_end: usize,
|
||||
pub glyph_start: usize,
|
||||
pub glyph_end: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PreparedText {
|
||||
pub element_id: Option<ElementId>,
|
||||
pub text: String,
|
||||
pub origin: Point,
|
||||
pub bounds: Option<UiSize>,
|
||||
pub font_size: f32,
|
||||
pub line_height: f32,
|
||||
pub color: Color,
|
||||
pub selectable: bool,
|
||||
pub selection_style: TextSelectionStyle,
|
||||
pub lines: Vec<PreparedTextLine>,
|
||||
pub glyphs: Vec<GlyphInstance>,
|
||||
}
|
||||
|
||||
@@ -126,27 +144,147 @@ impl PreparedText {
|
||||
let text = text.into();
|
||||
let mut x = origin.x;
|
||||
let mut glyphs = Vec::with_capacity(text.chars().count());
|
||||
for ch in text.chars() {
|
||||
for (text_start, ch) in text.char_indices() {
|
||||
let text_end = text_start + ch.len_utf8();
|
||||
glyphs.push(GlyphInstance {
|
||||
glyph: ch.to_string(),
|
||||
position: Point::new(x, origin.y),
|
||||
advance,
|
||||
color,
|
||||
cache_key: None,
|
||||
text_start,
|
||||
text_end,
|
||||
});
|
||||
x += advance;
|
||||
}
|
||||
|
||||
Self {
|
||||
element_id: None,
|
||||
text,
|
||||
origin,
|
||||
bounds: None,
|
||||
font_size,
|
||||
line_height: font_size,
|
||||
color,
|
||||
selectable: true,
|
||||
selection_style: TextSelectionStyle::DEFAULT,
|
||||
lines: vec![PreparedTextLine {
|
||||
rect: Rect::new(origin.x, origin.y, x - origin.x, font_size),
|
||||
text_start: 0,
|
||||
text_end: glyphs.last().map_or(0, |glyph| glyph.text_end),
|
||||
glyph_start: 0,
|
||||
glyph_end: glyphs.len(),
|
||||
}],
|
||||
glyphs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn byte_offset_for_position(&self, point: Point) -> usize {
|
||||
let Some(line) = self.line_for_position(point.y) else {
|
||||
return 0;
|
||||
};
|
||||
self.byte_offset_for_line_position(line, point.x)
|
||||
}
|
||||
|
||||
pub fn selection_range(&self, start: usize, end: usize) -> Range<usize> {
|
||||
let start = start.min(self.text.len());
|
||||
let end = end.min(self.text.len());
|
||||
if start <= end { start..end } else { end..start }
|
||||
}
|
||||
|
||||
pub fn selected_text(&self, start: usize, end: usize) -> Option<&str> {
|
||||
let range = self.selection_range(start, end);
|
||||
self.text.get(range)
|
||||
}
|
||||
|
||||
pub fn selection_rects(&self, start: usize, end: usize) -> Vec<Rect> {
|
||||
let range = self.selection_range(start, end);
|
||||
if range.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut rects = Vec::new();
|
||||
for line in &self.lines {
|
||||
let mut left = None::<f32>;
|
||||
let mut right = None::<f32>;
|
||||
for glyph in &self.glyphs[line.glyph_start..line.glyph_end] {
|
||||
if glyph.text_end <= range.start || glyph.text_start >= range.end {
|
||||
continue;
|
||||
}
|
||||
let glyph_left = glyph.position.x;
|
||||
let glyph_right = glyph.position.x + glyph.advance.max(0.0);
|
||||
left = Some(left.map_or(glyph_left, |current| current.min(glyph_left)));
|
||||
right = Some(right.map_or(glyph_right, |current| current.max(glyph_right)));
|
||||
}
|
||||
|
||||
if let (Some(left), Some(right)) = (left, right) {
|
||||
rects.push(Rect::new(
|
||||
left,
|
||||
line.rect.origin.y,
|
||||
(right - left).max(0.0),
|
||||
line.rect.size.height,
|
||||
));
|
||||
}
|
||||
}
|
||||
rects
|
||||
}
|
||||
|
||||
pub fn apply_selected_text_color(&mut self, start: usize, end: usize) {
|
||||
let Some(selected_color) = self.selection_style.text_color else {
|
||||
return;
|
||||
};
|
||||
let range = self.selection_range(start, end);
|
||||
if range.is_empty() {
|
||||
return;
|
||||
}
|
||||
for glyph in &mut self.glyphs {
|
||||
if glyph.text_end > range.start && glyph.text_start < range.end {
|
||||
glyph.color = selected_color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn line_for_position(&self, y: f32) -> Option<&PreparedTextLine> {
|
||||
let mut lines = self.lines.iter();
|
||||
let first = lines.next()?;
|
||||
if y < first.rect.origin.y {
|
||||
return Some(first);
|
||||
}
|
||||
|
||||
let mut last = first;
|
||||
for line in std::iter::once(first).chain(lines) {
|
||||
last = line;
|
||||
if y < line.rect.origin.y + line.rect.size.height {
|
||||
return Some(line);
|
||||
}
|
||||
}
|
||||
Some(last)
|
||||
}
|
||||
|
||||
fn byte_offset_for_line_position(&self, line: &PreparedTextLine, x: f32) -> usize {
|
||||
if line.glyph_start == line.glyph_end {
|
||||
return line.text_start;
|
||||
}
|
||||
|
||||
let line_glyphs = &self.glyphs[line.glyph_start..line.glyph_end];
|
||||
let first_glyph = &line_glyphs[0];
|
||||
if x <= first_glyph.position.x {
|
||||
return first_glyph.text_start;
|
||||
}
|
||||
|
||||
for glyph in line_glyphs {
|
||||
let glyph_left = glyph.position.x;
|
||||
let glyph_right = glyph.position.x + glyph.advance.max(0.0);
|
||||
let midpoint = glyph_left + glyph.advance.max(0.0) * 0.5;
|
||||
if x < midpoint {
|
||||
return glyph.text_start;
|
||||
}
|
||||
if x < glyph_right {
|
||||
return glyph.text_end;
|
||||
}
|
||||
}
|
||||
|
||||
line.text_end
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -226,3 +364,49 @@ impl SceneSnapshot {
|
||||
self.push_item(DisplayItem::LayerEnd)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Color, Point, PreparedText, Rect};
|
||||
use crate::TextSelectionStyle;
|
||||
|
||||
#[test]
|
||||
fn prepared_text_hit_testing_clamps_to_nearest_cluster_boundary() {
|
||||
let text = PreparedText::monospace(
|
||||
"abcd",
|
||||
Point::new(10.0, 20.0),
|
||||
16.0,
|
||||
8.0,
|
||||
Color::rgb(0xFF, 0xFF, 0xFF),
|
||||
);
|
||||
|
||||
assert_eq!(text.byte_offset_for_position(Point::new(6.0, 24.0)), 0);
|
||||
assert_eq!(text.byte_offset_for_position(Point::new(13.0, 24.0)), 0);
|
||||
assert_eq!(text.byte_offset_for_position(Point::new(17.0, 24.0)), 1);
|
||||
assert_eq!(text.byte_offset_for_position(Point::new(33.0, 24.0)), 3);
|
||||
assert_eq!(text.byte_offset_for_position(Point::new(50.0, 24.0)), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_text_selection_rects_cover_selected_glyphs() {
|
||||
let mut text = PreparedText::monospace(
|
||||
"abcd",
|
||||
Point::new(10.0, 20.0),
|
||||
16.0,
|
||||
8.0,
|
||||
Color::rgb(0xFF, 0xFF, 0xFF),
|
||||
);
|
||||
text.selection_style = TextSelectionStyle::new(Color::rgba(0x44, 0x66, 0xFF, 0xAA))
|
||||
.with_text_color(Color::rgb(0x11, 0x12, 0x1A));
|
||||
|
||||
assert_eq!(
|
||||
text.selection_rects(1, 3),
|
||||
vec![Rect::new(18.0, 20.0, 16.0, 16.0)]
|
||||
);
|
||||
assert_eq!(text.selected_text(1, 3), Some("bc"));
|
||||
text.apply_selected_text_color(1, 3);
|
||||
assert_eq!(text.glyphs[0].color, Color::rgb(0xFF, 0xFF, 0xFF));
|
||||
assert_eq!(text.glyphs[1].color, Color::rgb(0x11, 0x12, 0x1A));
|
||||
assert_eq!(text.glyphs[2].color, Color::rgb(0x11, 0x12, 0x1A));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::mem;
|
||||
use std::time::Instant;
|
||||
|
||||
use cosmic_text::{
|
||||
Attrs, Buffer, Color as CosmicColor, FontSystem, Metrics, Shaping, Style as CosmicStyle,
|
||||
@@ -6,16 +9,16 @@ use cosmic_text::{
|
||||
};
|
||||
use fontconfig::Fontconfig;
|
||||
|
||||
use crate::{Color, GlyphInstance, Point, PreparedText, UiSize};
|
||||
use crate::{Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, UiSize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum TextAlign {
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum TextSpanWeight {
|
||||
Normal,
|
||||
Medium,
|
||||
@@ -46,7 +49,7 @@ impl TextFontFamily {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct TextSpan {
|
||||
pub text: String,
|
||||
pub color: Option<Color>,
|
||||
@@ -87,7 +90,7 @@ impl TextSpan {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum TextWrap {
|
||||
None,
|
||||
Word,
|
||||
@@ -102,6 +105,31 @@ impl TextWrap {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct TextSelectionStyle {
|
||||
pub highlight_color: Color,
|
||||
pub text_color: Option<Color>,
|
||||
}
|
||||
|
||||
impl TextSelectionStyle {
|
||||
pub const DEFAULT: Self = Self {
|
||||
highlight_color: Color::rgba(0x5B, 0x9D, 0xFF, 0x55),
|
||||
text_color: None,
|
||||
};
|
||||
|
||||
pub const fn new(highlight_color: Color) -> Self {
|
||||
Self {
|
||||
highlight_color,
|
||||
text_color: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_text_color(mut self, text_color: Color) -> Self {
|
||||
self.text_color = Some(text_color);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextStyle {
|
||||
pub font_size: f32,
|
||||
@@ -112,6 +140,8 @@ pub struct TextStyle {
|
||||
pub wrap: TextWrap,
|
||||
pub align: TextAlign,
|
||||
pub max_lines: Option<usize>,
|
||||
pub selectable: bool,
|
||||
pub selection_style: TextSelectionStyle,
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
@@ -125,6 +155,8 @@ impl TextStyle {
|
||||
wrap: TextWrap::None,
|
||||
align: TextAlign::Start,
|
||||
max_lines: None,
|
||||
selectable: true,
|
||||
selection_style: TextSelectionStyle::DEFAULT,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,19 +189,44 @@ impl TextStyle {
|
||||
self.max_lines = Some(max_lines);
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_selectable(mut self, selectable: bool) -> Self {
|
||||
self.selectable = selectable;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_selection_style(mut self, selection_style: TextSelectionStyle) -> Self {
|
||||
self.selection_style = selection_style;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextSystem {
|
||||
font_system: FontSystem,
|
||||
family_resolver: FontFamilyResolver,
|
||||
layout_cache: HashMap<u64, TextLayout>,
|
||||
frame_stats: TextFrameStats,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct TextLayout {
|
||||
lines: Vec<PreparedTextLine>,
|
||||
glyphs: Vec<GlyphInstance>,
|
||||
size: UiSize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct TextFrameStats {
|
||||
pub requests: u32,
|
||||
pub cache_hits: u32,
|
||||
pub cache_misses: u32,
|
||||
pub output_glyphs: u32,
|
||||
pub family_resolve_ms: f64,
|
||||
pub buffer_build_ms: f64,
|
||||
pub glyph_collect_ms: f64,
|
||||
pub miss_ms: f64,
|
||||
}
|
||||
|
||||
struct FontFamilyResolver {
|
||||
fontconfig: Option<Fontconfig>,
|
||||
cache: HashMap<(TextFontFamily, TextSpanSlant), Option<String>>,
|
||||
@@ -189,51 +246,84 @@ impl TextSystem {
|
||||
Self {
|
||||
font_system,
|
||||
family_resolver,
|
||||
layout_cache: HashMap::new(),
|
||||
frame_stats: TextFrameStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reset_frame_stats(&mut self) {
|
||||
self.frame_stats = TextFrameStats::default();
|
||||
}
|
||||
|
||||
pub(crate) fn take_frame_stats(&mut self) -> TextFrameStats {
|
||||
mem::take(&mut self.frame_stats)
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
text: impl Into<String>,
|
||||
origin: Point,
|
||||
style: TextStyle,
|
||||
style: &TextStyle,
|
||||
) -> PreparedText {
|
||||
self.prepare_spans([TextSpan::new(text)], origin, style)
|
||||
let spans = [TextSpan::new(text)];
|
||||
self.prepare_spans(&spans, origin, style, style.bounds)
|
||||
}
|
||||
|
||||
pub fn prepare_spans(
|
||||
&mut self,
|
||||
spans: impl IntoIterator<Item = TextSpan>,
|
||||
spans: &[TextSpan],
|
||||
origin: Point,
|
||||
style: TextStyle,
|
||||
style: &TextStyle,
|
||||
bounds: Option<UiSize>,
|
||||
) -> PreparedText {
|
||||
let spans: Vec<TextSpan> = spans.into_iter().collect();
|
||||
let text = combined_text(&spans);
|
||||
let bounds = bounds.or(style.bounds);
|
||||
let text = combined_text(spans);
|
||||
let layout = self.layout(
|
||||
&spans,
|
||||
style.clone(),
|
||||
style.bounds.map(|bounds| bounds.width),
|
||||
style.bounds.map(|bounds| bounds.height),
|
||||
spans,
|
||||
style,
|
||||
bounds.map(|bounds| bounds.width),
|
||||
bounds.map(|bounds| bounds.height),
|
||||
);
|
||||
let glyphs = layout
|
||||
.glyphs
|
||||
.into_iter()
|
||||
.map(|glyph| GlyphInstance {
|
||||
glyph: glyph.glyph,
|
||||
position: Point::new(origin.x + glyph.position.x, origin.y + glyph.position.y),
|
||||
advance: glyph.advance,
|
||||
color: glyph.color,
|
||||
cache_key: glyph.cache_key,
|
||||
text_start: glyph.text_start,
|
||||
text_end: glyph.text_end,
|
||||
})
|
||||
.collect();
|
||||
let lines = layout
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|line| PreparedTextLine {
|
||||
rect: Rect::new(
|
||||
origin.x + line.rect.origin.x,
|
||||
origin.y + line.rect.origin.y,
|
||||
line.rect.size.width,
|
||||
line.rect.size.height,
|
||||
),
|
||||
text_start: line.text_start,
|
||||
text_end: line.text_end,
|
||||
glyph_start: line.glyph_start,
|
||||
glyph_end: line.glyph_end,
|
||||
})
|
||||
.collect();
|
||||
|
||||
PreparedText {
|
||||
element_id: None,
|
||||
text,
|
||||
origin,
|
||||
bounds: style.bounds,
|
||||
bounds,
|
||||
font_size: style.font_size,
|
||||
line_height: style.line_height,
|
||||
color: style.color,
|
||||
selectable: style.selectable,
|
||||
selection_style: style.selection_style,
|
||||
lines,
|
||||
glyphs,
|
||||
}
|
||||
}
|
||||
@@ -241,66 +331,98 @@ impl TextSystem {
|
||||
pub fn measure(
|
||||
&mut self,
|
||||
text: &str,
|
||||
style: TextStyle,
|
||||
style: &TextStyle,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
) -> UiSize {
|
||||
self.measure_spans([TextSpan::new(text)], style, width, height)
|
||||
let spans = [TextSpan::new(text)];
|
||||
self.measure_spans(&spans, style, width, height)
|
||||
}
|
||||
|
||||
pub fn measure_spans(
|
||||
&mut self,
|
||||
spans: impl IntoIterator<Item = TextSpan>,
|
||||
style: TextStyle,
|
||||
spans: &[TextSpan],
|
||||
style: &TextStyle,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
) -> UiSize {
|
||||
let spans: Vec<TextSpan> = spans.into_iter().collect();
|
||||
self.layout(&spans, style, width, height).size
|
||||
self.layout(spans, style, width, height).size
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
spans: &[TextSpan],
|
||||
style: TextStyle,
|
||||
style: &TextStyle,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
) -> TextLayout {
|
||||
self.frame_stats.requests = self.frame_stats.requests.saturating_add(1);
|
||||
let cache_key = layout_cache_key(spans, style, width, height);
|
||||
if let Some(layout) = self.layout_cache.get(&cache_key) {
|
||||
self.frame_stats.cache_hits = self.frame_stats.cache_hits.saturating_add(1);
|
||||
self.frame_stats.output_glyphs = self
|
||||
.frame_stats
|
||||
.output_glyphs
|
||||
.saturating_add(layout.glyphs.len() as u32);
|
||||
return clamp_text_layout(layout.clone(), width, height);
|
||||
}
|
||||
self.frame_stats.cache_misses = self.frame_stats.cache_misses.saturating_add(1);
|
||||
let miss_started = Instant::now();
|
||||
|
||||
let uses_plain_text_fast_path = uses_plain_text_fast_path(spans);
|
||||
let family_resolve_started = Instant::now();
|
||||
let default_family = self.resolve_font_family(&style.font_family, TextSpanSlant::Normal);
|
||||
let resolved_span_families: Vec<_> = spans
|
||||
.iter()
|
||||
.map(|span| {
|
||||
self.resolve_font_family(
|
||||
span.font_family.as_ref().unwrap_or(&style.font_family),
|
||||
span.slant,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let resolved_span_families = (!uses_plain_text_fast_path).then(|| {
|
||||
spans
|
||||
.iter()
|
||||
.map(|span| {
|
||||
self.resolve_font_family(
|
||||
span.font_family.as_ref().unwrap_or(&style.font_family),
|
||||
span.slant,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
self.frame_stats.family_resolve_ms +=
|
||||
family_resolve_started.elapsed().as_secs_f64() * 1_000.0;
|
||||
|
||||
let buffer_build_started = Instant::now();
|
||||
let mut buffer = Buffer::new_empty(Metrics::new(style.font_size, style.line_height));
|
||||
{
|
||||
let mut borrowed = buffer.borrow_with(&mut self.font_system);
|
||||
borrowed.set_wrap(style.wrap.to_cosmic());
|
||||
borrowed.set_size(width, height);
|
||||
let default_attrs = default_attrs_for_style(&style, default_family.as_deref());
|
||||
borrowed.set_rich_text(
|
||||
spans
|
||||
.iter()
|
||||
.zip(resolved_span_families.iter())
|
||||
.map(|(span, resolved_family)| {
|
||||
(
|
||||
span.text.as_str(),
|
||||
attrs_for_span(span, &style, resolved_family.as_deref()),
|
||||
)
|
||||
}),
|
||||
&default_attrs,
|
||||
Shaping::Advanced,
|
||||
None,
|
||||
);
|
||||
borrowed.set_size(width, None);
|
||||
let default_attrs = default_attrs_for_style(style, default_family.as_deref());
|
||||
if uses_plain_text_fast_path {
|
||||
borrowed.set_text(
|
||||
spans[0].text.as_str(),
|
||||
&default_attrs,
|
||||
Shaping::Advanced,
|
||||
None,
|
||||
);
|
||||
} else if let Some(resolved_span_families) = resolved_span_families.as_ref() {
|
||||
borrowed.set_rich_text(
|
||||
spans.iter().zip(resolved_span_families.iter()).map(
|
||||
|(span, resolved_family)| {
|
||||
(
|
||||
span.text.as_str(),
|
||||
attrs_for_span(span, style, resolved_family.as_deref()),
|
||||
)
|
||||
},
|
||||
),
|
||||
&default_attrs,
|
||||
Shaping::Advanced,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.frame_stats.buffer_build_ms += buffer_build_started.elapsed().as_secs_f64() * 1_000.0;
|
||||
|
||||
let mut measured_width: f32 = 0.0;
|
||||
let mut measured_height: f32 = 0.0;
|
||||
let mut lines = Vec::new();
|
||||
let mut glyphs = Vec::new();
|
||||
let glyph_collect_started = Instant::now();
|
||||
for (line_index, run) in buffer.layout_runs().enumerate() {
|
||||
if matches!(style.max_lines, Some(max_lines) if line_index >= max_lines) {
|
||||
break;
|
||||
@@ -308,25 +430,47 @@ impl TextSystem {
|
||||
measured_width = measured_width.max(run.line_w);
|
||||
measured_height = measured_height.max(run.line_top + run.line_height);
|
||||
let x_offset = aligned_line_offset(style.align, width, run.line_w);
|
||||
let glyph_start = glyphs.len();
|
||||
glyphs.extend(run.glyphs.iter().map(move |glyph| {
|
||||
let physical = glyph.physical((x_offset, run.line_y), 1.0);
|
||||
GlyphInstance {
|
||||
glyph: run.text[glyph.start..glyph.end].to_string(),
|
||||
position: Point::new(physical.x as f32, physical.y as f32),
|
||||
advance: glyph.w,
|
||||
color: glyph.color_opt.map_or(style.color, color_from_cosmic),
|
||||
cache_key: Some(physical.cache_key),
|
||||
text_start: glyph.start,
|
||||
text_end: glyph.end,
|
||||
}
|
||||
}));
|
||||
let glyph_end = glyphs.len();
|
||||
let text_start = run.glyphs.first().map_or(0, |glyph| glyph.start);
|
||||
let text_end = run.glyphs.last().map_or(text_start, |glyph| glyph.end);
|
||||
lines.push(PreparedTextLine {
|
||||
rect: Rect::new(x_offset, run.line_top, run.line_w.max(0.0), run.line_height),
|
||||
text_start,
|
||||
text_end,
|
||||
glyph_start,
|
||||
glyph_end,
|
||||
});
|
||||
}
|
||||
self.frame_stats.glyph_collect_ms +=
|
||||
glyph_collect_started.elapsed().as_secs_f64() * 1_000.0;
|
||||
|
||||
let measured_width = width.map_or(measured_width, |limit| measured_width.min(limit));
|
||||
let measured_height = height.map_or(measured_height, |limit| measured_height.min(limit));
|
||||
|
||||
TextLayout {
|
||||
let layout = TextLayout {
|
||||
lines,
|
||||
glyphs,
|
||||
size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)),
|
||||
};
|
||||
self.frame_stats.output_glyphs = self
|
||||
.frame_stats
|
||||
.output_glyphs
|
||||
.saturating_add(layout.glyphs.len() as u32);
|
||||
self.frame_stats.miss_ms += miss_started.elapsed().as_secs_f64() * 1_000.0;
|
||||
if self.layout_cache.len() >= 256 {
|
||||
self.layout_cache.clear();
|
||||
}
|
||||
self.layout_cache.insert(cache_key, layout.clone());
|
||||
clamp_text_layout(layout, width, height)
|
||||
}
|
||||
|
||||
fn resolve_font_family(
|
||||
@@ -360,6 +504,63 @@ fn combined_text(spans: &[TextSpan]) -> String {
|
||||
text
|
||||
}
|
||||
|
||||
fn layout_cache_key(
|
||||
spans: &[TextSpan],
|
||||
style: &TextStyle,
|
||||
width: Option<f32>,
|
||||
_height: Option<f32>,
|
||||
) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
spans.hash(&mut hasher);
|
||||
style.font_size.to_bits().hash(&mut hasher);
|
||||
style.line_height.to_bits().hash(&mut hasher);
|
||||
style.color.hash(&mut hasher);
|
||||
style.font_family.hash(&mut hasher);
|
||||
style.wrap.hash(&mut hasher);
|
||||
style.align.hash(&mut hasher);
|
||||
style.max_lines.hash(&mut hasher);
|
||||
quantized_layout_dimension(width).hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn clamp_text_layout(
|
||||
mut layout: TextLayout,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
) -> TextLayout {
|
||||
if let Some(width) = width {
|
||||
layout.size.width = layout.size.width.min(width.max(0.0));
|
||||
}
|
||||
if let Some(height) = height {
|
||||
layout.size.height = layout.size.height.min(height.max(0.0));
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
fn quantized_layout_dimension(value: Option<f32>) -> Option<u32> {
|
||||
const LAYOUT_CACHE_BUCKET_PX: f32 = 8.0;
|
||||
value.map(|value| {
|
||||
(value / LAYOUT_CACHE_BUCKET_PX)
|
||||
.round()
|
||||
.mul_add(LAYOUT_CACHE_BUCKET_PX, 0.0)
|
||||
.max(0.0)
|
||||
.to_bits()
|
||||
})
|
||||
}
|
||||
|
||||
fn uses_plain_text_fast_path(spans: &[TextSpan]) -> bool {
|
||||
matches!(
|
||||
spans,
|
||||
[TextSpan {
|
||||
color: None,
|
||||
weight: TextSpanWeight::Normal,
|
||||
slant: TextSpanSlant::Normal,
|
||||
font_family: None,
|
||||
..
|
||||
}]
|
||||
)
|
||||
}
|
||||
|
||||
fn default_attrs_for_style<'a>(
|
||||
style: &'a TextStyle,
|
||||
resolved_family: Option<&'a str>,
|
||||
@@ -617,13 +818,14 @@ mod tests {
|
||||
.with_wrap(TextWrap::Word);
|
||||
let unclamped = text_system.measure(
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota kappa",
|
||||
style.clone(),
|
||||
&style,
|
||||
Some(120.0),
|
||||
None,
|
||||
);
|
||||
let clamped_style = style.with_max_lines(2);
|
||||
let clamped = text_system.measure(
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota kappa",
|
||||
style.with_max_lines(2),
|
||||
&clamped_style,
|
||||
Some(120.0),
|
||||
None,
|
||||
);
|
||||
@@ -638,13 +840,13 @@ mod tests {
|
||||
let start = text_system.prepare(
|
||||
"title",
|
||||
origin,
|
||||
TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
&TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
.with_bounds(UiSize::new(200.0, 40.0)),
|
||||
);
|
||||
let centered = text_system.prepare(
|
||||
"title",
|
||||
origin,
|
||||
TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
&TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
.with_bounds(UiSize::new(200.0, 40.0))
|
||||
.with_align(TextAlign::Center),
|
||||
);
|
||||
@@ -656,19 +858,18 @@ mod tests {
|
||||
#[test]
|
||||
fn rich_spans_preserve_per_glyph_colors() {
|
||||
let mut text_system = TextSystem::new();
|
||||
let prepared = text_system.prepare_spans(
|
||||
[
|
||||
TextSpan::new("Red ").color(Color::rgb(0xFF, 0x33, 0x33)),
|
||||
TextSpan::new("Blue")
|
||||
.color(Color::rgb(0x33, 0x66, 0xFF))
|
||||
.weight(TextSpanWeight::Bold)
|
||||
.slant(TextSpanSlant::Italic),
|
||||
],
|
||||
Point::new(0.0, 0.0),
|
||||
TextStyle::new(18.0, Color::rgb(0xEE, 0xEE, 0xEE))
|
||||
.with_wrap(TextWrap::Word)
|
||||
.with_bounds(UiSize::new(240.0, 80.0)),
|
||||
);
|
||||
let spans = [
|
||||
TextSpan::new("Red ").color(Color::rgb(0xFF, 0x33, 0x33)),
|
||||
TextSpan::new("Blue")
|
||||
.color(Color::rgb(0x33, 0x66, 0xFF))
|
||||
.weight(TextSpanWeight::Bold)
|
||||
.slant(TextSpanSlant::Italic),
|
||||
];
|
||||
let style = TextStyle::new(18.0, Color::rgb(0xEE, 0xEE, 0xEE))
|
||||
.with_wrap(TextWrap::Word)
|
||||
.with_bounds(UiSize::new(240.0, 80.0));
|
||||
let prepared =
|
||||
text_system.prepare_spans(&spans, Point::new(0.0, 0.0), &style, style.bounds);
|
||||
|
||||
assert!(
|
||||
prepared
|
||||
|
||||
@@ -20,6 +20,13 @@ pub enum FlexDirection {
|
||||
Column,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum CursorIcon {
|
||||
Default,
|
||||
Pointer,
|
||||
Text,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Edges {
|
||||
pub top: f32,
|
||||
@@ -65,6 +72,7 @@ pub struct Style {
|
||||
pub padding: Edges,
|
||||
pub background: Option<Color>,
|
||||
pub pointer_events: bool,
|
||||
pub cursor: Option<CursorIcon>,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
@@ -78,6 +86,7 @@ impl Default for Style {
|
||||
padding: Edges::ZERO,
|
||||
background: None,
|
||||
pointer_events: true,
|
||||
cursor: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +198,11 @@ impl Element {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cursor(mut self, cursor: CursorIcon) -> Self {
|
||||
self.style.cursor = Some(cursor);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn child(mut self, child: Element) -> Self {
|
||||
self.assert_container();
|
||||
self.children.push(child);
|
||||
|
||||
@@ -200,7 +200,7 @@ impl WindowUpdate {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn apply_to(&self, spec: &mut WindowSpec) {
|
||||
pub fn apply_to(&self, spec: &mut WindowSpec) {
|
||||
if let Some(title) = &self.title {
|
||||
spec.title = title.clone();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user