More text improvements, performance enhancements, input handling, text selection, wl cursors

This commit is contained in:
2026-03-20 22:24:29 -04:00
parent d79a3bb728
commit 423df4ae1f
15 changed files with 2458 additions and 265 deletions

View File

@@ -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))]
);
}
}

View File

@@ -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()
);
}
}

View File

@@ -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,
};

View File

@@ -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();

View File

@@ -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");
});
}
}

View File

@@ -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));
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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();
}