Text, many performance improvements
This commit is contained in:
462
lib/ui/src/layout.rs
Normal file
462
lib/ui/src/layout.rs
Normal 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(_)))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,23 @@ pub(crate) mod trace_targets {
|
||||
pub const SCENE: &str = "ruin_ui::scene";
|
||||
}
|
||||
|
||||
mod layout;
|
||||
mod platform;
|
||||
mod runtime;
|
||||
mod scene;
|
||||
mod text;
|
||||
mod tree;
|
||||
mod window;
|
||||
|
||||
pub use layout::{layout_scene, layout_scene_with_text_system};
|
||||
pub use platform::{PlatformClosed, PlatformEvent, PlatformProxy, PlatformRuntime, start_headless};
|
||||
pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
|
||||
pub use scene::{
|
||||
Color, DisplayItem, GlyphInstance, Point, PreparedText, Quad, Rect, SceneSnapshot, Translation,
|
||||
UiSize,
|
||||
};
|
||||
pub use text::{TextAlign, TextStyle, TextSystem, TextWrap};
|
||||
pub use tree::{Edges, Element, FlexDirection, Style};
|
||||
pub use window::{
|
||||
DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Renderer-oriented scene snapshot types.
|
||||
|
||||
use cosmic_text::CacheKey;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::trace_targets;
|
||||
@@ -92,12 +93,16 @@ pub struct GlyphInstance {
|
||||
pub glyph: String,
|
||||
pub position: Point,
|
||||
pub advance: f32,
|
||||
pub cache_key: Option<CacheKey>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PreparedText {
|
||||
pub text: String,
|
||||
pub origin: Point,
|
||||
pub bounds: Option<UiSize>,
|
||||
pub font_size: f32,
|
||||
pub line_height: f32,
|
||||
pub color: Color,
|
||||
pub glyphs: Vec<GlyphInstance>,
|
||||
}
|
||||
@@ -118,13 +123,17 @@ impl PreparedText {
|
||||
glyph: ch.to_string(),
|
||||
position: Point::new(x, origin.y),
|
||||
advance,
|
||||
cache_key: None,
|
||||
});
|
||||
x += advance;
|
||||
}
|
||||
|
||||
Self {
|
||||
text,
|
||||
origin,
|
||||
bounds: None,
|
||||
font_size,
|
||||
line_height: font_size,
|
||||
color,
|
||||
glyphs,
|
||||
}
|
||||
|
||||
251
lib/ui/src/text.rs
Normal file
251
lib/ui/src/text.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
use cosmic_text::{Attrs, Buffer, FontSystem, Metrics, Shaping, Wrap};
|
||||
|
||||
use crate::{Color, GlyphInstance, Point, PreparedText, UiSize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TextAlign {
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TextWrap {
|
||||
None,
|
||||
Word,
|
||||
}
|
||||
|
||||
impl TextWrap {
|
||||
const fn to_cosmic(self) -> Wrap {
|
||||
match self {
|
||||
Self::None => Wrap::None,
|
||||
Self::Word => Wrap::WordOrGlyph,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TextStyle {
|
||||
pub font_size: f32,
|
||||
pub line_height: f32,
|
||||
pub color: Color,
|
||||
pub bounds: Option<UiSize>,
|
||||
pub wrap: TextWrap,
|
||||
pub align: TextAlign,
|
||||
pub max_lines: Option<usize>,
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
pub const fn new(font_size: f32, color: Color) -> Self {
|
||||
Self {
|
||||
font_size,
|
||||
line_height: font_size * 1.2,
|
||||
color,
|
||||
bounds: None,
|
||||
wrap: TextWrap::None,
|
||||
align: TextAlign::Start,
|
||||
max_lines: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_line_height(mut self, line_height: f32) -> Self {
|
||||
self.line_height = line_height;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_bounds(mut self, bounds: UiSize) -> Self {
|
||||
self.bounds = Some(bounds);
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_wrap(mut self, wrap: TextWrap) -> Self {
|
||||
self.wrap = wrap;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_align(mut self, align: TextAlign) -> Self {
|
||||
self.align = align;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_max_lines(mut self, max_lines: usize) -> Self {
|
||||
self.max_lines = Some(max_lines);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextSystem {
|
||||
font_system: FontSystem,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct TextLayout {
|
||||
glyphs: Vec<GlyphInstance>,
|
||||
size: UiSize,
|
||||
}
|
||||
|
||||
impl Default for TextSystem {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextSystem {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
font_system: FontSystem::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
text: impl Into<String>,
|
||||
origin: Point,
|
||||
style: TextStyle,
|
||||
) -> PreparedText {
|
||||
let text = text.into();
|
||||
let layout = self.layout(
|
||||
&text,
|
||||
style,
|
||||
style.bounds.map(|bounds| bounds.width),
|
||||
style.bounds.map(|bounds| bounds.height),
|
||||
);
|
||||
let glyphs = layout
|
||||
.glyphs
|
||||
.into_iter()
|
||||
.map(|glyph| GlyphInstance {
|
||||
glyph: glyph.glyph,
|
||||
position: Point::new(origin.x + glyph.position.x, origin.y + glyph.position.y),
|
||||
advance: glyph.advance,
|
||||
cache_key: glyph.cache_key,
|
||||
})
|
||||
.collect();
|
||||
|
||||
PreparedText {
|
||||
text,
|
||||
origin,
|
||||
bounds: style.bounds,
|
||||
font_size: style.font_size,
|
||||
line_height: style.line_height,
|
||||
color: style.color,
|
||||
glyphs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn measure(
|
||||
&mut self,
|
||||
text: &str,
|
||||
style: TextStyle,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
) -> UiSize {
|
||||
self.layout(text, style, width, height).size
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
text: &str,
|
||||
style: TextStyle,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
) -> TextLayout {
|
||||
let mut buffer = Buffer::new_empty(Metrics::new(style.font_size, style.line_height));
|
||||
{
|
||||
let mut borrowed = buffer.borrow_with(&mut self.font_system);
|
||||
borrowed.set_wrap(style.wrap.to_cosmic());
|
||||
borrowed.set_size(width, height);
|
||||
borrowed.set_text(text, &Attrs::new(), Shaping::Advanced, None);
|
||||
}
|
||||
|
||||
let mut measured_width: f32 = 0.0;
|
||||
let mut measured_height: f32 = 0.0;
|
||||
let mut glyphs = Vec::new();
|
||||
for (line_index, run) in buffer.layout_runs().enumerate() {
|
||||
if matches!(style.max_lines, Some(max_lines) if line_index >= max_lines) {
|
||||
break;
|
||||
}
|
||||
measured_width = measured_width.max(run.line_w);
|
||||
measured_height = measured_height.max(run.line_top + run.line_height);
|
||||
let x_offset = aligned_line_offset(style.align, width, run.line_w);
|
||||
glyphs.extend(run.glyphs.iter().map(move |glyph| {
|
||||
let physical = glyph.physical((x_offset, run.line_y), 1.0);
|
||||
GlyphInstance {
|
||||
glyph: run.text[glyph.start..glyph.end].to_string(),
|
||||
position: Point::new(physical.x as f32, physical.y as f32),
|
||||
advance: glyph.w,
|
||||
cache_key: Some(physical.cache_key),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let measured_width = width.map_or(measured_width, |limit| measured_width.min(limit));
|
||||
let measured_height = height.map_or(measured_height, |limit| measured_height.min(limit));
|
||||
|
||||
TextLayout {
|
||||
glyphs,
|
||||
size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn aligned_line_offset(align: TextAlign, width: Option<f32>, line_width: f32) -> f32 {
|
||||
let Some(width) = width else {
|
||||
return 0.0;
|
||||
};
|
||||
let remaining = (width - line_width).max(0.0);
|
||||
match align {
|
||||
TextAlign::Start => 0.0,
|
||||
TextAlign::Center => remaining * 0.5,
|
||||
TextAlign::End => remaining,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{TextAlign, TextStyle, TextSystem, TextWrap};
|
||||
use crate::{Color, Point, UiSize};
|
||||
|
||||
#[test]
|
||||
fn max_lines_limits_measured_height() {
|
||||
let mut text_system = TextSystem::new();
|
||||
let style = TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
.with_line_height(20.0)
|
||||
.with_wrap(TextWrap::Word);
|
||||
let unclamped = text_system.measure(
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota kappa",
|
||||
style,
|
||||
Some(120.0),
|
||||
None,
|
||||
);
|
||||
let clamped = text_system.measure(
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota kappa",
|
||||
style.with_max_lines(2),
|
||||
Some(120.0),
|
||||
None,
|
||||
);
|
||||
assert!(unclamped.height > clamped.height);
|
||||
assert!(clamped.height <= 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_text_shifts_glyph_positions_within_bounds() {
|
||||
let mut text_system = TextSystem::new();
|
||||
let origin = Point::new(0.0, 0.0);
|
||||
let start = text_system.prepare(
|
||||
"title",
|
||||
origin,
|
||||
TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
.with_bounds(UiSize::new(200.0, 40.0)),
|
||||
);
|
||||
let centered = text_system.prepare(
|
||||
"title",
|
||||
origin,
|
||||
TextStyle::new(20.0, Color::rgb(0xFF, 0xFF, 0xFF))
|
||||
.with_bounds(UiSize::new(200.0, 40.0))
|
||||
.with_align(TextAlign::Center),
|
||||
);
|
||||
assert!(!start.glyphs.is_empty());
|
||||
assert!(!centered.glyphs.is_empty());
|
||||
assert!(centered.glyphs[0].position.x > start.glyphs[0].position.x);
|
||||
}
|
||||
}
|
||||
187
lib/ui/src/tree.rs
Normal file
187
lib/ui/src/tree.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use crate::scene::Color;
|
||||
use crate::text::{TextStyle, TextWrap};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum FlexDirection {
|
||||
Row,
|
||||
Column,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Edges {
|
||||
pub top: f32,
|
||||
pub right: f32,
|
||||
pub bottom: f32,
|
||||
pub left: f32,
|
||||
}
|
||||
|
||||
impl Edges {
|
||||
pub const ZERO: Self = Self {
|
||||
top: 0.0,
|
||||
right: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0.0,
|
||||
};
|
||||
|
||||
pub const fn all(value: f32) -> Self {
|
||||
Self {
|
||||
top: value,
|
||||
right: value,
|
||||
bottom: value,
|
||||
left: value,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn symmetric(horizontal: f32, vertical: f32) -> Self {
|
||||
Self {
|
||||
top: vertical,
|
||||
right: horizontal,
|
||||
bottom: vertical,
|
||||
left: horizontal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Style {
|
||||
pub direction: FlexDirection,
|
||||
pub width: Option<f32>,
|
||||
pub height: Option<f32>,
|
||||
pub flex_grow: f32,
|
||||
pub gap: f32,
|
||||
pub padding: Edges,
|
||||
pub background: Option<Color>,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
direction: FlexDirection::Column,
|
||||
width: None,
|
||||
height: None,
|
||||
flex_grow: 0.0,
|
||||
gap: 0.0,
|
||||
padding: Edges::ZERO,
|
||||
background: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum ElementContent {
|
||||
Container,
|
||||
Text(TextNode),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct TextNode {
|
||||
pub text: String,
|
||||
pub style: TextStyle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Element {
|
||||
pub style: Style,
|
||||
pub children: Vec<Element>,
|
||||
content: ElementContent,
|
||||
}
|
||||
|
||||
impl Element {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
style: Style::default(),
|
||||
children: Vec::new(),
|
||||
content: ElementContent::Container,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(text: impl Into<String>, style: TextStyle) -> Self {
|
||||
Self {
|
||||
style: Style::default(),
|
||||
children: Vec::new(),
|
||||
content: ElementContent::Text(TextNode {
|
||||
text: text.into(),
|
||||
style,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paragraph(text: impl Into<String>, style: TextStyle) -> Self {
|
||||
Self::text(text, style.with_wrap(TextWrap::Word))
|
||||
}
|
||||
|
||||
pub fn row() -> Self {
|
||||
Self::new().direction(FlexDirection::Row)
|
||||
}
|
||||
|
||||
pub fn column() -> Self {
|
||||
Self::new().direction(FlexDirection::Column)
|
||||
}
|
||||
|
||||
pub fn direction(mut self, direction: FlexDirection) -> Self {
|
||||
self.style.direction = direction;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: f32) -> Self {
|
||||
self.style.width = Some(width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn height(mut self, height: f32) -> Self {
|
||||
self.style.height = Some(height);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn flex(mut self, flex_grow: f32) -> Self {
|
||||
self.style.flex_grow = flex_grow.max(0.0);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn gap(mut self, gap: f32) -> Self {
|
||||
self.style.gap = gap.max(0.0);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn padding(mut self, padding: Edges) -> Self {
|
||||
self.style.padding = padding;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn background(mut self, color: Color) -> Self {
|
||||
self.style.background = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn child(mut self, child: Element) -> Self {
|
||||
self.assert_container();
|
||||
self.children.push(child);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = Element>) -> Self {
|
||||
self.assert_container();
|
||||
self.children.extend(children);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn text_node(&self) -> Option<&TextNode> {
|
||||
match &self.content {
|
||||
ElementContent::Text(text) => Some(text),
|
||||
ElementContent::Container => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_container(&self) {
|
||||
assert!(
|
||||
matches!(self.content, ElementContent::Container),
|
||||
"text elements cannot contain children"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Element {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user