Lots of claude-driven performance work.

This commit is contained in:
2026-03-23 00:24:55 -04:00
parent 497af9151d
commit e90f09bf3e
14 changed files with 1820 additions and 394 deletions

View File

@@ -13,3 +13,8 @@ fontconfig = { version = "0.10", features = ["dlopen"] }
[dev-dependencies]
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] }
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "layout_bench"
harness = false

View File

@@ -0,0 +1,94 @@
use criterion::{Criterion, criterion_group, criterion_main};
use ruin_ui::{
Color, Element, FlexDirection, LayoutCache, TextStyle, TextSystem, UiSize,
layout_snapshot_with_cache,
};
fn text_style() -> TextStyle {
TextStyle::new(14.0, Color::rgb(0xFF, 0xFF, 0xFF))
}
fn make_column_tree(n: usize) -> Element {
let mut root = Element::new().direction(FlexDirection::Column);
for i in 0..n {
root = root.child(Element::text(format!("item {i}"), text_style()));
}
root
}
/// Static tree — nothing changes between frames. After one warmup frame, all
/// subsequent frames should be near-zero cost (all cache hits).
fn bench_static_tree(c: &mut Criterion) {
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
let root = make_column_tree(200);
let size = UiSize::new(400.0, 4000.0);
// Warm up cache.
layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
c.bench_function("layout_static_200_nodes", |b| {
let mut version = 2u64;
b.iter(|| {
version += 1;
layout_snapshot_with_cache(version, size, &root, &mut text_system, &mut layout_cache)
});
});
}
/// 200 nodes, one text content changes per frame — only one cache miss expected.
fn bench_single_change(c: &mut Criterion) {
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
let size = UiSize::new(400.0, 4000.0);
// Initial warmup.
let root = make_column_tree(200);
layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
c.bench_function("layout_single_change_200_nodes", |b| {
let mut counter = 0usize;
let mut version = 2u64;
b.iter(|| {
counter += 1;
version += 1;
let mut tree = make_column_tree(200);
tree.children[100] = Element::text(format!("changed {counter}"), text_style());
layout_snapshot_with_cache(version, size, &tree, &mut text_system, &mut layout_cache)
});
});
}
/// Scroll box with 500 items, 20 visible. Simulates a large list.
fn bench_scroll_list(c: &mut Criterion) {
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
let item_height = 32.0;
let viewport_height = 640.0;
let n = 500;
let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height);
for i in 0..n {
scroll_box = scroll_box.child(
Element::new()
.height(item_height)
.child(Element::text(format!("list item {i}"), text_style())),
);
}
let root = Element::new().child(scroll_box);
let size = UiSize::new(400.0, viewport_height);
// Warm up.
layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
c.bench_function("layout_scroll_list_500_items", |b| {
let mut version = 2u64;
b.iter(|| {
version += 1;
layout_snapshot_with_cache(version, size, &root, &mut text_system, &mut layout_cache)
});
});
}
criterion_group!(benches, bench_static_tree, bench_single_change, bench_scroll_list);
criterion_main!(benches);

View File

