Text, many performance improvements

This commit is contained in:
2026-03-20 19:23:55 -04:00
parent 39ede248cf
commit 00fe1daa0c
18 changed files with 3432 additions and 26 deletions

462
lib/ui/src/layout.rs Normal file
View File

@@ -0,0 +1,462 @@
use crate::scene::{Rect, SceneSnapshot, UiSize};
use crate::text::TextSystem;
use crate::tree::{Edges, Element, FlexDirection};
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
let mut text_system = TextSystem::new();
layout_scene_with_text_system(version, logical_size, root, &mut text_system)
}
pub fn layout_scene_with_text_system(
version: u64,
logical_size: UiSize,
root: &Element,
text_system: &mut TextSystem,
) -> SceneSnapshot {
let mut scene = SceneSnapshot::new(version, logical_size);
layout_element(
root,
Rect::new(
0.0,
0.0,
logical_size.width.max(0.0),
logical_size.height.max(0.0),
),
&mut scene,
text_system,
);
scene
}
fn layout_element(
element: &Element,
rect: Rect,
scene: &mut SceneSnapshot,
text_system: &mut TextSystem,
) {
if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
return;
}
if let Some(color) = element.style.background {
scene.push_quad(rect, color);
}
if let Some(text) = element.text_node() {
let content = inset_rect(rect, element.style.padding);
if content.size.width > 0.0 && content.size.height > 0.0 {
scene.push_text(text_system.prepare(
text.text.clone(),
content.origin,
text.style.with_bounds(content.size),
));
}
return;
}
if element.children.is_empty() {
return;
}
let content = inset_rect(rect, element.style.padding);
if content.size.width <= 0.0 || content.size.height <= 0.0 {
return;
}
let gap_count = element.children.len().saturating_sub(1) as f32;
let total_gap = element.style.gap * gap_count;
let available_main = main_axis_size(content.size, element.style.direction).max(0.0) - total_gap;
let available_main = available_main.max(0.0);
let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0);
let mut measured_children = Vec::with_capacity(element.children.len());
let mut fixed_total = 0.0;
let mut flex_total = 0.0;
for child in &element.children {
let cross = child_cross_size(child, element.style.direction)
.unwrap_or(available_cross)
.clamp(0.0, available_cross);
let explicit_main =
child_main_size(child, element.style.direction).map(|main| main.max(0.0));
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
let measured_main = explicit_main.unwrap_or_else(|| {
if is_flex {
0.0
} else {
intrinsic_main_size(
child,
element.style.direction,
cross,
available_main,
text_system,
)
}
});
if is_flex {
flex_total += child_flex_weight(child);
} else {
fixed_total += measured_main;
}
measured_children.push(MeasuredChild {
cross,
main: measured_main,
is_flex,
});
}
let remaining_main = (available_main - fixed_total).max(0.0);
let mut cursor = main_axis_origin(content, element.style.direction);
for (child, measured) in element.children.iter().zip(measured_children) {
let child_main = if measured.is_flex {
if flex_total <= 0.0 {
0.0
} else {
remaining_main * (child_flex_weight(child) / flex_total)
}
} else {
measured.main
};
let child_rect = child_rect(
content,
element.style.direction,
cursor,
child_main.max(0.0),
measured.cross,
);
layout_element(child, child_rect, scene, text_system);
cursor += child_main.max(0.0) + element.style.gap;
}
}
#[derive(Clone, Copy, Debug)]
struct MeasuredChild {
cross: f32,
main: f32,
is_flex: bool,
}
fn intrinsic_main_size(
child: &Element,
direction: FlexDirection,
cross_size: f32,
available_main: f32,
text_system: &mut TextSystem,
) -> f32 {
if let Some(text) = child.text_node() {
let constraints = match direction {
FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))),
FlexDirection::Column => (Some(cross_size.max(0.0)), None),
};
let content = text_system.measure(&text.text, text.style, constraints.0, constraints.1);
let padding = main_axis_padding(child.style.padding, direction);
return main_axis_size(content, direction) + padding;
}
let available_size = match direction {
FlexDirection::Row => UiSize::new(available_main.max(0.0), cross_size.max(0.0)),
FlexDirection::Column => UiSize::new(cross_size.max(0.0), available_main.max(0.0)),
};
main_axis_size(
intrinsic_size(child, available_size, text_system),
direction,
)
}
fn intrinsic_size(
element: &Element,
available_size: UiSize,
text_system: &mut TextSystem,
) -> UiSize {
if let Some(text) = element.text_node() {
let measured = text_system.measure(
&text.text,
text.style,
Some(available_size.width.max(0.0)),
Some(available_size.height.max(0.0)),
);
return UiSize::new(
element.style.width.unwrap_or(
measured.width + element.style.padding.left + element.style.padding.right,
),
element.style.height.unwrap_or(
measured.height + element.style.padding.top + element.style.padding.bottom,
),
);
}
let explicit_width = element.style.width;
let explicit_height = element.style.height;
let content_width = explicit_width.unwrap_or(available_size.width).max(0.0)
- element.style.padding.left
- element.style.padding.right;
let content_height = explicit_height.unwrap_or(available_size.height).max(0.0)
- element.style.padding.top
- element.style.padding.bottom;
let content_size = UiSize::new(content_width.max(0.0), content_height.max(0.0));
if element.children.is_empty() {
return UiSize::new(
explicit_width.unwrap_or(element.style.padding.left + element.style.padding.right),
explicit_height.unwrap_or(element.style.padding.top + element.style.padding.bottom),
);
}
let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32;
let (intrinsic_content_width, intrinsic_content_height) = match element.style.direction {
FlexDirection::Column => {
let mut width: f32 = 0.0;
let mut height = gap_total;
for child in &element.children {
if child.style.flex_grow > 0.0 && child.style.height.is_none() {
continue;
}
let child_size = intrinsic_size(
child,
UiSize::new(
child.style.width.unwrap_or(content_size.width),
child.style.height.unwrap_or(content_size.height),
),
text_system,
);
width = width.max(child.style.width.unwrap_or(child_size.width));
height += child.style.height.unwrap_or(child_size.height);
}
(width, height)
}
FlexDirection::Row => {
let mut width = gap_total;
let mut height: f32 = 0.0;
for child in &element.children {
if child.style.flex_grow > 0.0 && child.style.width.is_none() {
continue;
}
let child_size = intrinsic_size(
child,
UiSize::new(
child.style.width.unwrap_or(content_size.width),
child.style.height.unwrap_or(content_size.height),
),
text_system,
);
width += child.style.width.unwrap_or(child_size.width);
height = height.max(child.style.height.unwrap_or(child_size.height));
}
(width, height)
}
};
UiSize::new(
explicit_width.unwrap_or(
intrinsic_content_width + element.style.padding.left + element.style.padding.right,
),
explicit_height.unwrap_or(
intrinsic_content_height + element.style.padding.top + element.style.padding.bottom,
),
)
}
fn inset_rect(rect: Rect, edges: Edges) -> Rect {
let width = (rect.size.width - edges.left - edges.right).max(0.0);
let height = (rect.size.height - edges.top - edges.bottom).max(0.0);
Rect::new(
rect.origin.x + edges.left,
rect.origin.y + edges.top,
width,
height,
)
}
fn child_main_size(child: &Element, direction: FlexDirection) -> Option<f32> {
match direction {
FlexDirection::Row => child.style.width,
FlexDirection::Column => child.style.height,
}
}
fn child_cross_size(child: &Element, direction: FlexDirection) -> Option<f32> {
match direction {
FlexDirection::Row => child.style.height,
FlexDirection::Column => child.style.width,
}
}
fn child_flex_weight(child: &Element) -> f32 {
if child.style.flex_grow > 0.0 {
child.style.flex_grow
} else {
1.0
}
}
fn main_axis_padding(edges: Edges, direction: FlexDirection) -> f32 {
match direction {
FlexDirection::Row => edges.left + edges.right,
FlexDirection::Column => edges.top + edges.bottom,
}
}
fn main_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
match direction {
FlexDirection::Row => size.width,
FlexDirection::Column => size.height,
}
}
fn cross_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
match direction {
FlexDirection::Row => size.height,
FlexDirection::Column => size.width,
}
}
fn main_axis_origin(rect: Rect, direction: FlexDirection) -> f32 {
match direction {
FlexDirection::Row => rect.origin.x,
FlexDirection::Column => rect.origin.y,
}
}
fn child_rect(
content: Rect,
direction: FlexDirection,
main_origin: f32,
main_size: f32,
cross_size: f32,
) -> Rect {
match direction {
FlexDirection::Row => Rect::new(main_origin, content.origin.y, main_size, cross_size),
FlexDirection::Column => Rect::new(content.origin.x, main_origin, cross_size, main_size),
}
}
#[cfg(test)]
mod tests {
use super::layout_scene;
use crate::scene::{Color, DisplayItem, Quad, Rect, UiSize};
use crate::text::{TextStyle, TextWrap};
use crate::tree::{Edges, Element};
#[test]
fn row_layout_apportions_fixed_and_flex_children() {
let root = Element::row()
.padding(Edges::all(10.0))
.gap(10.0)
.children([
Element::new()
.width(50.0)
.background(Color::rgb(0xAA, 0x11, 0x11)),
Element::new()
.flex(1.0)
.background(Color::rgb(0x11, 0xAA, 0x11)),
Element::new()
.flex(2.0)
.background(Color::rgb(0x11, 0x11, 0xAA)),
]);
let scene = layout_scene(1, UiSize::new(300.0, 100.0), &root);
let quads: Vec<Quad> = scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(quad) => Some(*quad),
_ => None,
})
.collect();
assert_eq!(
quads,
vec![
Quad::new(
Rect::new(10.0, 10.0, 50.0, 80.0),
Color::rgb(0xAA, 0x11, 0x11)
),
Quad::new(
Rect::new(70.0, 10.0, 70.0, 80.0),
Color::rgb(0x11, 0xAA, 0x11)
),
Quad::new(
Rect::new(150.0, 10.0, 140.0, 80.0),
Color::rgb(0x11, 0x11, 0xAA)
),
]
);
}
#[test]
fn column_layout_reflows_text_and_moves_following_children() {
let root = Element::column()
.padding(Edges::all(10.0))
.gap(10.0)
.children([
Element::text(
"RUIN text nodes should reflow inside narrow layout columns instead of acting like overlays.",
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF))
.with_line_height(20.0)
.with_wrap(TextWrap::Word),
)
.padding(Edges::all(8.0))
.background(Color::rgb(0x22, 0x2C, 0x46)),
Element::new()
.height(40.0)
.background(Color::rgb(0x44, 0x55, 0x66)),
]);
let scene = layout_scene(1, UiSize::new(160.0, 220.0), &root);
let text = scene
.items
.iter()
.find_map(|item| match item {
DisplayItem::Text(text) => Some(text),
_ => None,
})
.expect("layout should emit a text display item");
let quads: Vec<Quad> = scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(quad) => Some(*quad),
_ => None,
})
.collect();
let text_bounds = text.bounds.expect("text layout should provide bounds");
assert_eq!(text.origin.x, 18.0);
assert_eq!(text.origin.y, 18.0);
assert_eq!(text_bounds.width, 124.0);
assert!(text_bounds.height > 20.0);
assert_eq!(quads.len(), 2);
assert!(quads[1].rect.origin.y > text.origin.y + text_bounds.height);
}
#[test]
fn column_container_with_text_children_gets_intrinsic_height() {
let root = Element::column().child(
Element::column()
.padding(Edges::all(12.0))
.background(Color::rgb(0x22, 0x33, 0x44))
.child(Element::paragraph(
"Paragraph containers should not collapse to zero height anymore.",
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
)),
);
let scene = layout_scene(1, UiSize::new(420.0, 240.0), &root);
let quads: Vec<Quad> = scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(quad) => Some(*quad),
_ => None,
})
.collect();
assert!(quads.iter().any(|quad| quad.rect.size.height > 24.0));
assert!(
scene
.items
.iter()
.any(|item| matches!(item, DisplayItem::Text(_)))
);
}
}