Lots of claude-driven performance work.
This commit is contained in:
@@ -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
|
||||
|
||||
94
lib/ui/benches/layout_bench.rs
Normal file
94
lib/ui/benches/layout_bench.rs
Normal 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);
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user