@@ -1,8 +1,10 @@
use std::collections::HashMap;
use std::time::Instant;
use crate::ImageFit;
use crate::scene::{
PreparedImage, PreparedText, Rect, RoundedRect, SceneSnapshot, ShadowRect, UiSize,
DisplayItem, Point, PreparedImage, PreparedText, Rect, RoundedRect, SceneSnapshot, ShadowRect,
UiSize,
};
use crate::text::TextSystem;
use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style};
@@ -122,6 +124,38 @@ impl InteractionTree {
}
}
impl LayoutNode {
/// Return a copy of this node (and all descendants) with all positions shifted by `offset`.
pub fn translated(self, offset: Point) -> Self {
fn translate_rect(r: Rect, o: Point) -> Rect {
Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height)
}
let rect = translate_rect(self.rect, offset);
let clip_rect = self.clip_rect.map(|r| translate_rect(r, offset));
let scroll_metrics = self.scroll_metrics.map(|m| ScrollMetrics {
viewport_rect: translate_rect(m.viewport_rect, offset),
scrollbar_track: m.scrollbar_track.map(|r| translate_rect(r, offset)),
scrollbar_thumb: m.scrollbar_thumb.map(|r| translate_rect(r, offset)),
..m
});
let prepared_text = self.prepared_text.map(|t| t.translated(offset));
let prepared_image = self.prepared_image.map(|img| PreparedImage {
rect: translate_rect(img.rect, offset),
..img
});
let children = self.children.into_iter().map(|c| c.translated(offset)).collect();
LayoutNode {
rect,
clip_rect,
scroll_metrics,
prepared_text,
prepared_image,
children,
..self
}
}
}
pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot {
let mut text_system = TextSystem::new();
layout_snapshot_with_text_system(version, logical_size, root, &mut text_system)
@@ -132,6 +166,17 @@ pub fn layout_snapshot_with_text_system(
logical_size: UiSize,
root: &Element,
text_system: &mut TextSystem,
) -> LayoutSnapshot {
let mut layout_cache = LayoutCache::new();
layout_snapshot_with_cache(version, logical_size, root, text_system, &mut layout_cache)
}
pub fn layout_snapshot_with_cache(
version: u64,
logical_size: UiSize,
root: &Element,
text_system: &mut TextSystem,
layout_cache: &mut LayoutCache,
) -> LayoutSnapshot {
let layout_started = Instant::now();
let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG);
@@ -150,6 +195,8 @@ pub fn layout_snapshot_with_text_system(
&mut scene,
text_system,
&mut perf_stats,
layout_cache,
None,
);
let text_stats = text_system.take_frame_stats();
if perf_stats.enabled {
@@ -170,6 +217,10 @@ pub fn layout_snapshot_with_text_system(
intrinsic_ms = perf_stats.intrinsic_ms,
text_prepare_calls = perf_stats.text_prepare_calls,
text_prepare_ms = perf_stats.text_prepare_ms,
viewport_culled = perf_stats.viewport_culled,
layout_cache_hits = perf_stats.layout_cache_hits,
layout_cache_misses = perf_stats.layout_cache_misses,
intrinsic_cache_hits = perf_stats.intrinsic_cache_hits,
text_requests = text_stats.requests,
text_cache_hits = text_stats.cache_hits,
text_cache_misses = text_stats.cache_misses,
@@ -197,8 +248,54 @@ fn layout_element(
scene: &mut SceneSnapshot,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
// Scissor rect from the nearest enclosing scroll box (window coords).
// Elements that don't overlap this rect are skipped entirely.
clip_rect: Option<Rect>,
) -> LayoutNode {
perf_stats.nodes += 1;
// Viewport culling: skip fully off-screen elements inside a scroll box.
if let Some(clip) = clip_rect {
if !rects_overlap(rect, clip) {
perf_stats.viewport_culled += 1;
return LayoutNode {
path,
element_id: element.id,
rect,
corner_radius: 0.0,
clip_rect: None,
pointer_events: element.style.pointer_events,
focusable: element.style.focusable,
cursor: element.style.cursor.unwrap_or(CursorIcon::Default),
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
};
}
}
// Incremental layout cache check.
// Round to nearest pixel to eliminate floating-point representation
// artifacts from arithmetic paths (e.g. total - padding - gap) that
// produce logically identical sizes with different bit patterns.
let subtree_hash = element.subtree_hash();
let cache_key = LayoutCacheKey {
subtree_hash,
avail_width_bits: rect.size.width.round() as u32,
avail_height_bits: rect.size.height.round() as u32,
};
if let Some(cached) = layout_cache.results.get(&cache_key) {
let offset = rect.origin;
for item in &cached.scene_items {
scene.items.push(item.translated(offset));
}
perf_stats.layout_cache_hits += 1;
return cached.interaction_node.clone().translated(offset);
}
perf_stats.layout_cache_misses += 1;
let scene_start = scene.items.len();
let cursor = element.style.cursor.unwrap_or_else(|| {
if element.text_node().is_some() {
CursorIcon::Text
@@ -222,6 +319,7 @@ fn layout_element(
};
if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
cache_layout(layout_cache, cache_key, &interaction, &[], rect.origin);
return interaction;
}
@@ -270,6 +368,7 @@ fn layout_element(
if pushed_clip {
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
return interaction;
}
@@ -283,6 +382,7 @@ fn layout_element(
if pushed_clip {
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
return interaction;
}
@@ -311,6 +411,7 @@ fn layout_element(
),
text_system,
perf_stats,
layout_cache,
);
let provisional_content_height = content_size.height.max(viewport_rect.size.height);
let requested_offset_y = scroll_box.offset_y.max(0.0);
@@ -333,6 +434,8 @@ fn layout_element(
scene,
text_system,
perf_stats,
layout_cache,
Some(viewport_rect),
);
scene.pop_clip();
}
@@ -367,6 +470,8 @@ fn layout_element(
scene,
text_system,
perf_stats,
layout_cache,
Some(viewport_rect),
);
scene.pop_clip();
} else {
@@ -403,6 +508,7 @@ fn layout_element(
if pushed_clip {
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
return interaction;
}
@@ -412,6 +518,7 @@ fn layout_element(
if pushed_clip {
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
return interaction;
}
@@ -420,6 +527,7 @@ fn layout_element(
if pushed_clip {
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
return interaction;
}
interaction.children = layout_container_children(
@@ -429,15 +537,33 @@ fn layout_element(
scene,
text_system,
perf_stats,
layout_cache,
clip_rect,
);
if pushed_clip {
scene.pop_clip();
}
cache_layout(layout_cache, cache_key, &interaction, &scene.items[scene_start..], rect.origin);
interaction
}
/// Store a layout result in the cache in origin-relative (local) coordinates.
fn cache_layout(
cache: &mut LayoutCache,
key: LayoutCacheKey,
interaction: &LayoutNode,
scene_items: &[DisplayItem],
origin: Point,
) {
let neg = Point::new(-origin.x, -origin.y);
cache.results.insert(key, CachedLayout {
interaction_node: interaction.clone().translated(neg),
scene_items: scene_items.iter().map(|i| i.translated(neg)).collect(),
});
}
fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option<Vec<HitTarget>> {
if !point_hits_node_shape(node, point) {
return None;
@@ -616,6 +742,8 @@ fn layout_container_children(
scene: &mut SceneSnapshot,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
clip_rect: Option<Rect>,
) -> Vec<LayoutNode> {
if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 {
return Vec::new();
@@ -655,6 +783,7 @@ fn layout_container_children(
available_main,
text_system,
perf_stats,
layout_cache,
);
if let Some(intrinsic_started) = intrinsic_started {
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
@@ -701,6 +830,8 @@ fn layout_container_children(
scene,
text_system,
perf_stats,
layout_cache,
clip_rect,
));
cursor += child_main.max(0.0) + element.style.gap;
}
@@ -714,6 +845,7 @@ fn intrinsic_main_size(
available_main: f32,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> f32 {
if let Some(text) = child.text_node() {
let constraints = match direction {
@@ -736,7 +868,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, perf_stats),
intrinsic_size(child, available_size, text_system, perf_stats, layout_cache),
direction,
)
}
@@ -746,6 +878,7 @@ fn intrinsic_container_content_size(
content_size: UiSize,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> UiSize {
if element.children.is_empty() {
return UiSize::new(0.0, 0.0);
@@ -766,6 +899,7 @@ fn intrinsic_container_content_size(
),
text_system,
perf_stats,
layout_cache,
);
width = width.max(child.style.width.unwrap_or(child_size.width));
if !skip_main {
@@ -795,6 +929,7 @@ fn intrinsic_container_content_size(
),
text_system,
perf_stats,
layout_cache,
);
let child_main = child.style.width.unwrap_or(child_size.width);
fixed_main += child_main;
@@ -818,6 +953,7 @@ fn intrinsic_container_content_size(
),
text_system,
perf_stats,
layout_cache,
);
if !skip_main {
width += child_main;
@@ -834,9 +970,35 @@ fn intrinsic_size(
available_size: UiSize,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> UiSize {
perf_stats.intrinsic_size_calls += 1;
// Intrinsic size cache check.
// Round to nearest pixel — same rationale as the layout cache key.
let cache_key = (
element.subtree_hash(),
available_size.width.round() as u32,
available_size.height.round() as u32,
);
if let Some(&cached) = layout_cache.intrinsic_cache.get(&cache_key) {
perf_stats.intrinsic_cache_hits += 1;
return cached;
}
let insets = content_insets(&element.style);
let result = intrinsic_size_inner(element, available_size, insets, text_system, perf_stats, layout_cache);
layout_cache.intrinsic_cache.insert(cache_key, result);
result
}
fn intrinsic_size_inner(
element: &Element,
available_size: UiSize,
insets: Edges,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> UiSize {
if let Some(text) = element.text_node() {
let measured = text_system.measure_spans(
&text.spans,
@@ -886,7 +1048,7 @@ fn intrinsic_size(
}
let intrinsic_content =
intrinsic_container_content_size(element, content_size, text_system, perf_stats);
intrinsic_container_content_size(element, content_size, text_system, perf_stats, layout_cache);
UiSize::new(
explicit_width
@@ -895,6 +1057,43 @@ fn intrinsic_size(
)
}
/// Cache for incremental layout. Holds both full-subtree layout results and
/// intrinsic-size results keyed by subtree hash + available size.
///
/// Cleared by calling [`LayoutCache::clear`] or dropped between runs if desired.
/// In practice, the cache is held across frames so unchanged subtrees pay no layout cost.
#[derive(Default)]
pub struct LayoutCache {
results: HashMap<LayoutCacheKey, CachedLayout>,
/// Intrinsic-size cache: keyed by (subtree_hash, avail_w.to_bits(), avail_h.to_bits()).
pub intrinsic_cache: HashMap<(u64, u32, u32), UiSize>,
}
impl LayoutCache {
pub fn new() -> Self {
Self::default()
}
pub fn clear(&mut self) {
self.results.clear();
self.intrinsic_cache.clear();
}
}
#[derive(Eq, Hash, PartialEq)]
struct LayoutCacheKey {
subtree_hash: u64,
avail_width_bits: u32,
avail_height_bits: u32,
}
struct CachedLayout {
/// Layout node in origin-relative (local) coordinates.
interaction_node: LayoutNode,
/// Scene items in origin-relative (local) coordinates.
scene_items: Vec<DisplayItem>,
}
#[derive(Debug, Default)]
struct LayoutPerfStats {
enabled: bool,
@@ -909,6 +1108,10 @@ struct LayoutPerfStats {
intrinsic_ms: f64,
text_prepare_calls: usize,
text_prepare_ms: f64,
viewport_culled: usize,
layout_cache_hits: usize,
layout_cache_misses: usize,
intrinsic_cache_hits: usize,
}
impl LayoutPerfStats {
@@ -926,10 +1129,23 @@ impl LayoutPerfStats {
intrinsic_ms: 0.0,
text_prepare_calls: 0,
text_prepare_ms: 0.0,
viewport_culled: 0,
layout_cache_hits: 0,
layout_cache_misses: 0,
intrinsic_cache_hits: 0,
}
}
}
/// Returns true if two rects have any overlap (including touching edges).
#[inline]
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.origin.x < b.origin.x + b.size.width
&& a.origin.x + a.size.width > b.origin.x
&& a.origin.y < b.origin.y + b.size.height
&& a.origin.y + a.size.height > b.origin.y
}
fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option<ElementId>) -> PreparedImage {
let source_size = image.resource.size();
let source_aspect = if source_size.height > 0.0 {
@@ -1321,10 +1537,11 @@ fn child_rect(
#[cfg(test)]
mod tests {
use super::{layout_scene, layout_snapshot};
use super::{LayoutCache, layout_scene, layout_snapshot, layout_snapshot_with_cache};
use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
use crate::text::{TextStyle, TextWrap};
use crate::tree::{Edges, Element, ElementId};
use crate::tree::{Edges, Element, ElementId, FlexDirection};
use crate::text::TextSystem;
#[test]
fn row_layout_apportions_fixed_and_flex_children() {
@@ -1916,4 +2133,92 @@ mod tests {
.is_none()
);
}
// --- incremental layout cache tests ---
fn text_style() -> TextStyle {
TextStyle::new(14.0, Color::rgb(0xFF, 0xFF, 0xFF))
}
fn make_tree(n: usize) -> Element {
let mut root = Element::new().direction(FlexDirection::Column);
for i in 0..n {
root = root.child(Element::text(format!("item {i}"), text_style()));
}
root
}
#[test]
fn stable_layout_produces_equal_snapshots() {
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
let root = make_tree(10);
let size = UiSize::new(400.0, 600.0);
let snap1 = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
let snap2 = layout_snapshot_with_cache(2, size, &root, &mut text_system, &mut layout_cache);
assert_eq!(snap1.scene.items, snap2.scene.items, "scene items should be identical");
assert_eq!(
snap1.interaction_tree.root,
snap2.interaction_tree.root,
"interaction trees should be identical"
);
}
#[test]
fn cache_effectiveness_single_change() {
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
// Build a 200-node tree; warm up the cache.
let root = make_tree(200);
let size = UiSize::new(400.0, 4000.0);
layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
// Change one node and re-layout.
let mut root2 = root;
root2.children[100] = Element::text("CHANGED", text_style());
let _ = layout_snapshot_with_cache(2, size, &root2, &mut text_system, &mut layout_cache);
let hits = layout_cache.results.len();
// At least 199 of 200 children should be cached (one changed).
// The root (container) also misses since its hash changed.
// Total cache entries: 200 children (199 hit on second pass + 1 new) + root.
// We verify that we have substantially more hits than misses on the second pass
// by checking that the cache has entries for at least 199 children.
assert!(hits >= 199, "expected >= 199 cache entries, got {hits}");
}
#[test]
fn scroll_culling_skips_off_screen_children() {
use crate::tree::ScrollbarStyle;
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
// Scroll box showing roughly 5 items (each 40px tall), 100 total.
let item_height = 40.0;
let viewport_height = 200.0;
let n = 100;
let mut scroll_box = Element::scroll_box(0.0).width(400.0).height(viewport_height);
for i in 0..n {
scroll_box = scroll_box.child(
Element::new()
.height(item_height)
.child(Element::text(format!("item {i}"), text_style()))
);
}
let root = Element::new().child(scroll_box);
let size = UiSize::new(400.0, viewport_height);
// Hack: we need LayoutPerfStats to check viewport_culled.
// Instead, test indirectly: scene items for off-screen text should be absent.
let snap = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
// Only text items within the 200px viewport should appear in the scene.
let text_items = snap.scene.items.iter().filter(|item| {
matches!(item, DisplayItem::Text(_))
}).count();
// At most 6 text items should be visible (5 fit + 1 partial).
assert!(text_items <= 6, "expected <= 6 text items in scene, got {text_items} (culling not working)");
assert!(text_items >= 4, "expected >= 4 text items visible, got {text_items}");
}
}

View File

@@ -8,6 +8,7 @@
pub(crate) mod trace_targets {
pub const PLATFORM: &str = "ruin_ui::platform";
pub const SCENE: &str = "ruin_ui::scene";
pub const TEXT_PERF: &str = "ruin_ui::text_perf";
}
mod image;
@@ -28,8 +29,8 @@ pub use interaction::{
};
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
pub use layout::{
HitTarget, InteractionTree, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
TextHitTarget, layout_snapshot, layout_snapshot_with_text_system,
HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
TextHitTarget, layout_snapshot, layout_snapshot_with_cache, layout_snapshot_with_text_system,
};
pub use layout::{layout_scene, layout_scene_with_text_system};
pub use platform::{
@@ -39,7 +40,8 @@ pub use platform::{
pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
pub use scene::{
ClipRegion, Color, DisplayItem, GlyphInstance, Point, PreparedImage, PreparedText,
PreparedTextLine, Quad, Rect, RoundedRect, SceneSnapshot, ShadowRect, Translation, UiSize,
PreparedTextLine, Quad, Rect, RoundedRect, SceneSnapshot, ShadowRect, TextLayoutData,
Translation, UiSize,
};
pub use text::{
TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight,

View File

@@ -1,6 +1,7 @@
//! Renderer-oriented scene snapshot types.
use std::ops::Range;
use std::ops::{Deref, Range};
use std::sync::Arc;
use cosmic_text::CacheKey;
use tracing::debug;
@@ -56,6 +57,13 @@ impl Rect {
&& point.x < self.origin.x + self.size.width
&& point.y < self.origin.y + self.size.height
}
pub fn intersects(self, other: Rect) -> bool {
self.origin.x < other.origin.x + other.size.width
&& self.origin.x + self.size.width > other.origin.x
&& self.origin.y < other.origin.y + other.size.height
&& self.origin.y + self.size.height > other.origin.y
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -67,6 +75,11 @@ pub struct Color {
}
impl Color {
/// Sentinel value meaning "use the PreparedText default_color at render time".
/// Fully transparent black (a == 0) is never a useful visible color, so it is
/// safe to reserve as a sentinel. No user-facing API sets this value directly.
pub const SENTINEL: Self = Self::rgba(0, 0, 0, 0);
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
@@ -74,6 +87,12 @@ impl Color {
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self::rgba(r, g, b, 255)
}
/// Returns `true` when this color is the sentinel "use default" marker.
#[inline]
pub const fn is_sentinel(self) -> bool {
self.a == 0
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -130,6 +149,10 @@ pub struct ClipRegion {
pub struct GlyphInstance {
pub position: Point,
pub advance: f32,
/// Glyph color. When equal to `Color::SENTINEL` the renderer uses the
/// enclosing `PreparedText::default_color`. This allows the text layout
/// cache to be color-independent: the same shaped data can be reused for
/// the same text displayed in different colors.
pub color: Color,
pub cache_key: Option<CacheKey>,
pub text_start: usize,
@@ -145,6 +168,21 @@ pub struct PreparedTextLine {
pub glyph_end: usize,
}
/// Shaped, positioned text ready for rendering.
///
/// Glyphs and line rects are stored in **local (origin-relative) coordinates**:
/// add `self.origin` to convert to absolute window coords. This keeps the
/// `Arc<TextLayoutData>` from the text shape cache shareable across different
/// on-screen positions — no per-frame translation is required.
///
/// The `lines` and `glyphs` are stored behind an `Arc` so that cloning a
/// `PreparedText` (e.g. to put the same shaped text into both the scene
/// snapshot and the interaction tree) is O(1). The `Arc` is shared as long
/// as no mutation is needed; `apply_selected_text_color` uses
/// `Arc::make_mut` and clones on first write.
///
/// Glyph colors may be `Color::SENTINEL` — the renderer substitutes
/// `default_color` for those glyphs.
#[derive(Clone, Debug, PartialEq)]
pub struct PreparedText {
pub element_id: Option<ElementId>,
@@ -153,22 +191,69 @@ pub struct PreparedText {
pub bounds: Option<UiSize>,
pub font_size: f32,
pub line_height: f32,
pub color: Color,
/// Default color applied to sentinel-colored glyphs at render time.
pub default_color: Color,
pub selectable: bool,
pub selection_style: TextSelectionStyle,
pub lines: Vec<PreparedTextLine>,
pub glyphs: Vec<GlyphInstance>,
layout: Arc<TextLayoutData>,
}
/// Shaped glyph and line data shared between scene and interaction-tree copies
/// of the same `PreparedText`. Coordinates are LOCAL (relative to `PreparedText::origin`).
/// Add `origin` to convert to absolute window coords.
#[derive(Clone, Debug, PartialEq)]
pub struct PreparedImage {
pub element_id: Option<ElementId>,
pub resource: ImageResource,
pub rect: Rect,
pub uv_rect: (f32, f32, f32, f32),
pub struct TextLayoutData {
pub lines: Vec<PreparedTextLine>,
pub glyphs: Vec<GlyphInstance>,
/// Measured (unclamped) size of the laid-out text.
pub size: UiSize,
}
impl Deref for PreparedText {
type Target = TextLayoutData;
fn deref(&self) -> &TextLayoutData {
&self.layout
}
}
impl PreparedText {
/// Construct a `PreparedText` from shaped data. Called by `TextSystem::prepare_spans`.
pub(crate) fn from_layout(
element_id: Option<ElementId>,
text: String,
origin: Point,
bounds: Option<UiSize>,
font_size: f32,
line_height: f32,
default_color: Color,
selectable: bool,
selection_style: TextSelectionStyle,
layout: Arc<TextLayoutData>,
) -> Self {
Self {
element_id,
text,
origin,
bounds,
font_size,
line_height,
default_color,
selectable,
selection_style,
layout,
}
}
/// Returns a raw pointer to the underlying `TextLayoutData` allocation.
/// Used in tests to verify Arc sharing between PreparedTexts.
#[cfg(test)]
pub(crate) fn layout_ptr(&self) -> *const TextLayoutData {
Arc::as_ptr(&self.layout)
}
/// Create a monospace `PreparedText` without going through the text system.
/// Used for testing and low-level terminal-style rendering.
pub fn monospace(
text: impl Into<String>,
origin: Point,
@@ -177,21 +262,31 @@ impl PreparedText {
color: Color,
) -> Self {
let text = text.into();
let mut x = origin.x;
// Glyphs are stored in LOCAL (origin-relative) coordinates.
let mut local_x = 0.0f32;
let mut glyphs = Vec::with_capacity(text.chars().count());
for (text_start, ch) in text.char_indices() {
let text_end = text_start + ch.len_utf8();
// monospace() builds glyphs directly; use the actual color (not sentinel)
// because this path does not go through the text cache.
glyphs.push(GlyphInstance {
position: Point::new(x, origin.y),
position: Point::new(local_x, 0.0),
advance,
color,
cache_key: None,
text_start,
text_end,
});
x += advance;
local_x += advance;
}
let line = PreparedTextLine {
rect: Rect::new(0.0, 0.0, local_x, font_size),
text_start: 0,
text_end: glyphs.last().map_or(0, |glyph| glyph.text_end),
glyph_start: 0,
glyph_end: glyphs.len(),
};
let size = UiSize::new(local_x, font_size);
Self {
element_id: None,
text,
@@ -199,25 +294,34 @@ impl PreparedText {
bounds: None,
font_size,
line_height: font_size,
color,
default_color: 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,
layout: Arc::new(TextLayoutData {
lines: vec![line],
glyphs,
size,
}),
}
}
/// Translate this text by `offset`. O(1): only `self.origin` is updated.
/// Glyphs are stored in local coords and are unaffected.
pub fn translated(mut self, offset: Point) -> Self {
self.origin.x += offset.x;
self.origin.y += offset.y;
self
}
/// Return the byte offset within `self.text` for an absolute window-space point.
pub fn byte_offset_for_position(&self, point: Point) -> usize {
let Some(line) = self.line_for_position(point.y) else {
// Convert to local coords before delegating to the local-space helpers.
let local_x = point.x - self.origin.x;
let local_y = point.y - self.origin.y;
let Some(line) = self.line_for_position(local_y) else {
return 0;
};
self.byte_offset_for_line_position(line, point.x)
self.byte_offset_for_line_position(line, local_x)
}
pub fn selection_range(&self, start: usize, end: usize) -> Range<usize> {
@@ -252,9 +356,10 @@ impl PreparedText {
}
if let (Some(left), Some(right)) = (left, right) {
// Glyph x/y and line rect are in local coords; add origin for absolute result.
rects.push(Rect::new(
left,
line.rect.origin.y,
self.origin.x + left,
self.origin.y + line.rect.origin.y,
(right - left).max(0.0),
line.rect.size.height,
));
@@ -266,10 +371,11 @@ impl PreparedText {
pub fn caret_rect(&self, offset: usize, width: f32) -> Option<Rect> {
let width = width.max(0.0);
let line = self.line_for_offset(offset)?;
let x = self.caret_x_for_line_offset(line, offset);
// caret_x_for_line_offset returns local x; add origin for absolute result.
let local_x = self.caret_x_for_line_offset(line, offset);
Some(Rect::new(
x,
line.rect.origin.y,
self.origin.x + local_x,
self.origin.y + line.rect.origin.y,
width,
line.rect.size.height,
))
@@ -346,6 +452,8 @@ impl PreparedText {
start..end
}
/// Apply `selection_style.text_color` to glyphs in `[start, end)`.
/// Clones the inner `Arc<TextLayoutData>` on first call if it is shared.
pub fn apply_selected_text_color(&mut self, start: usize, end: usize) {
let Some(selected_color) = self.selection_style.text_color else {
return;
@@ -354,7 +462,7 @@ impl PreparedText {
if range.is_empty() {
return;
}
for glyph in &mut self.glyphs {
for glyph in Arc::make_mut(&mut self.layout).glyphs.iter_mut() {
if glyph.text_end > range.start && glyph.text_start < range.end {
glyph.color = selected_color;
}
@@ -488,6 +596,14 @@ fn classify_word_char(ch: char) -> WordClass {
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct PreparedImage {
pub element_id: Option<ElementId>,
pub resource: ImageResource,
pub rect: Rect,
pub uv_rect: (f32, f32, f32, f32),
}
#[derive(Clone, Debug, PartialEq)]
pub enum DisplayItem {
Quad(Quad),
@@ -503,6 +619,42 @@ pub enum DisplayItem {
LayerEnd,
}
impl DisplayItem {
/// Return a copy of this item with all positions shifted by `offset`.
pub fn translated(&self, offset: Point) -> Self {
fn translate_rect(r: Rect, o: Point) -> Rect {
Rect::new(r.origin.x + o.x, r.origin.y + o.y, r.size.width, r.size.height)
}
match self {
Self::Quad(q) => Self::Quad(Quad { rect: translate_rect(q.rect, offset), ..*q }),
Self::RoundedRect(r) => Self::RoundedRect(RoundedRect {
rect: translate_rect(r.rect, offset),
..*r
}),
Self::ShadowRect(s) => Self::ShadowRect(ShadowRect {
rect: translate_rect(s.rect, offset),
source_rect: translate_rect(s.source_rect, offset),
..*s
}),
Self::Image(img) => Self::Image(PreparedImage {
rect: translate_rect(img.rect, offset),
..img.clone()
}),
Self::Text(text) => Self::Text(text.clone().translated(offset)),
Self::PushClip(clip) => Self::PushClip(ClipRegion {
rect: translate_rect(clip.rect, offset),
..*clip
}),
// These items carry no position data or are balanced markers.
Self::PopClip => Self::PopClip,
Self::PushTransform(t) => Self::PushTransform(*t),
Self::PopTransform => Self::PopTransform,
Self::LayerBegin { opacity } => Self::LayerBegin { opacity: *opacity },
Self::LayerEnd => Self::LayerEnd,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SceneSnapshot {
pub version: SceneVersion,
@@ -581,6 +733,23 @@ impl SceneSnapshot {
}
}
// ---------------------------------------------------------------------------
// Hash implementations for f32-containing types.
impl std::hash::Hash for Point {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.x.to_bits().hash(state);
self.y.to_bits().hash(state);
}
}
impl std::hash::Hash for UiSize {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.width.to_bits().hash(state);
self.height.to_bits().hash(state);
}
}
#[cfg(test)]
mod tests {
use super::{Color, Point, PreparedText, Rect};
@@ -669,6 +838,9 @@ mod tests {
#[test]
fn prepared_text_vertical_offset_moves_between_lines() {
use std::sync::Arc;
use super::{PreparedTextLine, TextLayoutData};
let mut text = PreparedText::monospace(
"abcdwxyz",
Point::new(10.0, 20.0),
@@ -676,28 +848,32 @@ mod tests {
8.0,
Color::rgb(0xFF, 0xFF, 0xFF),
);
text.lines = vec![
super::PreparedTextLine {
rect: Rect::new(10.0, 20.0, 32.0, 16.0),
// Lines and glyphs use LOCAL (origin-relative) coordinates.
let lines = vec![
PreparedTextLine {
rect: Rect::new(0.0, 0.0, 32.0, 16.0),
text_start: 0,
text_end: 4,
glyph_start: 0,
glyph_end: 4,
},
super::PreparedTextLine {
rect: Rect::new(10.0, 36.0, 32.0, 16.0),
PreparedTextLine {
rect: Rect::new(0.0, 16.0, 32.0, 16.0),
text_start: 4,
text_end: 8,
glyph_start: 4,
glyph_end: 8,
},
];
for (index, glyph) in text.glyphs.iter_mut().enumerate() {
let mut glyphs = text.glyphs.to_vec();
for (index, glyph) in glyphs.iter_mut().enumerate() {
if index >= 4 {
glyph.position.y = 36.0;
glyph.position.x = 10.0 + ((index - 4) as f32 * 8.0);
glyph.position.y = 16.0;
glyph.position.x = (index - 4) as f32 * 8.0;
}
}
let orig_size = text.layout.size;
text.layout = Arc::new(TextLayoutData { lines, glyphs, size: orig_size });
assert_eq!(text.vertical_offset(2, 1), Some(6));
assert_eq!(text.vertical_offset(6, -1), Some(2));
@@ -714,12 +890,14 @@ mod tests {
assert_eq!(text.lines.len(), 3);
let target_line = &text.lines[1];
let y = target_line.rect.origin.y + target_line.rect.size.height * 0.5;
let start = text.byte_offset_for_position(Point::new(target_line.rect.origin.x, y));
let end = text.byte_offset_for_position(Point::new(target_line.rect.origin.x + 16.0, y));
// Lines store LOCAL coords; add text.origin to get absolute window coords for the query.
let y = text.origin.y + target_line.rect.origin.y + target_line.rect.size.height * 0.5;
let start = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x, y));
let end = text.byte_offset_for_position(Point::new(text.origin.x + target_line.rect.origin.x + 16.0, y));
let rects = text.selection_rects(start, end);
assert_eq!(rects.len(), 1);
assert_eq!(rects[0].origin.y, target_line.rect.origin.y);
// selection_rects returns absolute coords; compare against absolute line y.
assert_eq!(rects[0].origin.y, text.origin.y + target_line.rect.origin.y);
}
}

View File

@@ -1,6 +1,7 @@
use std::collections::{BTreeSet, HashMap};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::mem;
use std::sync::Arc;
use std::time::Instant;
use cosmic_text::{
@@ -9,7 +10,8 @@ use cosmic_text::{
};
use fontconfig::Fontconfig;
use crate::{Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, UiSize};
use crate::{Color, GlyphInstance, Point, PreparedText, PreparedTextLine, Rect, TextLayoutData,
UiSize};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TextAlign {
@@ -201,20 +203,70 @@ impl TextStyle {
}
}
// ---------------------------------------------------------------------------
// LRU text layout cache
// ---------------------------------------------------------------------------
/// Simple generation-based LRU cache for `Arc<TextLayoutData>`.
/// No external dependencies — evicts the least-recently-used entry on insert
/// when at capacity.
struct LruTextCache {
map: HashMap<u64, (u64, Arc<TextLayoutData>)>, // key → (generation, data)
generation: u64,
capacity: usize,
}
impl LruTextCache {
fn new(capacity: usize) -> Self {
Self {
map: HashMap::with_capacity(capacity.min(64)),
generation: 0,
capacity,
}
}
fn get(&mut self, key: u64) -> Option<Arc<TextLayoutData>> {
if let Some((lru, data)) = self.map.get_mut(&key) {
self.generation += 1;
*lru = self.generation;
return Some(Arc::clone(data));
}
None
}
fn insert(&mut self, key: u64, data: Arc<TextLayoutData>) {
if self.map.len() >= self.capacity && !self.map.contains_key(&key) {
// Evict the entry with the lowest generation (least recently used).
if let Some(oldest_key) = self
.map
.iter()
.min_by_key(|(_, (lru, _))| *lru)
.map(|(k, _)| *k)
{
self.map.remove(&oldest_key);
}
}
self.generation += 1;
self.map.insert(key, (self.generation, data));
}
#[allow(dead_code)]
fn len(&self) -> usize {
self.map.len()
}
}
// ---------------------------------------------------------------------------
// TextSystem
// ---------------------------------------------------------------------------
pub struct TextSystem {
font_system: FontSystem,
family_resolver: FontFamilyResolver,
layout_cache: HashMap<u64, TextLayout>,
layout_cache: LruTextCache,
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,
@@ -246,7 +298,7 @@ impl TextSystem {
Self {
font_system,
family_resolver,
layout_cache: HashMap::new(),
layout_cache: LruTextCache::new(1024),
frame_stats: TextFrameStats::default(),
}
}
@@ -269,6 +321,17 @@ impl TextSystem {
self.prepare_spans(&spans, origin, style, style.bounds)
}
/// Shape `spans` and return a `PreparedText` with origin-relative glyph positions.
///
/// Glyphs are stored in LOCAL coordinates (relative to `origin`). The
/// renderer adds `text.origin` when emitting geometry. This allows the
/// cached `Arc<TextLayoutData>` to be shared directly — no per-call
/// translation is needed.
///
/// Glyphs that use the default color are stored with `Color::SENTINEL`;
/// the renderer substitutes `PreparedText::default_color` for those. This
/// makes the shape cache color-independent: the same shaped data is reused
/// even when `style.color` differs between calls.
pub fn prepare_spans(
&mut self,
spans: &[TextSpan],
@@ -278,54 +341,26 @@ impl TextSystem {
) -> PreparedText {
let bounds = bounds.or(style.bounds);
let text = combined_text(spans);
let layout = self.layout(
// Use the cached Arc directly — glyphs are in local (origin-0) coords.
let layout_data = self.layout(
spans,
style,
bounds.map(|bounds| bounds.width),
bounds.map(|bounds| bounds.height),
bounds.map(|b| b.width),
bounds.map(|b| b.height),
);
let glyphs = layout
.glyphs
.into_iter()
.map(|glyph| GlyphInstance {
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,
PreparedText::from_layout(
None,
text,
origin,
bounds,
font_size: style.font_size,
line_height: style.line_height,
color: style.color,
selectable: style.selectable,
selection_style: style.selection_style,
lines,
glyphs,
}
style.font_size,
style.line_height,
style.color,
style.selectable,
style.selection_style,
layout_data,
)
}
pub fn measure(
@@ -346,26 +381,40 @@ impl TextSystem {
width: Option<f32>,
height: Option<f32>,
) -> UiSize {
self.layout(spans, style, width, height).size
let layout = self.layout(spans, style, width, height);
// Clamp the measured size to the supplied bounds (mirrors old clamp_text_layout).
let mut size = layout.size;
if let Some(w) = width {
size.width = size.width.min(w.max(0.0));
}
if let Some(h) = height {
size.height = size.height.min(h.max(0.0));
}
size
}
/// Return the cached (origin-0) `Arc<TextLayoutData>` for the given spans/style.
/// Builds and caches on miss. Color is NOT part of the cache key; glyphs
/// that take the default color are stored with `Color::SENTINEL`.
fn layout(
&mut self,
spans: &[TextSpan],
style: &TextStyle,
width: Option<f32>,
height: Option<f32>,
) -> TextLayout {
) -> Arc<TextLayoutData> {
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) {
if let Some(cached) = 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);
.saturating_add(cached.glyphs.len() as u32);
return cached;
}
self.frame_stats.cache_misses = self.frame_stats.cache_misses.saturating_add(1);
let miss_started = Instant::now();
@@ -391,7 +440,11 @@ impl TextSystem {
{
let mut borrowed = buffer.borrow_with(&mut self.font_system);
borrowed.set_wrap(style.wrap.to_cosmic());
borrowed.set_size(width, None);
// For no-wrap + start-aligned text, width doesn't affect shaping.
// Pass None so cosmic-text doesn't truncate and the result is
// consistent with the width-independent cache key.
let effective_width = if width_affects_layout(style) { width } else { None };
borrowed.set_size(effective_width, None);
let default_attrs = default_attrs_for_style(style, default_family.as_deref());
if uses_plain_text_fast_path {
borrowed.set_text(
@@ -438,7 +491,13 @@ impl TextSystem {
GlyphInstance {
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),
// Use SENTINEL for default-colored glyphs so that the cache
// is color-independent. Per-span colors (from glyph.color_opt)
// are stored directly since they are part of the shape key via
// the spans hash.
color: glyph
.color_opt
.map_or(Color::SENTINEL, color_from_cosmic),
cache_key: Some(physical.cache_key),
text_start: line_offset + glyph.start,
text_end: line_offset + glyph.end,
@@ -464,21 +523,34 @@ impl TextSystem {
self.frame_stats.glyph_collect_ms +=
glyph_collect_started.elapsed().as_secs_f64() * 1_000.0;
let layout = TextLayout {
let layout = Arc::new(TextLayoutData {
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();
// Warn loudly when a single text node is large enough to cause frame drops.
// At 16px, 10_000 glyphs ≈ 600 lines of text. Use a virtual list instead.
const GLYPH_COUNT_WARNING_THRESHOLD: usize = 10_000;
if layout.glyphs.len() > GLYPH_COUNT_WARNING_THRESHOLD {
tracing::warn!(
target: "ruin_ui::text_perf",
glyph_count = layout.glyphs.len(),
shape_ms = miss_started.elapsed().as_secs_f64() * 1_000.0,
"large text node shaped: consider splitting into a virtual list \
(each item a separate Element::text) so off-screen items can be \
culled and unchanged items skip reshaping"
);
}
self.layout_cache.insert(cache_key, layout.clone());
clamp_text_layout(layout, width, height)
self.layout_cache.insert(cache_key, Arc::clone(&layout));
layout
}
fn resolve_font_family(
@@ -534,6 +606,13 @@ fn line_start_offsets(text: &str) -> Vec<usize> {
starts
}
/// Cache key for shaped text. `style.color` is intentionally excluded:
/// glyphs that use the default color are stored with `Color::SENTINEL`, making
/// the shape cache color-independent. Per-span colors are part of `spans`.
///
/// Width is also excluded when it cannot affect the layout: no-wrap +
/// start-aligned text has the same glyph positions at any available width.
/// Omitting it prevents resize from invalidating these (often large) entries.
fn layout_cache_key(
spans: &[TextSpan],
style: &TextStyle,
@@ -544,31 +623,30 @@ fn layout_cache_key(
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.color intentionally omitted — see Color::SENTINEL
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);
// Width is excluded for no-wrap + start-aligned text: glyph positions are
// independent of container width, so the cached data is valid at any width.
if width_affects_layout(style) {
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
/// Returns `true` if the available width can change the shaped glyph positions.
///
/// For `TextWrap::None` + `TextAlign::Start`, text flows past any boundary
/// and lines are left-aligned, so width has no effect on the output.
#[inline]
fn width_affects_layout(style: &TextStyle) -> bool {
style.wrap != TextWrap::None || style.align != TextAlign::Start
}
fn quantized_layout_dimension(value: Option<f32>) -> Option<u32> {
const LAYOUT_CACHE_BUCKET_PX: f32 = 8.0;
const LAYOUT_CACHE_BUCKET_PX: f32 = 1.0;
value.map(|value| {
(value / LAYOUT_CACHE_BUCKET_PX)
.round()
@@ -595,9 +673,10 @@ fn default_attrs_for_style<'a>(
style: &'a TextStyle,
resolved_family: Option<&'a str>,
) -> Attrs<'a> {
// Do NOT pass style.color to cosmic-text — default-colored glyphs should have
// color_opt = None so they are stored as Color::SENTINEL (color-independent cache).
Attrs::new()
.family(font_family_to_cosmic(&style.font_family, resolved_family))
.color(to_cosmic_color(style.color))
.metrics(Metrics::new(style.font_size, style.line_height))
}
@@ -607,10 +686,16 @@ fn attrs_for_span<'a>(
resolved_family: Option<&'a str>,
) -> Attrs<'a> {
let font_family = span.font_family.as_ref().unwrap_or(&style.font_family);
default_attrs_for_style(style, resolved_family)
.family(font_family_to_cosmic(font_family, resolved_family))
.color(to_cosmic_color(span.color.unwrap_or(style.color)))
.weight(match span.weight {
let base = default_attrs_for_style(style, resolved_family)
.family(font_family_to_cosmic(font_family, resolved_family));
// Only set a color when the span explicitly overrides the default. Spans that
// inherit style.color leave color_opt = None, producing Color::SENTINEL in the cache.
let base = if let Some(span_color) = span.color {
base.color(to_cosmic_color(span_color))
} else {
base
};
base.weight(match span.weight {
TextSpanWeight::Normal => CosmicWeight::NORMAL,
TextSpanWeight::Medium => CosmicWeight::MEDIUM,
TextSpanWeight::Semibold => CosmicWeight::SEMIBOLD,
@@ -829,6 +914,31 @@ impl TextFontFamily {
}
}
// ---------------------------------------------------------------------------
// Hash implementations for f32-containing types.
impl std::hash::Hash for TextSelectionStyle {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.highlight_color.hash(state);
self.text_color.hash(state);
}
}
impl std::hash::Hash for TextStyle {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.font_size.to_bits().hash(state);
self.line_height.to_bits().hash(state);
self.color.hash(state);
self.font_family.hash(state);
self.bounds.map(|b| (b.width.to_bits(), b.height.to_bits())).hash(state);
self.wrap.hash(state);
self.align.hash(state);
self.max_lines.hash(state);
self.selectable.hash(state);
self.selection_style.hash(state);
}
}
#[cfg(test)]
mod tests {
use super::{
@@ -915,6 +1025,34 @@ mod tests {
);
}
/// Same text at two different default colors must produce identical shaped glyph data
/// (same positions, same cache keys, SENTINEL colors) since color is not part of
/// the shape cache key.
#[test]
fn same_text_different_default_color_shares_layout_data() {
let mut text_system = TextSystem::new();
let style_red = TextStyle::new(16.0, Color::rgb(0xFF, 0x00, 0x00));
let style_blue = TextStyle::new(16.0, Color::rgb(0x00, 0x00, 0xFF));
let origin = Point::new(0.0, 0.0);
let red = text_system.prepare("hello", origin, &style_red);
let blue = text_system.prepare("hello", origin, &style_blue);
// Both PreparedTexts must have identical glyph shapes and sentinel colors —
// the second call hits the shape cache and produces the same layout data.
assert_eq!(red.glyphs.len(), blue.glyphs.len(), "glyph count must match");
for (r, b) in red.glyphs.iter().zip(blue.glyphs.iter()) {
assert_eq!(r.position, b.position, "glyph positions must match");
assert_eq!(r.cache_key, b.cache_key, "glyph cache keys must match");
assert_eq!(r.color, Color::SENTINEL, "default-color glyphs must use SENTINEL");
assert_eq!(b.color, Color::SENTINEL, "default-color glyphs must use SENTINEL");
}
// PreparedText::clone() is an Arc clone — O(1).
let cloned = red.clone();
assert_eq!(cloned.layout_ptr(), red.layout_ptr(), "clone must share the same Arc");
// But they should carry different default colors.
assert_eq!(red.default_color, Color::rgb(0xFF, 0x00, 0x00));
assert_eq!(blue.default_color, Color::rgb(0x00, 0x00, 0xFF));
}
#[test]
fn preferred_family_name_uses_first_available_candidate() {
let selected = preferred_family_name(

View File

@@ -1,3 +1,5 @@
use std::hash::{Hash, Hasher};
use crate::scene::{Color, Point};
use crate::text::{TextSpan, TextStyle, TextWrap};
use crate::{ImageFit, ImageResource};
@@ -462,3 +464,144 @@ impl Default for Element {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Hash implementations for f32-containing types.
// f32 fields use `.to_bits()` so NaN != NaN is avoided in hashing context
// (layout values are always finite so NaN is not expected).
impl Hash for FlexDirection {
fn hash<H: Hasher>(&self, state: &mut H) {
(*self as u8).hash(state);
}
}
impl Hash for Edges {
fn hash<H: Hasher>(&self, state: &mut H) {
self.top.to_bits().hash(state);
self.right.to_bits().hash(state);
self.bottom.to_bits().hash(state);
self.left.to_bits().hash(state);
}
}
impl Hash for Border {
fn hash<H: Hasher>(&self, state: &mut H) {
self.width.to_bits().hash(state);
self.color.hash(state);
}
}
impl Hash for CornerRadius {
fn hash<H: Hasher>(&self, state: &mut H) {
self.top_left.to_bits().hash(state);
self.top_right.to_bits().hash(state);
self.bottom_right.to_bits().hash(state);
self.bottom_left.to_bits().hash(state);
}
}
impl Hash for BoxShadowKind {
fn hash<H: Hasher>(&self, state: &mut H) {
(*self as u8).hash(state);
}
}
impl Hash for BoxShadow {
fn hash<H: Hasher>(&self, state: &mut H) {
self.offset.hash(state);
self.blur.to_bits().hash(state);
self.spread.to_bits().hash(state);
self.color.hash(state);
self.kind.hash(state);
}
}
impl Hash for ScrollbarStyle {
fn hash<H: Hasher>(&self, state: &mut H) {
self.gutter_width.to_bits().hash(state);
self.track_color.hash(state);
self.thumb_color.hash(state);
self.corner_radius.to_bits().hash(state);
self.min_thumb_size.to_bits().hash(state);
}
}
impl Hash for Style {
fn hash<H: Hasher>(&self, state: &mut H) {
self.direction.hash(state);
self.width.map(f32::to_bits).hash(state);
self.height.map(f32::to_bits).hash(state);
self.flex_grow.to_bits().hash(state);
self.gap.to_bits().hash(state);
self.padding.hash(state);
self.background.hash(state);
self.border.hash(state);
self.corner_radius.hash(state);
self.box_shadows.hash(state);
self.pointer_events.hash(state);
self.focusable.hash(state);
self.cursor.hash(state);
}
}
impl Hash for TextNode {
fn hash<H: Hasher>(&self, state: &mut H) {
self.spans.hash(state);
self.style.hash(state);
}
}
impl Hash for ImageNode {
fn hash<H: Hasher>(&self, state: &mut H) {
self.resource.hash(state);
self.fit.hash(state);
}
}
impl Hash for ScrollBoxNode {
fn hash<H: Hasher>(&self, state: &mut H) {
self.offset_y.to_bits().hash(state);
self.scrollbar.hash(state);
}
}
impl Hash for ElementContent {
fn hash<H: Hasher>(&self, state: &mut H) {
match self {
Self::Container => 0u8.hash(state),
Self::ScrollBox(s) => {
1u8.hash(state);
s.hash(state);
}
Self::Image(i) => {
2u8.hash(state);
i.hash(state);
}
Self::Text(t) => {
3u8.hash(state);
t.hash(state);
}
}
}
}
impl Hash for Element {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
self.style.hash(state);
self.children.hash(state);
self.content.hash(state);
}
}
impl Element {
/// A hash of this element's entire subtree. Two subtrees with identical structure and
/// data will produce the same hash; any change produces a different hash.
pub fn subtree_hash(&self) -> u64 {
use std::hash::DefaultHasher;
let mut h = DefaultHasher::new();
self.hash(&mut h);
h.finish()
}
}